How to integrate a crypto on-ramp API: payin quotes, deposit instructions, and webhooks
The on-ramp API flow step by step: verify the customer, pick the wallet, quote, create the payin, show deposit instructions, and handle webhooks.
A crypto on-ramp integration follows six steps: verify the customer, choose the wallet that receives the stablecoins, request a quote, create the payment and show the payer deposit instructions, handle webhooks until the payment settles, and reconcile. The quote locks the rate and fees. Webhooks, not polling, tell you when the fiat landed.
This guide covers the on-ramp direction: fiat in, stablecoins out. For the payout direction, see integrating a stablecoin API. Examples use BlindPay's API on a development instance. Field names are real, but check the payins docs for the current shape before you ship.
Key takeaways
- The quote is the contract. It fixes the amount, fees, and destination for a short window, and the payment must be created inside it.
- Each rail shows the payer something different: bank details and a memo code, a Pix code, a CLABE, a CBU, or a payment link.
- Deposits arrive on the payer's schedule. Design for minutes on Pix and SPEI and days on ACH.
- Verify webhook signatures on the raw body, deduplicate retries, and never move a payment backwards from a final status.
- Force failures in the sandbox before production.
failedandrefundedmean different things for your ledger.
What does an on-ramp integration look like?
Your backend talks to the on-ramp API. The provider talks to the banks and the blockchain. Events come back to you.
Three objects carry the whole flow: a customer (who is paying in), a wallet (where the stablecoins land), and a payin (one deposit, created from a quote). Everything else is detail on those three.
How do you onboard and verify the customer?
Every payin belongs to a verified customer. On BlindPay that takes two calls.
- Terms of service. Generate a terms-of-service URL with
POST /v1/e/instances/{instance_id}/tos, send the customer to it, and keep thetos_idit returns. - Create the customer.
POST /v1/instances/{instance_id}/customerswith thetos_id,type(individualorbusiness), and the KYC or KYB fields.
Then wait. KYC Standard for individuals is automated and takes about 60 seconds. KYB for businesses and KYC Enhanced for high-risk countries are manual reviews of 3 hours to 1 business day. A customer.update webhook tells you when kyc_status changes. Don't request quotes until it reads approved. How to automate KYC and KYB covers the data you need to collect.
Where should the stablecoins land?
Pick the destination before you quote, because the quote needs its ID.
| Destination | ID | Custody | When to use it |
|---|---|---|---|
| External blockchain wallet | bw_... | The customer holds the keys | The customer already has a wallet, or you never want to hold funds |
| Managed wallet (beta) | bl_... | BlindPay custodies the balance | You want a balance without running wallet infrastructure |
| Virtual account | Settles to a linked wallet | Depends on the linked wallet | US payers send ACH or wire to a dedicated account number |
An external wallet is registered once per address. On EVM networks the customer can sign a message so BlindPay recovers the address from the signature. On Stellar, Solana, and Tron you submit the address directly, so validate it twice: a stablecoin sent to the wrong address can't be recovered. The network is read from the wallet record, so you never pass a network on the quote.
How do you request a quote?
The payin quote locks the numbers. Send it the amount, the rail, the token, who pays the fee, and the destination.
Four fields cause most bugs:
request_amountis an integer in minor units.100000is R$1,000.00. For MXN, COP, and ARS it must also be a whole currency unit (a multiple of 100).currency_typesays which side the amount is in. On a payin quote,sendermeans fiat. On a payout quote it means stablecoin. Same field, opposite meaning.cover_feesdecides who pays:falsetakes the fee out of the stablecoins delivered,trueadds it to the fiat the payer sends.payer_rulesnames who may pay. Pix needs the allowed CPF or CNPJ tax IDs, Transfers 3.0 needs a CUIT or CUIL, and PSE needs the payer's name, document, email, phone, and bank code.
The response returns sender_amount, receiver_amount, the market rate (commercial_quotation), the rate with fees (blindpay_quotation), each fee line, and expires_at in epoch milliseconds. You have 5 minutes. Stablecoin API quotes explained covers fee direction and units in depth.
How do you create the payin and show deposit instructions?
Create the payin from the quote, then show the payer what their rail needs.
The path says evm for every method, including Pix, SPEI, PSE, and Transfers. The name is historical; it doesn't restrict the network. The response fills only the field for your rail:
| Payment method | Show the payer |
|---|---|
| ACH, wire | blindpay_bank_details plus the memo_code to include in the transfer |
| Pix | pix_code, as copyable text or a QR code |
| SPEI | The clabe to transfer to |
| Transfers 3.0 | The account (CVU, CBU, or alias) in tracking_transaction.transfers_instruction |
| PSE | The payment link in tracking_transaction.pse_instruction |
If the customer has an approved virtual account, ACH and wire deposits go to that dedicated account and the memo code is ignored. RTP is the exception: it always uses the memo-code path.
A created payin can't be cancelled. If the payer never pays, it fails after the rail's waiting window. Send the idempotency key on every create, so a network retry can't produce two payins. The header follows the IETF Idempotency-Key draft, and why idempotency keys matter covers the failure it prevents.
How do you handle on-ramp webhooks?
Three events cover a payin's life:
payin.newwhen the payin is created, including deposits into a virtual account.payin.updateat intermediate steps, such as an arrival check or manual review.payin.completewhen it finishes: delivered, refunded, or failed.
Every call is signed with svix-id, svix-timestamp, and svix-signature headers. Verify the signature on the exact raw bytes, before you parse the JSON, following Svix's verification guide. The svix-id stays the same across redeliveries of one event, so it's your deduplication key. Events can arrive out of order, so store a status only if it moves the payment forward, and never overwrite completed, failed, or refunded. Stablecoin API webhooks has the code-level detail.
How do you reconcile on-ramp payments?
Treat webhooks as the fast path and a daily job as the truth.
The payin's top-level status is the source of truth: processing, on_hold, completed, failed, or refunded. Four tracking_* objects (tracking_transaction, tracking_payment, tracking_complete, tracking_partner_fee) expose a finer step for status screens. Each day, list payins with GET /v1/instances/{instance_id}/payins and compare them with your ledger: amounts, statuses, fees. Match on the payin ID, not the transaction hash, because a transaction replaced during a gas spike can land under a different hash.
What edge cases should you handle?
These show up in the first week of production:
- Expired quote. The payin create fails. Request a new quote; don't retry the old one.
- Amount out of range. Payin quotes enforce a minimum and maximum per currency, and the customer's per-transaction limit applies too. Read the error for the range instead of hardcoding it.
- The payer never pays. Pix, SPEI, Transfers, and PSE payins wait up to 30 minutes before failing. ACH and wire wait up to 5 business days.
- On hold. Transaction monitoring can hold a payin for review. Answer any request for information quickly; an unanswered one may end in a refund.
- Refunded vs failed.
refundedmeans the deposit went back to the sender. Fiat refunds wait on the bank network and can carry fees.failedneeds a look before you tell the customer anything. - Wrong destination. A wallet registered with the wrong address receives the stablecoins anyway. Validate addresses at registration, not at delivery.
How do you test an on-ramp integration?
Development instances run the whole flow without real money. Payins complete automatically about 30 seconds after creation and deliver USDB, a test stablecoin, on testnets such as Sepolia, Base Sepolia, Polygon Amoy, Stellar testnet, and Solana devnet.
Force the outcomes you can't wait for in real life: a payin for 666.00 fails and one for 777.00 is refunded. Run both through your webhook handler and ledger. Two things the sandbox can't show you: Tron has no testnet, and real deposits arrive on real rail timing. Plan one small supervised payin per rail in production. Sandbox vs production lists the rest.
Abstracted or Advanced: which API flavor should you use?
BlindPay's docs come in two flavors over the same API, the same keys, and the same webhooks.
- Abstracted is for teams that think in bank payments. Payins are deposits, virtual accounts are deposit accounts, and the stablecoin settles behind the scenes in the linked wallet.
- Advanced exposes the stablecoin layer: chains, tokens, managed and external wallets, and on-chain authorization for payouts.
Choose Abstracted if your users never see a wallet address. Choose Advanced if your product is a wallet, or if your users pick the network. You can switch at any time; nothing about your account changes.
How do you use a coding agent to build the integration?
AI coding agents are good at this kind of work, if they get the API's rules up front. BlindPay ships three surfaces for them:
- An MCP server (
@blindpay/mcp) that exposes the API as tools for Claude Code, Codex, Cursor, and other MCP clients. - Agent Skills that teach the agent the API's conventions.
- Prebuilt prompts, including a payin quickstart prompt that encodes the gotchas (minor units, the 5-minute quote window, the
evmpath).
A prompt that works: "Using the BlindPay API on a development instance, build a payin flow for Pix: create a customer, register a Solana wallet, request a payin quote with cover_fees: false, create the payin, render the Pix code, and handle payin.* webhooks with Svix signature verification and deduplication on svix-id. Write tests for completed, failed (666.00), and refunded (777.00)." The build with AI page has the setup.
What to do next
Get a development instance, run one Pix or ACH payin end to end, and force a failure and a refund. If your handler and ledger stay correct through all three, you're ready for the production checklist. If you're still choosing a provider, read business vs consumer crypto on-ramps and crypto on-ramp fees explained first.
If you need consumer coverage in many countries instead, on-ramp aggregators vs a direct ramp API explains when Onramper or Meld make sense.
For a copy-paste version of this flow, follow the stablecoin payin API integration quickstart.
This article is general information, not legal, tax, or financial advice. API fields and behavior change; the BlindPay docs are the reference.
FAQ
More payments articles
See all payments articlesOn/off ramp liquidity with live quotes: how it works and why it matters
What on/off ramp liquidity with live quotes means for developers: live quote vs batch rate, the quote ID flow in an API, what happens on expiry, how liquidity depth changes spread by transaction size, rate windows and UX, and a checklist for evaluating a live quote API.
How crypto exchanges add local-currency withdrawals with an off-ramp API
How exchanges, brokers, and wallets let users withdraw to Pix, SPEI, ACH, or SEPA: the flow, the quote screen, the compliance split, and refunds.
How virtual accounts automate payment reconciliation (with a worked example)
Virtual accounts match each deposit to a customer by account number, not a free-text reference. A worked example with partial payments and micro-deposits.