Skip to content

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. Only open sessions 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-Key per 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:

JSON
{
  "id": "cs_test_8f2k",
  "object": "checkout.session",
  "url": "https://pay.example.com/pay/cs_test_8f2k",
  "status": "open",
  "payment_status": "unpaid",
  "amount": 4250,
  "currency": "usd",
  "line_items": [{ "name": "Espresso blend", "quantity": 1, "unit_amount": 4250 }],
  "success_url": "https://shop.example.com/success",
  "cancel_url": "https://shop.example.com/cart",
  "metadata": { "order_id": "1042" },
  "expires_at": 1793000000,
  "payment": null
}

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 stateEntered whenCan it expire?Webhook to the merchant
openSession created, no payin yetYes, at expires_atcheckout.session.expired if it does
processingShopper confirmed and a BlindPay payin existsNoNone yet
completePayin status is completedNocheckout.session.completed
failedPayin status is failed or refundedNocheckout.session.payment_failed
expiredAn open session passed expires_atTerminalAlready 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.

  1. Shopper opens the session URL. Show the order summary in USD. No quote yet.
  2. Shopper picks a rail and enters payer details. Now request a payin quote with currency_type: "receiver" and cover_fees: true, so the merchant receives the session amount and the shopper's sender_amount includes fees and exchange.
  3. Show the local price with a countdown to the quote's expires_at (epoch milliseconds).
  4. 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.
  5. 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.
  6. 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_flight with a Retry-After header.

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, or rtp.
  • 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_id on 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

One API.
Multiple payment rails.

Blind Pay, Inc. (“BlindPay”) is a United States-based financial technology and payments infrastructure company, incorporated in the State of Delaware on May 2, 2024. BlindPay is duly registered as a Money Services Business (“MSB”) with the Financial Crimes Enforcement Network (“FinCEN”) under registration number 31000277566153. BlindPay is not a state-licensed money transmitter and does not, in its own capacity, engage in regulated money transmission services. All such regulated activities are conducted exclusively through, and under the licenses of, duly authorized financial institution partners. BlindPay's role is limited to providing financial technology, virtual account management, payment processing, and related payments infrastructure services that facilitate payment flows and account functionalities. These services are provided in coordination with its licensed partners and in compliance with applicable federal MSB requirements and relevant state regulatory frameworks.

Blind Pay, LLC (“BlindPay”) is a Delaware limited liability company formed on June 24, 2025. BlindPay is duly registered as a Money Services Business (“MSB”) with the Financial Crimes Enforcement Network (“FinCEN”) under registration number 31000318334247 and is listed in the Nationwide Multistate Licensing System (“NMLS”) under NMLS ID #2745309.

BLIND PAY SOCIEDADE PRESTADORA DE SERVIÇOS DE ATIVOS VIRTUAIS LTDA. (“BlindPay”) is a Virtual Asset Service Provider (“VASP”) operating in Brazil. The company is presently completing the regulatory adaptation process required of Virtual Asset Service Provider Companies (SPSAV) under Central Bank of Brazil Resolution No. 520/2025, and remains subject to the transitional regime set forth in Article 88 of that Resolution. Registered address: Avenida Princesa Isabel, nº 15, Sala 1405, Edifício Martinho de Freitas, Centro, Vitória/ES, 29010-361.

BlindPay is a stablecoin API that enables individuals and businesses to move money worldwide.