---
title: "Designing a checkout sessions API for local bank payments"
seoTitle: "Checkout sessions API design for local bank payments"
description: "How to design a checkout sessions API over local bank rails in 2026: session states, quote vs session lifetime, idempotency, and webhooks."
date: "2026-10-23"
updated: "2026-10-23"
category: "payments"
author: "BlindPay Team"
faq:
  - q: "What is a checkout session?"
    a: "A checkout session is the object a merchant creates on its backend for one purchase. It holds the amount, currency, line items, success and cancel URLs, and an expiry, and it returns a URL for a hosted payment page. The shopper pays on that page, and the session moves through states until it is complete, failed, or expired, with a webhook at the end."
  - q: "Why should a processing session never expire?"
    a: "Because the payment behind it cannot be undone from your side. On BlindPay, a payin cannot be canceled once created; if the shopper never pays, it stays processing until BlindPay cleans it up. If your session expired while a transfer was still in flight, the shopper could pay a dead order. Expire only open sessions, and let processing ones finish."
  - q: "How long should a checkout session last compared to a quote?"
    a: "Longer, and they should be separate. A BlindPay payin quote locks the rate and fees for 5 minutes, while a session can stay open for an hour or a day. Quote on the click that shows the price, re-quote silently when the countdown ends, and bind the session to one payin only when the shopper confirms."
  - q: "How do I stop a double click from creating two payments?"
    a: "Send an Idempotency-Key header tied to the session on the call that creates the payin. BlindPay replays the original response for the same key and the same body, returns 409 while the first request is still in flight, and 422 if the body changed. Your own API should accept an Idempotency-Key on session creation too."
  - q: "Should my checkout poll BlindPay or use webhooks?"
    a: "Use webhooks in production and keep polling as a fallback. BlindPay sends payin.new, payin.update, and payin.complete, signed with svix headers you verify on every call. payin.complete fires when the payin finishes, so read its status. A sync job that fetches open payins covers missed deliveries, and your pages should poll your own API, never BlindPay directly."
---

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](/resources/more/what-is-a-global-payments-checkout). The shopper-facing half lives in [bank transfer checkout UX](/resources/more/checkout-payment-instructions-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](https://docs.stripe.com/payments/checkout), 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 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.

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](/resources/more/handle-expired-quotes-typescript-python) 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](/docs/learn/idempotency):

- 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](/resources/more/stablecoin-api-idempotency-keys) 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](/resources/more/stablecoin-api-webhooks-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](https://baymard.com/lists/cart-abandonment-rate). 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](https://www.banxico.org.mx/services/interbanking-electronic-payme.html) after approval, and [Pix](https://www.bcb.gov.br/en/financialstability/pix_en) 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](https://docs.stripe.com/payments/checkout). Abandonment data comes from [Baymard](https://baymard.com/lists/cart-abandonment-rate). Rail timing comes from [Banco de México](https://www.banxico.org.mx/services/interbanking-electronic-payme.html) and the [Banco Central do Brasil](https://www.bcb.gov.br/en/financialstability/pix_en). BlindPay behavior comes from the [payins](/docs/payins), [payin quotes](/docs/payin-quotes), [idempotency](/docs/learn/idempotency), and [webhooks](/docs/learn/webhooks) docs, as of October 2026. For pricing in local currency, see [local currency pricing at checkout](/resources/more/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.
