Overview
Custom checkout (ui_mode: "custom") is for headless / server-to-server flows where you render the entire payment experience yourself instead of redirecting to Bachs’s hosted page. You drive the flow through a small set of endpoints on a single checkout resource, and Bachs returns the instructions the customer must act on (a bank account to transfer into, a mobile-money prompt, or a crypto address).
Use custom checkout when:
- You’re building a custom or embedded payment experience inside your app
- You want full control over the UI and branding
- You’re building a native mobile integration
This flow replaces the deprecated one-shot
POST /v1/payments/whitelabel endpoint. Instead of one call, you now use a small, explicit lifecycle on /v1/checkout-sessions/{id}, which gives you a stable checkout id, a payment_status you can branch on, and price locking. See Migrating from /v1/payments/whitelabel.The flow
1
Create the checkout
POST /v1/checkout-sessions with ui_mode: "custom". Returns a checkout_id. No hosted URL is returned. You render everything.2
Set the payment method & get a price
PATCH /v1/checkout-sessions/{id} with the chosen method + currency. Bachs prices it, locks that price for a short window, and returns the amount, fee, and rate to show the customer.3
Confirm
POST /v1/checkout-sessions/{id}/confirm when the customer commits. Returns a next_step describing what the customer must do (transfer, approve, or submit an OTP).4
Advance (if needed)
For OTP flows,
POST /v1/checkout-sessions/{id}/next with the OTP. Bank transfer and STK-push flows skip this.5
Poll or receive a webhook
GET /v1/checkout-sessions/{id} returns the live payment_status, or subscribe to webhooks for automatic updates.Authentication
Type: API Key (required)
Scope required:
payments:write (for create / patch / confirm / next), payments:read (for the status GET).
Step 1: Create the checkout
View request fields
View request fields
string
required
Set to
custom for the server-to-server flow. (standard returns a hosted URL; embedded is reserved for future use.)object
Raw pricing. Supply exactly one pricing source:
pricing, or product_cart / line_items (products).string
Merchant base currency (e.g.
USD, NGN).string
Amount in the base currency.
array
Existing product IDs + quantities. Alternative to
pricing.array
Inline product details created on the fly (hidden from your catalog). Alternative to
pricing.object
required
Either
{ "customer_id": "cust_..." } for an existing customer, or { "email": "...", "name": "..." } to create one.array
Restrict which corridors this checkout accepts (e.g.
NGN_BANK_TRANSFER, MOMO_GHS, CRYPTO). Any card corridor (USD_CARD, NGN_CARD) is rejected for custom. See Restrict payment methods for the full corridor list.string
Your idempotent client reference (unique per organization).
object
Custom metadata (max 20 keys, 10KB).
Response
checkout_url is null for custom checkouts: there is no hosted page. You own the UI. Keep the checkout_id; every following step targets it.Step 2: Set the payment method & lock a price
View request fields
View request fields
string
required
An exact non-card corridor:
NGN_BANK_TRANSFER, a mobile-money corridor (e.g. MOMO_GHS), or CRYPTO.string
required
The currency the customer pays in (e.g.
NGN, GHS, USDT_TRC20).string
Optional. The specific network/rail (e.g.
mtn_momo_gh). If omitted, Bachs auto-selects the default rail for the method + currency. See Discovering rails.Response
string
The total the customer will pay (base + fee, where the customer bears it). Show this number to the customer.
string
Fee amount.
0 when your organization absorbs the fee.string|null
FX rate used.
1.0 for same-currency payments (e.g. NGN → NGN).string
When the locked price lapses. Confirm before this to guarantee the shown price. See Price locking & expiry.
You don’t manage a
quote_id. PATCH prices the checkout and holds that price internally. Confirm only references the checkout, and the price is honored automatically within the window.Step 3: Confirm (initiate payment)
Call this when the customer clicks “Pay”. This is the point where money starts moving.View request fields
View request fields
string
Required for mobile money. The customer’s wallet number.
string
Optional. Provide
payment_method + currency (the same exact-corridor values as PATCH) to price and confirm in one call (skipping PATCH).string
Optional. Required only if you skipped PATCH.
string
Optional / advanced. Omit it to use the locked price from PATCH.
string
Sandbox only.
success, failed, or underpaid.Response
next_step: the single, typed object that tells you what to render next.
The next_step object
next_step is discriminated by type. Key on it to know which payload is populated and what the customer must do:
Every
next_step also includes a poll_url (where to read status) and an expires_at.
bank_account (display_instructions)
bank_account (display_instructions)
mobile_money (approve_on_phone / submit_otp)
mobile_money (approve_on_phone / submit_otp)
Step 4: Advance a payment (/next)
Only needed for OTP flows (next_step.type: "submit_otp"). Bank transfer and STK-push flows go straight to polling.
View request fields
View request fields
Response
next_step. Keep advancing until type is poll or done.
Step 5: Poll the status
payment_status for the customer-facing lifecycle, and charge.status for the underlying charge. Prefer webhooks over polling where possible.
payment_status lifecycle
A status derived from the checkout and its charge/attempt:
Price locking & expiry
PATCH locks the price it returns for a short window (quote_expires_at). Behavior at confirm:
- Within the window → the locked price is honored.
- After the window → Bachs re-prices at current rates. If the total hasn’t changed, confirm proceeds. If it has changed, confirm returns
409 CHECKOUT_PRICE_CHANGEDwith the new amount. A fresh price is already locked, so show the customer the new amount and confirm again.
Discovering payment rails
payment_rail is optional. Omit it and Bachs auto-selects the default rail for the method + currency. To show the customer a choice of networks (mobile money) or banks, list the available rails:
id, name) you can present. See List Payment Rails. Bank transfer via virtual accounts and crypto typically have no meaningful rail to choose, so skip the picker.
Which methods and currencies your account can accept is configured during onboarding. There is no per-checkout discovery endpoint for enabled methods.
Example: end-to-end (bank transfer, NGN)
Sample.js
Errors
400 Bad Request
400 Bad Request
Validation or state errors, e.g. a card corridor (
USD_CARD, NGN_CARD) on a custom checkout, missing payment_method, or an explicitly supplied expired quote_id.409 Price Changed
409 Price Changed
The locked price expired and moved. Show the new amount (in
details) and confirm again. A fresh price is already locked.Testing
In sandbox (sk_sandbox_...), pass simulated_outcome on confirm to control the final result:
1
Create + price + confirm
Run the flow with
sk_sandbox_... keys.2
Force an outcome
Include
"simulated_outcome": "success" (or failed / underpaid) on the confirm call.3
Observe status
Poll
GET /v1/checkout-sessions/{id} or receive the webhook for the simulated result.Migrating from /v1/payments/whitelabel
The old one-shot POST /v1/payments/whitelabel endpoint has been removed. Map it to the new lifecycle:
Card was never supported by whitelabel, and still isn’t here.
Related endpoints
- List Payment Rails: discover rails for a method + currency
- Get Charge Status: charge-level status
- Webhook Events: receive payment updates automatically

