Skip to main content

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
  • Non-card methods only. Custom checkout supports bank transfer, mobile money, and crypto. Card is not supported here (it requires client-side card capture / PCI handling). For cards, use standard hosted checkout.
  • Access is gated. To enable custom checkout in production, email hello@bachs.io to discuss your use case.
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

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

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.
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

The response always carries a 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.
string
Account the customer transfers into.
string
Destination account name.
string
Destination bank.
string
Bank identifier code.
string
The reference for this payment, for reconciliation and support queries. Display it prominently.
string
enter_otp or approve_on_phone.
string|null
OTP, STK_PROMPT, etc. When OTP, next_step.type is submit_otp.
string
Human-readable instruction to display.
string
The mobile money rail handling the payment.
string
Destination wallet the customer sends to.
string
Digital asset to send (e.g. USDT).
string
Blockchain network required.
string|null
URL to a QR code image, if available.

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.
string
required
submit_otp, resend_otp, or resend_stk.
string
Required when action is submit_otp. The OTP the customer received.

Response

Returns the next next_step. Keep advancing until type is poll or done.

Step 5: Poll the status

Read 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_CHANGED with the new amount. A fresh price is already locked, so show the customer the new amount and confirm again.
To refresh proactively, call PATCH again. It returns the current price and resets the lock. For FX-sensitive flows, always render the amount from the latest PATCH/confirm response rather than a value you cached earlier.

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:
Returns the 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
For mobile money with OTP, add a step between 4 and 5:

Errors

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.
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.