Designing a checkout sessions API for local bank payments
How to design a checkout sessions API over local bank rails in 2026: session states, quote vs session lifetime, idempotency, and webhooks.
A checkout sessions API for local bank payments needs one object per purchase, five states, and a firm rule: a session that has a payment in flight never expires. Keep the quote, which lasts 5 minutes, separate from the session, which can last hours. Make every create call idempotent, and finish each session with a signed webhook.
This is the developer chapter of the series on what a global payments checkout is. The shopper-facing half lives in bank transfer checkout UX.
Key takeaways
- Model five session states:
open,processing,complete,failed,expired. Onlyopensessions expire. - A BlindPay payin quote lasts 5 minutes and a payin cannot be canceled, so bind the session to the payin only on confirm.
- One
Idempotency-Keyper user action prevents duplicate payins; BlindPay accepts keys up to 255 characters. - BlindPay webhooks (
payin.new,payin.update,payin.complete) are signed with svix headers; your own merchant webhooks need their own signature. - Stripe's Checkout Sessions API is the model most developers already know, which makes it a good shape to copy.
What should a checkout session object contain?
A session holds everything about one purchase except the payment itself. The payment attaches later, once the shopper picks a rail and confirms.
Developers already know this shape from Stripe's Checkout Sessions API, which backs a hosted page, an embedded form, and Elements. Copying the familiar shape lowers the integration cost for every merchant on your platform.
A minimal session, created from the merchant's backend:
Amounts stay in integer minor units end to end. BlindPay's request_amount is minor units too, so 4250 means US$42.50 everywhere, with no floating point anywhere in the path.
Once the shopper pays, payment fills in: the rail, the local currency, the local amount, a status, and the BlindPay payin id for support and reconciliation.
Which states does a session move through?
Five states cover every bank transfer outcome. The rule that matters most is when expired is allowed.
| Session state | Entered when | Can it expire? | Webhook to the merchant |
|---|---|---|---|
open | Session created, no payin yet | Yes, at expires_at | checkout.session.expired if it does |
processing | Shopper confirmed and a BlindPay payin exists | No | None yet |
complete | Payin status is completed | No | checkout.session.completed |
failed | Payin status is failed or refunded | No | checkout.session.payment_failed |
expired | An open session passed expires_at | Terminal | Already sent |
Why can't processing expire? On BlindPay, a payin cannot be canceled once created. If the shopper never pays, the payin simply stays processing until BlindPay cleans it up. If a Pix code or a CLABE is already on the shopper's screen, the money can still arrive, so the session has to be able to receive it.
Map one more BlindPay status carefully. on_hold means manual risk or compliance review, not a final answer. Keep the session processing and tell the shopper the payment is being reviewed.
How do quote lifetime and session lifetime fit together?
Treat the quote as disposable and the session as durable. A BlindPay payin quote locks the exchange rate and fees for 5 minutes; a session can stay open for 30 minutes to a day.
- Shopper opens the session URL. Show the order summary in USD. No quote yet.
- Shopper picks a rail and enters payer details. Now request a payin quote with
currency_type: "receiver"andcover_fees: true, so the merchant receives the session amount and the shopper'ssender_amountincludes fees and exchange. - Show the local price with a countdown to the quote's
expires_at(epoch milliseconds). - If the countdown ends, re-quote on the next click. No error, just the new amount. Handling expired quotes has retry code in TypeScript and Python.
- Shopper confirms. Create the payin from the live quote. From this point the session is bound to one payin and the rail can no longer change.
- Show the instructions for that rail and wait for the deposit.
If a quote fails because the amount is under a rail's minimum, the error names the limit. Show it under that rail and let the shopper pick another one.
Where does idempotency belong?
On every call that can create money movement, on both your API and BlindPay's. A double click, a flaky mobile connection, or a retrying worker should never produce two payins.
Your merchant API should accept an Idempotency-Key on session creation and return the same session for a repeated key. Inside your backend, send BlindPay an Idempotency-Key tied to the session when you create the payin. BlindPay's rules, from its idempotency docs:
- Same key, same body, original finished: the original response replays with
Idempotency-Replayed: true, and nothing runs twice. - Same key, different body:
422 idempotency_key_payload_mismatch. - Same key while the first request is in flight:
409 idempotency_key_in_flightwith aRetry-Afterheader.
The body check is a SHA-256 hash of the raw bytes, so serialize the retry exactly as the original. Never reuse a key for a new quote. Idempotency keys in a stablecoin API goes deeper on key design.
How should webhooks flow in and out?
Two webhook streams meet in your backend: BlindPay's events coming in, and your session events going out to merchants.
In, from BlindPay. Register an endpoint and subscribe to payin.new, payin.update, and payin.complete. Verify the svix-id, svix-timestamp, and svix-signature headers on every call before you trust a payload. payin.complete fires when the payin finishes, so read status to decide between complete and failed. Webhook signature checks and reconciliation covers duplicates and ordering.
Out, to merchants. Sign each event with a per-endpoint secret, for example HMAC-SHA256 over the timestamp plus the raw body, and send the timestamp in the header so receivers can reject stale events. Retry non-2xx responses with backoff and keep a delivery log merchants can read.
Fallback. Run a sync job that lists open payins and fetches any missing ones, in case a delivery is lost. Your hosted page should poll your own API every few seconds, never BlindPay. And for a payer-facing receipt, BlindPay's GET /v1/e/payins/{id} returns payin status without an API key.
Errors on the payment page cost conversions. Baymard found that 17% of US shoppers abandoned an order because the website had errors or crashed. A state machine with no gaps is the cheapest fix.
How does BlindPay fit under a sessions API?
BlindPay provides the payment primitives; your sessions API is the product your merchants integrate. Each session maps onto BlindPay objects:
- Merchant = a BlindPay business customer, onboarded with terms of service and KYB, holding a wallet for its balance.
- Price shown to the shopper = a payin quote, valid 5 minutes, on one rail:
pix,spei,transfers,pse,ach,wire, orrtp. - Payment = a payin created from that quote with one endpoint for every rail,
POST /v1/instances/{instance_id}/payins/evm. - Platform revenue = a partner fee, passed as
partner_fee_idon each payin quote. - Merchant withdrawal = a payout quote and payout over ACH, wire, RTP, or SWIFT (POBO/COBO).
The rails themselves are quick. Banco de México says a SPEI payment should take no more than 30 seconds after approval, and Pix runs 24/7, so most sessions finish while the shopper is still on the page. On a development instance every payin completes about 30 seconds after creation, and request_amount 66600 or 77700 forces failed or refunded, so you can test every branch of the state machine. Pricing is a monthly plan plus per-transaction fees disclosed in the live quote.
Who this is for: platform engineers building a hosted checkout for many merchants, or a single company adding local bank payments behind its own checkout.
Methodology and sources
API patterns compare against Stripe's Checkout docs. Abandonment data comes from Baymard. Rail timing comes from Banco de México and the Banco Central do Brasil. BlindPay behavior comes from the payins, payin quotes, idempotency, and webhooks docs, as of October 2026. For pricing in local currency, see local currency pricing at checkout.
What to do next
Write your session state machine as a table before you write code, with one row per BlindPay payin status. Then build it against a development instance and force each outcome with 66600 and 77700 until every row has a test.
FAQ
More payments articles
See all payments articlesHow to add stablecoin payments to your wallet integration
Connect the wallets you already run to bank deposits and local payouts: register addresses, open virtual accounts, quote, authorize, and track webhooks.
Neobank API quickstart: from sign-up screen to first deposit and first payout
Map every neobank screen to a BlindPay API call: terms, KYC, wallet, US account details, first deposit, bank account, quote, payout, and webhooks.
Multi-currency accounts without local entities: how a neobank collects in USD, MXN, COP, and BRL
How a neobank gives users account details in USD, MXN, and COP and local top-ups in BRL and ARS without an entity per country, and where the FX happens.