> ## Agent Instructions
> Money is always a decimal string at the currency's precision (for example "29.00"), paired with an ISO 4217 currency field. Never use minor units.
> Build against the sandbox first: base URL https://sandbox-api.bachs.io with sk_sandbox_ keys. Production is https://api.bachs.io with sk_live_ keys; going live is a key swap.
> Treat webhooks (for example collection.succeeded) as the source of truth for fulfilment, never client-side events or redirects.
> Subscriptions are created by completing a checkout for a recurring product. There is no direct create-subscription endpoint.
> IDs carry resource prefixes (cust_, prod_, sub_, chk_, inv_, ref_) and timestamps are ISO 8601 UTC.
# Create an account
Source: https://docs.bachs.io/api-reference/accounts/create-an-account
/docs/openapi/openapi.json post /v1/accounts
Create an account under your platform. The account starts with nothing enabled: the capabilities you request here decide which requirements it is given, and a person enables each capability once those requirements are satisfied. Requires an active `connect` capability on your own platform, and an account cannot create accounts of its own. See [Create an account](/connect/accounts).
# Create an account link
Source: https://docs.bachs.io/api-reference/accounts/create-an-account-link
/docs/openapi/openapi.json post /v1/accounts/{account_id}/account-links
Issue a hosted link that walks an account through its outstanding requirements. Creating a link invalidates any outstanding active link of the same `type` for that account, so create one at the moment you redirect rather than on every page render. Requires an active `connect` capability on your own platform. See [Onboarding](/connect/onboarding).
# Get an account
Source: https://docs.bachs.io/api-reference/accounts/get-an-account
/docs/openapi/openapi.json get /v1/accounts/{account_id}
Read an account: your own, or one you own. A platform account and an account you own are the same object, differing only by whether they have a parent, so one path serves both. `capabilities` and `requirements` always come back; `include=requirements.values` adds what has been submitted.
# Retrieve balances
Source: https://docs.bachs.io/api-reference/accounts/get-balances
/docs/openapi/openapi.json get /v1/balances
Return account balance buckets by currency, including available, locked, and pending amounts, plus a consolidated USD total.
# List capabilities
Source: https://docs.bachs.io/api-reference/accounts/list-capabilities
/docs/openapi/openapi.json get /v1/accounts/{account_id}/capabilities
List every capability applicable to an account, including ones it has never requested. A capability with no record reports `unrequested` rather than being omitted, so you can tell "never asked for" apart from "turned off". See [Capabilities](/connect/capabilities).
# Update an account
Source: https://docs.bachs.io/api-reference/accounts/update-an-account
/docs/openapi/openapi.json post /v1/accounts/{account_id}
The one write path for an account, yours or one you own. Set its profile and contact details, request capabilities, and supply requirement values in a single call. Omitted keys are left alone.
Each newly requested capability applies the configuration it belongs to, lands as `pending`, and surfaces the requirements it needs. Requesting authorizes nothing: a person enables the capability once those requirements are satisfied. Capabilities cannot be withdrawn once requested. See [Capabilities](/connect/capabilities).
# API Standards
Source: https://docs.bachs.io/api-reference/api-standards
The conventions every Bachs API endpoint follows: authentication, request and response format, HTTP status codes, and query parameters.
The Bachs API is organized around REST. It uses predictable resource-oriented URLs, accepts and returns JSON, and relies on standard HTTP verbs and status codes. Every endpoint in this reference follows the conventions on this page, so you only need to learn them once.
## Base URL
All requests go to a versioned base URL. The current version is `v1`.
| Environment | Base URL |
| - | - |
| Production | `https://api.bachs.io` |
| Sandbox | `https://sandbox-api.bachs.io` |
The sandbox is fully isolated from production. Data, keys, and charges created there never affect real money. See [Sandbox](/integrate/sandbox).
## Authentication
Every request must include your secret key in the `Authorization` header as a Bearer token.
```http theme={"dark"}
Authorization: Bearer sk_live_...
```
Use `sk_sandbox_...` keys against the sandbox base URL and `sk_live_...` keys against production. Keys are scoped: a key can only call the endpoints its scopes permit. A request with a missing or invalid key returns `401`; a valid key without the required scope returns `403`. See [Authentication](/authentication) and [Permissions](/api-reference/permissions).
Never expose secret keys in client-side code, public repositories, or logs. Rotate a key immediately if it is compromised.
## Request format
Send request bodies as JSON with a `Content-Type: application/json` header. Set `Accept: application/json` to receive JSON responses.
```bash theme={"dark"}
curl https://api.bachs.io/v1/products \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{ "name": "Pro plan", "price": { "price_type": "fixed", "amount": "10.00", "currency": "USD" } }'
```
A few conventions hold across every endpoint:
* **Money is a decimal string.** Amounts are sent and returned as strings with two decimal places in the currency's major unit: `"29.00"` for \$29.00, `"75000.00"` for ₦75,000.00. Never send a number, and never use minor units (cents).
* **Timestamps are ISO 8601.** All dates and times are UTC strings, e.g. `"2026-04-27T12:00:00Z"`.
* **IDs are prefixed strings.** Every resource ID carries a type prefix, e.g. `prod_...` for products, `cust_...` for customers, `chk_...` for checkout sessions. Treat IDs as opaque; do not parse them.
## Response format
Successful responses return a JSON object with an HTTP `2xx` status. Single-resource endpoints return the resource object directly. Endpoints that return a list wrap results in `items` alongside a `pagination` object. See [Success responses](/api-reference/success-responses) and [Pagination](/guides/pagination).
## HTTP status codes
Bachs uses conventional HTTP status codes to indicate the result of a request. In general, `2xx` means success, `4xx` means the request was rejected for a reason you can fix, and `5xx` means something went wrong on our side.
| Code | Meaning |
| - | - |
| `200 OK` | The request succeeded. |
| `201 Created` | A new resource was created. |
| `400 Bad Request` | The request was malformed or a parameter was invalid. Request validation errors (`VALIDATION_ERROR`) may include a field-level `errors` array. |
| `401 Unauthorized` | The API key is missing or invalid. |
| `403 Forbidden` | The key is valid but lacks the required scope. |
| `404 Not Found` | The resource does not exist. |
| `409 Conflict` | The request conflicts with the current state, e.g. a duplicate `reference`. |
| `422 Unprocessable Entity` | The request was well-formed, but a business rule rejected it. |
| `429 Too Many Requests` | You have exceeded your rate limit. See `Retry-After`. |
| `500 Server Error` | Something went wrong on our end. A write may still have taken effect; follow [error recovery](/errors#handling-errors) before repeating it. |
Every `4xx` response carries a machine-readable `error_code` you can branch on. See [Errors](/errors) for the shape and the full [error code reference](/api-reference/error-reference).
## Query parameters
List endpoints accept query parameters to filter and page through results. Parameters are simple key/value pairs appended to the URL.
```bash theme={"dark"}
curl "https://api.bachs.io/v1/products?limit=20&include_archived=true" \
-H "Authorization: Bearer $BACHS_API_KEY"
```
Common conventions:
* **Pagination.** List endpoints accept a `limit` (max `100`; values above are clamped, not rejected) and page with either a `cursor` or an `offset`, depending on the endpoint. Each endpoint's reference page states its default `limit` and which paging style it uses. See [Pagination](/guides/pagination).
* **Booleans.** Pass `true` or `false`, e.g. `include_archived=true`.
* **Unknown parameters are ignored.** An unrecognized query parameter does not error; it is silently dropped.
## Idempotency
Public `POST` and `PATCH` requests under `/v1/` support an `Idempotency-Key` header. Successful responses are cached for matching retries; a timeout or 5xx still requires outcome reconciliation. `DELETE` is not covered by this middleware. See [Idempotency](/guides/idempotency).
## Rate limits
Requests are rate limited per API key, so each key has its own budget.
| Environment | Limit |
| - | - |
| Production | 500 requests per minute |
| Sandbox | 100 requests per minute |
Every response includes headers describing your current budget:
| Header | Meaning |
| - | - |
| `X-RateLimit-Limit` | The maximum requests allowed in the current window, e.g. `500`. |
| `X-RateLimit-Remaining` | Requests remaining in the current window, e.g. `497`. |
| `X-RateLimit-Reset` | Unix timestamp (in seconds) when the window resets, e.g. `1783976340`. |
| `Retry-After` | On a `429`, the number of seconds to wait before retrying, e.g. `5`. |
If you exceed the limit you receive a `429 Too Many Requests`. Read `Retry-After` and wait that many seconds before retrying.
# The balance object
Source: https://docs.bachs.io/api-reference/balance/object
A balance snapshot across every currency an account holds, separating what is spendable now from what is still settling.
# Retrieve a checkout session
Source: https://docs.bachs.io/api-reference/checkout-sessions/get-checkout-session
/docs/openapi/openapi.json get /v1/checkout-sessions/{checkout_id}
Retrieve the details of a checkout session by its ID, including resolved product line items and charge information.
The `charge` field is `null` while the session is `OPEN` and populated once the customer submits a payment.
Requires `payments:read` scope.
# The checkout session object
Source: https://docs.bachs.io/api-reference/checkout-sessions/object
A checkout session ties one or more products to a customer, provided at creation or collected on the hosted page, and produces a hosted checkout URL. It tracks the customer through payment and, once paid, links to the payment it created.
## `customer` vs `customer_details`
Two fields carry the buyer, and they answer different questions.
* **`customer`** is the customer record behind this checkout: `{ id, email, name }`. It is non-null exactly when a record exists.
* **`customer_details`** is what the buyer supplied: `{ email, name }`. It is present whenever an identity was collected, record or no record.
The same split applies to the [`checkout.completed`](/guides/webhooks/events/checkout-completed) webhook.
| State | `customer` | `customer_details` |
| - | - | - |
| No identity collected yet | `null` | `null` |
| Customer passed at creation | `cust_` record | `{email, name}` |
| Guest identified on the hosted page, `customer_creation: if_required` (default) | `null` | `{email, name}` |
| Guest identified on the hosted page, `customer_creation: always` | `cust_` record | `{email, name}` |
| Payment link with a fixed price | `null` | `{email, name}` |
Read `customer_details` when you want the buyer's email and name and do not care whether a record backs them. Read `customer` when you need an id you can pass back to the [customers API](/api-reference/customers/object).
See [Whether a guest becomes a customer](/guides/checkout/checkout-sessions#whether-a-guest-becomes-a-customer-customer-creation) for how `customer_creation` decides which row you land on.
`customer_details` is additive. `customer` behaves exactly as it always did, so anything that reads `customer` keeps working. What is new is that a checkout with no customer record behind it used to send no identity at all on completion, and now carries one.
# Attach a company document
Source: https://docs.bachs.io/api-reference/connected-accounts/attach-a-company-document
/docs/openapi/openapi.json post /v1/accounts/{account_id}/documents
Point one of the company's document slots at an already-uploaded file. Upload the file first with `POST /v1/utilities/uploads`, then reference its `upload_id` here as `file`. `document` names the company slot the file satisfies (for example a certificate of incorporation or a memorandum), and which slots exist depends on the company's structure and country. This attaches company-level paperwork; a person's ID document attaches to the person instead. See [Requirements](/connect/requirements).
# Create a customer portal session
Source: https://docs.bachs.io/api-reference/customer-sessions/create-a-customer-portal-session
/docs/openapi/openapi.json post /v1/customers/{customer_id}/portal-sessions
Creates a pre-authenticated customer portal session and returns the URL that opens it. The URL carries the session credential, so redirect the customer to it and do not log or share it. Sessions are short-lived; create a fresh one each time a customer asks to manage their billing. Requires the `customers:write` scope.
# The customer session object
Source: https://docs.bachs.io/api-reference/customer-sessions/object
A short-lived, pre-authenticated session that opens the customer portal as one specific customer.
# Create a customer
Source: https://docs.bachs.io/api-reference/customers/create-a-customer
/docs/openapi/openapi.json post /v1/customers
Creates a customer. A customer groups a buyer's payments, subscriptions, and saved payment methods under one record. Only `email` is required. Requires the `customers:write` scope.
# List customers
Source: https://docs.bachs.io/api-reference/customers/list-customers
/docs/openapi/openapi.json get /v1/customers
Returns a paginated list of your customers, most recent first. Pass `search` to filter by email or name. See [Pagination](/guides/pagination) for how to page through results. Requires the `customers:read` scope.
# The customer object
Source: https://docs.bachs.io/api-reference/customers/object
A customer groups a buyer's payments, subscriptions, and saved payment methods under one record.
`billing_address` is **replaced, not merged**, whenever you send an object for it in `PATCH /v1/customers/{customer_id}`. Any component you leave out of that object becomes `null`, even if a value was previously stored. This is the only field on the customer object where omitted, explicit `null`, and an object all mean something different. It is not guessable from the field list above, so read this before you write an update.
| What you send | What happens |
| - | - |
| `billing_address` omitted from the body | Untouched. The stored address, if any, is unaffected |
| `"billing_address": null` | Cleared |
| `"billing_address": { ... }` | Full replace. Every component not present in the object becomes `null` |
**Worked example.** A customer already has this stored:
```json theme={"dark"}
{
"line1": "40 Yaba Road",
"line2": "Suite 4",
"city": "Lagos",
"state": "Lagos",
"postal_code": "101245",
"country": "NG"
}
```
Sending `{"billing_address": {"city": "Paris"}}` on `PATCH /v1/customers/{customer_id}`, intending to correct only the city, replaces the whole object instead:
```json theme={"dark"}
{
"line1": null,
"line2": null,
"city": "Paris",
"state": null,
"postal_code": null,
"country": null
}
```
`line1` and `country` are gone even though nobody meant to touch them, and the address is no longer valid to submit as evidence. Send the full object (all six keys, with the ones you want to keep repeated back) whenever you're changing part of an address.
This is deliberate. A partial address written over a stored one produces a plausible-looking address that is actually wrong, and this value is what gets submitted as evidence on a dispute. A silent partial write is worse than a rejected request.
`name` and `phone_number` do **not** work this way. Sending either of those updates only that field and leaves the rest of the customer alone. `billing_address` is the exception.
**Validation.** `country` must be a real ISO-3166-1 alpha-2 code (for example `NG`, `FR`). Invalid codes are rejected. `line1` and `country` are required whenever an address object is supplied. An all-empty object (`{}`) is rejected outright; send explicit `null` if your intent is to clear the address.
# Retrieve a customer
Source: https://docs.bachs.io/api-reference/customers/retrieve-a-customer
/docs/openapi/openapi.json get /v1/customers/{customer_id}
Retrieves a single customer by ID. Requires the `customers:read` scope.
# Update a customer
Source: https://docs.bachs.io/api-reference/customers/update-a-customer
/docs/openapi/openapi.json patch /v1/customers/{customer_id}
Updates a customer. Only the fields you send are changed. Requires the `customers:write` scope.
# Get Dispute
Source: https://docs.bachs.io/api-reference/disputes/get-dispute
/docs/openapi/openapi.json get /v1/disputes/{dispute_id}
Retrieve full details for a single dispute, including the current evidence draft and latest submission metadata.
# List Disputes
Source: https://docs.bachs.io/api-reference/disputes/list-disputes
/docs/openapi/openapi.json get /v1/disputes
Retrieve a paginated list of disputes for your account. Results are returned with the most recently created disputes first.
# Submit Dispute
Source: https://docs.bachs.io/api-reference/disputes/submit-dispute
/docs/openapi/openapi.json post /v1/disputes/{dispute_id}/submit
Submit saved dispute evidence for network review. This action is irreversible and locks further evidence edits.
# Update Dispute Evidence
Source: https://docs.bachs.io/api-reference/disputes/update-dispute-evidence
/docs/openapi/openapi.json patch /v1/disputes/{dispute_id}/evidence
Save or update dispute evidence fields before final submission. Evidence can be updated iteratively while the dispute remains editable.
# Upload Dispute Document
Source: https://docs.bachs.io/api-reference/disputes/upload-dispute-document
/docs/openapi/openapi.json post /v1/disputes/uploads
Upload a supporting document for a dispute and receive a document identifier for evidence submission.
# Error Reference
Source: https://docs.bachs.io/api-reference/error-reference
Every error code the Bachs API returns, grouped by category, with the cause and how to resolve it.
The tables below describe every `error_code` the Bachs API returns. When an error has a `code`, its `doc_url` links to that code's entry here, or to the guide that explains it. For the error object shape and how to handle errors, see [Errors](/errors).
Sections marked Limited Access cover features that are not enabled by default. Contact [support@bachs.io](mailto:support@bachs.io) to request access.
## General
Errors that can occur on any endpoint.
| Code | Status | Cause and resolution |
| - | - | - |
| `BAD_REQUEST` | 400 | The request is malformed or has invalid parameters. Check it against the endpoint reference. |
| `UNAUTHORIZED` | 401 | No `Authorization` header, or the key is invalid or revoked. Send a valid key. |
| `FORBIDDEN` | 403 | The key is valid but lacks permission. Check your [key's permissions](/api-reference/permissions). |
| `NOT_FOUND` | 404 | The resource does not exist in your scope. Verify the ID and environment. |
| `INSUFFICIENT_BALANCE` | 400 | The `available_balance` will not cover the debit. `detail` states the shortfall, what was required, and what was available. Fund the balance, or wait for a pending charge to settle. |
| `IDEMPOTENCY_IN_PROGRESS` | 409 | A request with the same `Idempotency-Key` is still in flight. Retry after a short delay; the winner's response is replayed once it lands. |
| `CONFLICT` | 409 | The request conflicts with current state, such as a duplicate. Reconcile and retry. |
| `VALIDATION_ERROR` | 400 | One or more fields failed validation. Inspect the `errors` array and correct the input. |
| `VALIDATION_FAILED` | 422 | A business rule rejected a well-formed request. Read `detail` for the rule. |
| `INVALID_REQUIREMENT_FIELD` | 400 | A field submitted on `POST /v1/accounts/{account_id}` was rejected. Inspect `errors[]` (`field`, `message`, `code`); no field from that submission was saved, though anything else in the same call (contact details, capability requests, profile changes) was applied before the fields were validated. See [`payout_destination`](/connect/requirements#the-payout-destination-shape) for its rejection codes. |
| `TOO_MANY_REQUESTS` | 429 | Rate limit exceeded. Check `Retry-After` or `X-RateLimit-Reset` before retrying. |
| `PRECONDITION_REQUIRED` | 428 | A required precondition is missing. Read `detail` for what to supply. |
| `TOTP_STEP_UP_REQUIRED` | 428 | This action needs two-factor verification. Complete step-up and retry. |
| `IMMUTABLE_FIELD` | 400 | A field that cannot change after creation was included in an update. Remove it. |
| `INTERNAL_SERVER_ERROR` | 500 | Unexpected server-side failure. The request was not processed. Retry shortly. |
| `NOT_IMPLEMENTED` | 501 | The operation is not yet supported. |
| `BAD_GATEWAY` | 502 | An upstream dependency is temporarily unavailable. Retry shortly. |
| `SERVICE_UNAVAILABLE` | 503 | The service is temporarily overloaded or in maintenance. Retry shortly. |
## Payments
Encountered when initiating or processing payments.
| Code | Status | Cause and resolution |
| - | - | - |
| `PAYMENT_ERROR` | 400 | A general payment processing error. Read `detail` for the cause. |
| `PAYMENTS_NOT_ENABLED` | 400 | Payments are not enabled for your organization. Complete onboarding or contact support. |
| `PAYMENT_METHOD_NOT_ENABLED` | 400 | The selected method is not enabled. Use an enabled method or contact support. |
| `CARD_TOKEN_INVALID` | 400 | The card token is invalid or expired. Collect the card again for a fresh token. |
| `CARD_VAULT_UNAVAILABLE` | 500 | The card service could not be reached. Retry shortly. |
| `PAYMENT_METHOD_SETUP_FAILED` | 500 | Payment method setup could not start. Retry shortly. |
| `OFF_SESSION_CHARGE_FAILED` | 500 | The off-session charge could not be created. Retry, or fall back to an on-session payment. |
| `OFF_SESSION_NOT_SUPPORTED` | 400 | This payment method cannot be charged off-session. Use an on-session flow. |
| `NO_SAVED_PAYMENT_METHOD` | 400 | The customer has no saved card to charge. Save one on a checkout first. See [Charge a saved card](/guides/payments/charge-a-saved-card). |
| `SAVED_PAYMENT_METHOD_NOT_FOUND` | 404 | No saved card with that `pm_` id belongs to this customer. Cards are scoped to one customer. |
| `PAYMENT_METHOD_UNUSABLE` | 400 | The saved card has expired or been removed. Ask the customer to save a new one. |
| `CHECKOUT_CANNOT_SAVE_PAYMENT_METHOD` | 400 | Saving a card needs a card on the checkout. This one offers no card method, so nothing could be saved. See [Why a checkout cannot save a card](/guides/payments/charge-a-saved-card#why-a-checkout-cannot-save-a-card). |
| `GATEWAY_UNAVAILABLE` | 500 | Our payment processing infrastructure is temporarily unreachable. Rare. Retry shortly, contact support if it persists. |
| `PROVIDER_ERROR` | 500 | Our payment processing infrastructure returned an unexpected error. Retry shortly. |
## Products & Prices
Encountered when creating or updating products and prices.
| Code | Status | Cause and resolution |
| - | - | - |
| `PRODUCT_NOT_FOUND` | 400 | A referenced product does not exist or is archived. Verify the product ID. |
| `PRODUCT_NO_PRICE` | 400 | The product has no price in the requested currency. Add a price in that currency. |
| `PRODUCT_ARCHIVED` | 400 | The product is archived and cannot be used for new checkouts. Unarchive or use another. |
| `PRICE_REQUIRED` | 400 | At least one price is required. Include a price on the product. |
| `MINIMUM_PRICE_REQUIRED` | 400 | A product must keep at least one price. You cannot remove the last one. |
| `DUPLICATE_CURRENCY` | 400 | A price for this currency already exists. Update the existing price instead. |
| `UNSUPPORTED_CURRENCY` | 400 | The currency is not in the active supported set. Use a supported currency. |
| `INTERVAL_REQUIRED` | 400 | `interval_count` requires a positive integer when `interval` is set. Supply both. |
| `INTERVAL_NOT_ALLOWED` | 400 | `interval_count` was provided without an `interval`. Add an `interval` or remove the count. |
| `INVALID_INTERVAL` | 400 | `interval` must be one of `day`, `week`, `month`, or `year`. |
| `RECURRING_INTERVAL_IMMUTABLE` | 400 | `recurring_interval` cannot change once set. Create a new product instead. |
| `TRIAL_REQUIRES_RECURRING` | 400 | A trial is only valid on a recurring product. Remove the trial or make it recurring. |
| `MEDIA_LIMIT_EXCEEDED` | 400 | A product supports at most 5 media items. Remove some before adding more. |
| `METADATA_LIMIT_EXCEEDED` | 400 | `metadata` allows at most 20 keys, each key and value at most 500 characters. Trim it. |
| `ENVIRONMENT_MISMATCH` | 400 | All products in a group must be in the same environment. Use one environment. |
| `INVALID_PRODUCT_REFERENCE` | 400 | A product ID is invalid or belongs to another merchant. Verify the IDs. |
| `INVALID_UPLOAD_REFERENCE` | 400 | An upload ID is invalid or belongs to another merchant. Verify the IDs. |
## Checkout Sessions
Encountered when creating or processing checkout sessions.
| Code | Status | Cause and resolution |
| - | - | - |
| `CHECKOUT_ERROR` | 400 | A general checkout configuration or processing error. Read `detail` for the cause. |
| `CHECKOUT_PRICE_CHANGED` | 409 | The price changed after the quote was issued. Fetch a fresh quote and retry. |
| `CUSTOM_AMOUNT_REQUIRED` | 400 | A `CUSTOM`-priced product needs an `amount` in the line item. Provide one. |
| `FIXED_AMOUNT_OVERRIDE_NOT_ALLOWED` | 400 | An `amount` was set for a `FIXED`-priced product. Remove `amount` from the line item. |
| `HETEROGENEOUS_PRICE_TYPES` | 422 | All prices for a product must share one type (`FIXED`, `CUSTOM`, or `FREE`). |
| `PAYMENT_METHOD_NOT_ALLOWED` | 400 | The checkout was restricted to other payment methods or currencies. Choose one the checkout offers. |
| `CHECKOUT_HAS_NO_PAYMENT_METHOD` | 400 | Nothing can be offered on this checkout, so it was refused instead of created unpayable. Work through [Why a checkout has no payment method](/guides/payments/payment-method-support#why-a-checkout-has-no-payment-method). |
| `CHECKOUT_RESTRICTION_LEAVES_NO_PAYMENT_METHOD` | 400 | Your `payment_method_types` narrowed the checkout to nothing that is available. Widen it, or see [Why a checkout has no payment method](/guides/payments/payment-method-support#why-a-checkout-has-no-payment-method). |
| `ACCOUNT_NOT_ACTIVATED` | 400 | The account has payment methods configured but is not yet approved to accept live payments. Finish [going live](/go-live), or keep building against a sandbox key. |
| `ACCOUNT_PAYMENT_METHODS_RESTRICTED` | 400 | Every payment method on the account is restricted, so no checkout can offer one. You cannot lift this from your own settings. Contact [support@bachs.io](mailto:support@bachs.io). |
| `CART_CURRENCY_MISMATCH` | 400 | All products in a multi-item checkout must share the same base currency. |
| `BASE_CURRENCY_NOT_HELD_BY_ORG` | 422 | A **recurring** checkout was priced in a currency you do not hold. One-time checkouts convert and settle in any supported currency; recurring will too once renewals settle to USD, but for now price the plan in a held currency. See [Charge in any currency](/guides/checkout/any-currency-checkout#recurring-products). |
| `BASE_CURRENCY_NOT_COLLECTIBLE` | 422 | No payment method can collect the `base_currency`. Crypto asset codes are refused here: an asset code names a rail, not a price. |
| `BASE_CURRENCY_NOT_ENABLED` | 422 | The `base_currency` is collectible, but not enabled for your organization. Enable it in your checkout settings first. |
| `BASE_CURRENCY_NOT_CONVERTIBLE` | 422 | We can collect the `base_currency` but have no rate to settle it into your settlement currency, so the checkout is refused rather than created with money that could not be paid out. |
| `BILLING_CURRENCY_NOT_AVAILABLE` | 400 | Nothing in the checkout is priced in the requested `billing_currency`. Add a price in that currency, or request one of the currencies named in `detail`. |
| `BILLING_CURRENCY_HAS_NO_PAYMENT_METHOD` | 400 | The checkout is priced in the requested `billing_currency`, but no payment method it offers can charge it. Request one of the currencies named in `detail`. See [Pin the currency](/guides/checkout/any-currency-checkout#pin-the-currency-with-billing_currency). |
## Subscriptions Limited Access
Encountered when creating or managing subscriptions.
| Code | Status | Cause and resolution |
| - | - | - |
| `SUBSCRIPTIONS_NOT_ENABLED` | 403 | Subscriptions are not enabled for this account. Contact support to request access. |
| `NGN_SUBSCRIPTIONS_NOT_ENABLED` | 403 | NGN subscriptions are not enabled. Contact support to request access. |
| `TRIALS_NOT_ENABLED` | 403 | Trials are not enabled for this account. Contact support to request access. |
| `SUBSCRIPTION_NOT_FOUND` | 404 | No subscription exists for this ID in your scope. Verify the ID. |
| `SUBSCRIPTION_ALREADY_CANCELED` | 400 | The subscription is already canceled and cannot be canceled again. |
| `SUBSCRIPTION_NOT_MODIFIABLE` | 400 | The subscription cannot be modified in its current status. Check its status first. |
| `SUBSCRIPTION_NOT_TRIALING` | 400 | The action requires a trialing subscription, but this one is not in a trial. |
| `INVALID_SUBSCRIPTION_PRICE` | 400 | The price is not valid for a subscription. It must be recurring with a positive amount. |
| `SUBSCRIPTION_PRICE_NOT_RECURRING` | 400 | A subscription needs a recurring price. Use a product with one. |
| `SUBSCRIPTION_REQUIRES_CATALOG_PRODUCT` | 400 | A subscription checkout needs exactly one recurring catalog product. Raw amounts and inline products are not supported. |
| `SUBSCRIPTION_METHOD_NOT_SUPPORTED` | 400 | Subscription checkouts can only be paid with a card. Use a card. |
| `PLAN_NOT_PRICED_IN_CURRENCY` | 400 | The target product has no price in the subscription's currency. Add one or choose another plan. |
| `SUBSCRIPTION_PLAN_INTERVAL_MISMATCH` | 400 | The target product's interval must match the subscription's. Choose a matching plan. |
| `PRORATION_BEHAVIOR_NOT_SUPPORTED` | 400 | The requested proration behavior is not supported yet. Use a supported value. |
## Payouts
Encountered when sending payouts or managing payout destinations.
| Code | Status | Cause and resolution |
| - | - | - |
| `PAYOUTS_NOT_ENABLED` | 400 | Withdrawals are not enabled for this organization. Complete the account's withdrawal requirements; contact support if the error persists. |
| `PAYOUT_CURRENCY_NOT_ENABLED` | 400 | The requested payout route is disabled for this account. [Global payouts](/guides/payouts/global-payouts) are available to all Bachs users; if this occurs for a supported route, contact support with the error details. |
| `PAYOUT_SOURCE_CURRENCY_UNSUPPORTED` | 400 | The chosen source balance cannot fund this route. International bank and mobile money payouts use your USD balance. |
| `PAYOUT_RECIPIENT_INCOMPLETE` | 400 | Required account identity details are missing. Complete the account's name, contact email and address before requesting the payout. |
| `DESTINATION_NOT_FOUND` | 404 | No destination with that id belongs to this account. Check the id. |
| `DESTINATION_PENDING_REVIEW` | 400 | The destination has not cleared review, so it cannot receive money yet. Wait for `is_usable` to be `true`. |
| `DESTINATION_REJECTED` | 400 | The destination was rejected in review and never becomes usable. Register a new one. |
| `DESTINATION_TYPE_REQUIRED` | 400 | The currency is served by more than one payout rail, so the type cannot be inferred. Send `type` explicitly. |
| `CURRENCY_NOT_SUPPORTED` | 400 | The requested payout currency and destination type have no supported rail. See [Supported currencies](/for-you/supported-currencies#withdrawals). |
| `AMOUNT_REQUIRED` | 400 | A same-currency payout needs an `amount`. |
| `AMOUNT_NOT_ALLOWED_WITH_QUOTE` | 400 | A quoted payout carries no `amount`, because the quote already fixes both sides. Send one or the other. |
| `QUOTE_REQUIRED` | 400 | The destination's currency differs from the balance being debited. Create a quote and pass `quote_id`. |
| `QUOTE_NOT_APPLICABLE` | 400 | A quote was passed to a same-currency payout, which converts nothing. Drop `quote_id`. |
| `QUOTE_NOT_FOUND` | 400 | The quote does not exist, or belongs to another organization. Create a new one. |
| `QUOTE_EXPIRED` | 400 | The quote has lapsed. Quotes are short-lived; create one immediately before the payout. |
| `QUOTE_DESTINATION_MISMATCH` | 400 | The quote was created for a different currency pair than this destination's. Quote the pair you are paying. |
| `PAYOUT_DESTINATION_NOT_FOUND` | 404 | No destination with that id belongs to this account. Returned when choosing the destination a payout schedule uses. |
| `PAYOUT_DESTINATION_NOT_APPROVED` | 400 | Only an approved destination can be a payout schedule's default. Wait for review to clear. |
| `PAYOUT_DESTINATION_INACTIVE` | 400 | A deleted destination cannot be a payout schedule's default. Register the account again and promote the new one. |
| `WITHDRAWAL_LIMIT_EXCEEDED` | 400 | The amount exceeds your single-withdrawal limit. `details` has `currency`, `requested_amount` and `max_allowed`, plus `requested_amount_usd` and `max_allowed_usd`. See [Withdrawal limit details](#withdrawal-limit-details). Lower the amount. |
| `DAILY_WITHDRAWAL_LIMIT_EXCEEDED` | 400 | The amount would exceed your daily cap. `details` has `currency`, `requested_amount`, `max_allowed` and `total_today`, plus `requested_amount_usd`, `max_allowed_usd` and `total_today_usd`. See [Withdrawal limit details](#withdrawal-limit-details). Retry within the cap. |
| `INSUFFICIENT_BALANCE` | 400 | `available_balance` will not cover the payout. Compare it against `total_debited` (what the destination receives plus the fee), not against `amount`. See [General](#general) for the shortfall the response states. |
Limit errors include a `details` object alongside `error_code` and `detail`. Parse it to show remaining allowance and cap amounts directly to your users.
### Withdrawal limit details
`currency`, `requested_amount`, `max_allowed` and `total_today` are in the currency the limit is set in. `total_today` is only on the daily error.
If your account has a limit for the payout currency, the payout is judged against that limit only. The amounts are in that currency, and the `_usd` fields are `null`.
If it has no limit for that currency, the check is in USD. `currency` is `"USD"`, and the `_usd` fields are filled.
For example, an NGN payout of 12,000,000 against a 10,000,000 limit:
```json theme={"dark"}
{
"detail": "Withdrawal limit exceeded. Requested: NGN 12,000,000.00, maximum allowed: NGN 10,000,000.00",
"error_code": "WITHDRAWAL_LIMIT_EXCEEDED",
"doc_url": "https://docs.bachs.io/api-reference/error-reference#payouts",
"details": {
"requested_amount_usd": null,
"max_allowed_usd": null,
"currency": "NGN",
"requested_amount": "12000000.00",
"max_allowed": "10000000.00"
}
}
```
## Deposits
Encountered when collecting payments from customers.
| Code | Status | Cause and resolution |
| - | - | - |
| `DEPOSIT_LIMIT_EXCEEDED` | 400 | The payment exceeds your deposit limit for this currency. `details` has `requested_amount`, `max_allowed_amount`, and `currency`. Lower the amount or contact support. |
| `UNSUPPORTED_DEPOSIT_CURRENCY` | 400 | The currency is not a supported deposit currency. Use a supported currency. |
## Virtual accounts
Encountered when creating or reading an account's fixed account number. See [Virtual accounts](/guides/virtual-accounts/overview).
| Code | Status | Cause and resolution |
| - | - | - |
| `VIRTUAL_ACCOUNT_CURRENCY_NOT_SUPPORTED` | 400 | We do not issue virtual accounts in that currency. Request `NGN`, the only supported currency today. |
| `VIRTUAL_ACCOUNT_REFUSED` | 400 | The issuing bank refused to create the number. The message carries the bank's reason. Fix what it names and retry, or contact [support](mailto:support@bachs.io) if it isn't clear. |
| `FORBIDDEN` | 403 | Your key lacks `virtual_accounts:read` or `virtual_accounts:write`, or the account's `virtual_accounts` capability is not active yet. Check the [key's permissions](/api-reference/permissions), then wait for `capability.updated` to report the capability as `active`. |
| `VALIDATION_FAILED` | 422 | The account representative has no BVN on file. The response includes `missing_fields: ["bvn"]`. Submit the representative's BVN through the account's [requirements](/connect/requirements#submitting), then retry. |
| `NOT_FOUND` | 404 | On `GET`, the account has no virtual account in that currency, so create one with `POST /v1/virtual-accounts`. On `POST`, virtual accounts are not enabled for this account yet. They are rolling out account by account, and each connected account is enabled separately. Contact [support](mailto:support@bachs.io) with the account ID. |
## Quotes Limited Access
Encountered when requesting conversion quotes.
| Code | Status | Cause and resolution |
| - | - | - |
| `QUOTE_ERROR` | 400 | The conversion quote could not be generated. Check the currency pair and amount are valid. |
# Delete an upload
Source: https://docs.bachs.io/api-reference/media/delete-an-upload
/docs/openapi/openapi.json delete /v1/utilities/uploads/{upload_id}
Delete an upload that has not yet been linked to any resource. Returns a `409` if the upload is already attached to a product.
# Retrieve an upload
Source: https://docs.bachs.io/api-reference/media/retrieve-an-upload
/docs/openapi/openapi.json get /v1/utilities/uploads/{upload_id}
Retrieve metadata for a previously created upload by its ID.
# Upload a file
Source: https://docs.bachs.io/api-reference/media/upload-a-file
/docs/openapi/openapi.json post /v1/utilities/uploads
Upload a file and receive an `upload_id`. Pass this ID in the `media` array when creating or updating a product.
Files must be sent as `multipart/form-data`. Maximum size is **20 MB**.
# Resolve a bank account
Source: https://docs.bachs.io/api-reference/misc/resolve-a-bank-account
/docs/openapi/openapi.json post /v1/misc/bank-accounts/resolve
Resolve an account number and bank code to the account holder's name. Worth calling before you submit a payout destination: it turns a rejection days later into an inline error while the account holder is still on the page.
# Get checkout settings
Source: https://docs.bachs.io/api-reference/organizations/get-checkout-settings
/docs/openapi/openapi.json get /v1/accounts/checkout/settings
Retrieve checkout configuration for your account context, including enabled payment methods, per-method currency toggles, and fee preference.
# Get your own account
Source: https://docs.bachs.io/api-reference/organizations/get-my-organization
/docs/openapi/openapi.json get /v1/accounts/me
Get the account your API key belongs to, including its capability names, checkout payment methods, and balance currencies. The `capabilities` and `requirements` blocks are not populated here; read the account by ID for those. Use this to confirm your own platform holds an active `connect` capability before you create accounts. See [Become a platform](/connect/become-a-platform).
# List accounts
Source: https://docs.bachs.io/api-reference/organizations/list-connected-accounts
/docs/openapi/openapi.json get /v1/accounts
Returns the accounts linked to your account. Items never carry the `capabilities` or `requirements` blocks; read a single account with [Get account](/api-reference/connected-accounts/get-connected-account) for those. Requires the `connect` capability to be active on your account.
# Update checkout settings
Source: https://docs.bachs.io/api-reference/organizations/update-checkout-settings
/docs/openapi/openapi.json put /v1/accounts/checkout/settings
Update checkout configuration for your account context, including enabled payment methods and fee preference.
# Introduction
Source: https://docs.bachs.io/api-reference/overview
The Bachs API: your first request and where to go next.
The Bachs API lets you create products, run checkouts, manage customers and subscriptions, pay out to bank accounts and wallets, and reconcile it all programmatically. It is organized around REST, uses predictable resource-oriented URLs, and returns JSON.
The conventions every endpoint shares are collected in one place:
* [**API Standards**](/api-reference/api-standards): authentication, request and response format, base URLs, status codes, query parameters, and rate limits.
* [**Authentication**](/authentication): create keys and authorize requests with a Bearer token.
* [**Errors**](/errors): the error object and how to handle failures.
* [**Sandbox**](/integrate/sandbox): test your integration without moving real funds.
## Your first request
Authenticate with your secret key in the `Authorization` header and call any endpoint. Use the sandbox base URL (`https://sandbox-api.bachs.io`) while you build, and the production base URL (`https://api.bachs.io`) when you go live. This lists your products:
```bash Sandbox theme={"dark"}
curl https://sandbox-api.bachs.io/v1/products \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Accept: application/json"
```
```bash Production theme={"dark"}
curl https://api.bachs.io/v1/products \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Accept: application/json"
```
Use `sk_sandbox_...` keys against the sandbox and `sk_live_...` keys against production. See [Authentication](/authentication) to create and manage keys, and [API Standards](/api-reference/api-standards#base-url) for the full base-URL reference.
## Next steps
* [**Create a product**](/api-reference/products/object): define your billing catalog with fixed, free, or custom pricing.
* [**Start a checkout**](/guides/checkout/checkout-sessions): create a hosted checkout and get paid.
* [**Pay someone out**](/guides/payouts/payout-using-api): send money to a bank account or wallet, in two calls.
* [**Permissions**](/api-reference/permissions): scopes and what each key can access.
* [**Pagination**](/guides/pagination): page through list endpoints.
# Create a charge
Source: https://docs.bachs.io/api-reference/payments/create-a-charge
/docs/openapi/openapi.json post /v1/charges
**In beta.** This endpoint might change, including field names and the shape of the response. Pin your integration to what you test.
Charge a customer's saved card off-session, meaning with nobody on a payment page. Use this for money a customer agreed to once and you collect later, such as a usage invoice or a top-up. An off-session charge cannot ask the customer to authenticate, so a card whose issuer demands it is refused.
The card must have been saved on an earlier checkout. See [Charge a saved card](/guides/payments/charge-a-saved-card).
This always answers with a payment, never an error, when the card is refused: a refusal is an outcome you read from `status`. The payment is usually `processing`, and the result reaches you as a `collection.succeeded` or `collection.failed` webhook. A card refused while the request is still open comes back already `failed`. A `201` is not payment received.
Send an `Idempotency-Key` header. Without one, a retry after a timeout charges the customer twice.
# Create a checkout session
Source: https://docs.bachs.io/api-reference/payments/create-checkout-session
/docs/openapi/openapi.json post /v1/checkout-sessions
Create a product-based checkout session
# Retrieve a payment
Source: https://docs.bachs.io/api-reference/payments/get-payment
/docs/openapi/openapi.json get /v1/payments/{payment_id}
Retrieve a single payment by its charge ID, with the full object: amount, status, the customer, fees, the products paid for, refunds, and status history.
# List payment methods
Source: https://docs.bachs.io/api-reference/payments/list-payment-methods
/docs/openapi/openapi.json get /v1/payment-methods
Get all available payment methods and their supported currencies. Use this to determine which payment options to show customers.
# List payment rails
Source: https://docs.bachs.io/api-reference/payments/list-payment-rails
/docs/openapi/openapi.json get /v1/payment-methods/rails
Get available payment rails for a specific payment method and currency combination. Use this to determine which payment rails are available before creating a quote. The 'id' field from the response should be used as the 'payment_rail' parameter when creating quotes.
# List payments
Source: https://docs.bachs.io/api-reference/payments/list-payments
/docs/openapi/openapi.json get /v1/payments
Return a paginated list of payments your account has received, newest first. Filter with query parameters for reconciliation and monitoring. List items carry a summary of each payment; call Retrieve a payment for the full object including fees, products, and status history.
# List payout supported currencies
Source: https://docs.bachs.io/api-reference/payments/list-payout-supported-currencies
/docs/openapi/openapi.json get /v1/currencies/payout-supported
Get all currencies that support payouts/withdrawals, organized by fiat and cryptocurrency types.
# List supported currencies
Source: https://docs.bachs.io/api-reference/payments/list-supported-currencies
/docs/openapi/openapi.json get /v1/currencies/supported
Get all supported fiat and cryptocurrency codes. Use this to validate currency selections and display currency options to users.
# The payment object
Source: https://docs.bachs.io/api-reference/payments/object
A payment tracks a charge and its outcome, whether started by checkout, an off-session API call, or a virtual-account deposit. It carries the amount, status, fees, and refunds when supported.
# The payout destination object
Source: https://docs.bachs.io/api-reference/payout-destinations/object
A saved bank account, mobile money wallet or crypto wallet belonging to the account holder. Payouts reference its ID; check is_usable before sending. Changes to routing or holder details require renewed review.
# Create Payout
Source: https://docs.bachs.io/api-reference/payouts/create-payout
/docs/openapi/openapi.json post /v1/payouts
Send money to a payout destination you have registered. `amount` is what the destination receives, and the fee is charged on top, so the balance must cover `total_debited`. Paying out in a different currency from the balance you are debiting omits `amount` and passes `quote_id` instead.
# Create Payout Destination
Source: https://docs.bachs.io/api-reference/payouts/create-payout-destination
/docs/openapi/openapi.json post /v1/payouts/destinations
Add a new payout destination (bank account, mobile money, or crypto wallet) where you can withdraw funds.
# Create Payout Quote
Source: https://docs.bachs.io/api-reference/payouts/create-payout-quote
/docs/openapi/openapi.json post /v1/payouts/quotes
Lock an exchange rate for a payout that delivers a different currency from the balance it debits. Returns a `quote_id` to pass to Create Payout in place of `amount`, along with the rate and the amount the destination receives. Same-currency payouts need no quote. The quote carries no fee: the payout fee is charged on top of the amount when the payout is created.
# Delete Payout Destination
Source: https://docs.bachs.io/api-reference/payouts/delete-payout-destination
/docs/openapi/openapi.json delete /v1/payouts/destinations/{destination_id}
Delete a payout destination.
# Get Payout
Source: https://docs.bachs.io/api-reference/payouts/get-payout
/docs/openapi/openapi.json get /v1/payouts/{withdrawal_id}
Get a payout withdrawal by ID.
# Get Payout Destination
Source: https://docs.bachs.io/api-reference/payouts/get-payout-destination
/docs/openapi/openapi.json get /v1/payouts/destinations/{destination_id}
Retrieve a single payout destination by ID.
# Get payout schedule
Source: https://docs.bachs.io/api-reference/payouts/get-payout-schedule
/docs/openapi/openapi.json get /v1/balance_settings
Read the payout schedule for your account, or for a connected account named by `X-Account-Id`. Its own resource rather than a block on the account object, so a platform can read timing without pulling the whole account.
# List Payout Destinations
Source: https://docs.bachs.io/api-reference/payouts/list-payout-destinations
/docs/openapi/openapi.json get /v1/payouts/destinations
List all configured payout destinations (bank accounts, mobile money, crypto wallets) for your account.
# List Payouts
Source: https://docs.bachs.io/api-reference/payouts/list-payouts
/docs/openapi/openapi.json get /v1/payouts
List payout withdrawals for your account.
# The payout object
Source: https://docs.bachs.io/api-reference/payouts/object
A payout moves money out of a balance to a bank account, mobile money wallet, or crypto wallet. Payouts settle asynchronously and are reported through the payout.paid and payout.failed webhook events.
# Update Payout Destination
Source: https://docs.bachs.io/api-reference/payouts/update-payout-destination
/docs/openapi/openapi.json patch /v1/payouts/destinations/{destination_id}
Update a destination: rename it, (de)promote it as a payout schedule's default, or restate where money lands.
`name` and `is_default` alone are safe: neither touches review status, since the default can only ever be one of your own approved destinations. Sending any routing detail (currency, type, account, wallet or phone) restates the destination in full, the same shape as `POST`; omitted routing fields fall back to what is already stored. Changing an account number, bank code, wallet, network, or phone number this way sends the destination back for review, because the approval it holds was granted for the details it is being asked to leave behind.
Common errors:
- `400 VALIDATION_ERROR`: neither `name` nor `is_default` sent, or an invalid payload for the destination type. Resolution: validate request fields and retry.
- `401 UNAUTHORIZED`: API key is missing, invalid, or revoked. Resolution: use a valid API key in the `Authorization` header.
- `404 NOT_FOUND`: `destination_id` was not found. Resolution: verify destination ID and retry.
# Update payout schedule
Source: https://docs.bachs.io/api-reference/payouts/update-payout-schedule
/docs/openapi/openapi.json post /v1/balance_settings
Set the payout schedule for your account, or for a connected account named by `X-Account-Id`. A currency you leave out of `schedule_by_currency` keeps the schedule it has; a currency you name is replaced in full. See [Payout Schedules](/guides/payouts/payout-schedules).
# Permissions
Source: https://docs.bachs.io/api-reference/permissions
Limit an API key's access to specific resources and actions in Bachs.
Permissions grant an API key access to specific resources or allow it to take specific actions in Bachs.
You can assign permissions to a key when creating or updating it in the dashboard.
If your key doesn't have the correct permissions, the API returns a `403 Forbidden` error.
## Permission types
Each permission targets a resource, like payments, customers, or products, and is one of two types:
* `resource:read`: Read and list the resource. Applies to `GET` requests.
* `resource:write`: Create, update, and delete the resource. Applies to `POST`, `PATCH`, and `DELETE` requests. Write permission automatically includes read.
## Available permissions
| Resource | Permission | What it covers |
| - | - | - |
| **Payments** | `payments:read` | Read and list payments |
| **Payments** | `payments:write` | Initiate and manage payment collections |
| **Customers** | `customers:read` | Read and list customers |
| **Customers** | `customers:write` | Create and update customers |
| **Payouts** | `payouts:read` | Read and list payout withdrawals and destinations |
| **Payouts** | `payouts:write` | Create payout destinations, request quotes, and initiate withdrawals |
| **Balance** | `balance:read` | Read balances and the payout schedule |
| **Balance** | `balance:write` | Set the payout schedule |
| **Refunds** Beta | `refunds:read` | Read and list refunds |
| **Refunds** Beta | `refunds:write` | Issue refunds |
| **Disputes** Beta | `disputes:read` | Read and list disputes |
| **Disputes** Beta | `disputes:write` | Submit evidence and manage disputes |
| **Quotes** Limited Access | `quotes:read` | Read conversion quotes |
| **Webhooks** | `webhooks:read` | Read and list webhook endpoints and events, including an endpoint's signing secret |
| **Webhooks** | `webhooks:write` | Create, update, enable, disable and delete webhook endpoints, rotate signing secrets, and resend events |
| **Products** | `products:read` | Read and list products and product groups |
| **Products** | `products:write` | Create, update, and archive products and product groups |
| **Connected Accounts** Beta | `connected_accounts:write` | Create and manage connected accounts |
| **Virtual accounts** | `virtual_accounts:read` | Read an account's fixed bank account number |
| **Virtual accounts** | `virtual_accounts:write` | Create an account's fixed bank account number |
## Best practices
* **Follow the principle of least privilege.**\
Only assign the permissions a key actually needs. A key used to sync product catalog data doesn't need `payments:write`.
* **Create separate keys per integration or team.**\
Issue a distinct key per system or team so you can revoke access without affecting others.
* **Review permissions when access requirements change.**\
When access requirements change, update or rotate the key rather than leaving unused permissions in place.
# Add a person
Source: https://docs.bachs.io/api-reference/persons/add-a-person
/docs/openapi/openapi.json post /v1/accounts/{account_id}/persons
Add a person to the account. Roles are flags, so one person can be representative, owner and director at once.
# Attach a document to a person
Source: https://docs.bachs.io/api-reference/persons/attach-a-document-to-a-person
/docs/openapi/openapi.json post /v1/accounts/{account_id}/persons/{person_id}/documents
Point one of a person's document slots at an already-uploaded file. Upload the file first with `POST /v1/utilities/uploads`, then reference its `upload_id` here as `file`. `document` is `primary_verification` (government ID) or `secondary_verification` (address evidence); a two-sided card is two attachments, each with its own `side`. Attaching does not verify: a reviewer accepting the document is what moves the person's `verification.status` to `passed`. See [Verify an account's identity](/connect/guides/identity-verification).
# List persons
Source: https://docs.bachs.io/api-reference/persons/list-persons
/docs/openapi/openapi.json get /v1/accounts/{account_id}/persons
The people behind the account: its representative, beneficial owners and directors.
# Read a person
Source: https://docs.bachs.io/api-reference/persons/read-a-person
/docs/openapi/openapi.json get /v1/accounts/{account_id}/persons/{person_id}
Read one person. Requirement keys are anchored to the person id, so `persons.per_3a91c0d7.id_document` names exactly who this is about.
# Remove a person
Source: https://docs.bachs.io/api-reference/persons/remove-a-person
/docs/openapi/openapi.json delete /v1/accounts/{account_id}/persons/{person_id}
Remove a person, along with the requirements that were only about them. The representative cannot be removed, since nothing would ask for a replacement, and returns `400 representative_cannot_be_removed`.
# Update a person
Source: https://docs.bachs.io/api-reference/persons/update-a-person
/docs/openapi/openapi.json post /v1/accounts/{account_id}/persons/{person_id}
Edit one person in place. Keys you omit are left alone; sending a key as `null` clears it.
# Get a platform fee
Source: https://docs.bachs.io/api-reference/platform-fees/get-a-platform-fee
/docs/openapi/openapi.json get /v1/platform_fees/{fee_id}
Retrieve a single platform fee your organization was a party to.
# List platform fees
Source: https://docs.bachs.io/api-reference/platform-fees/list-platform-fees
/docs/openapi/openapi.json get /v1/platform_fees
Returns platform fees your organization was a party to, newest first. Both the account a fee was collected from and the platform that earned it can list it.
# Archive a product
Source: https://docs.bachs.io/api-reference/products/archive-a-product
/docs/openapi/openapi.json post /v1/products/{product_id}/archive
Archives a product so it can no longer be used in new checkouts or subscriptions. Existing subscriptions keep billing. This is idempotent: archiving an already-archived product succeeds. Reverse it with [Unarchive Product](/api-reference/products/unarchive-product). Requires the `products:write` scope.
# Create a product
Source: https://docs.bachs.io/api-reference/products/create-a-product
/docs/openapi/openapi.json post /v1/products
Creates a product with its pricing. Every product has a `price`; add a `billing_cycle` to make it recurring, or omit it for a one-time product. Sell products through checkout sessions and subscriptions. Requires the `products:write` scope.
# List products
Source: https://docs.bachs.io/api-reference/products/list-products
/docs/openapi/openapi.json get /v1/products
Returns a paginated list of your products, most recent first. Archived products are excluded unless you pass `include_archived=true`. See [Pagination](/guides/pagination) for how to page through results. Requires the `products:read` scope.
# The product object
Source: https://docs.bachs.io/api-reference/products/object
A product is an item you sell, with its pricing. Products are sold through checkout sessions and subscriptions.
# Retrieve a product
Source: https://docs.bachs.io/api-reference/products/retrieve-a-product
/docs/openapi/openapi.json get /v1/products/{product_id}
Retrieves a single product by its ID. Requires the `products:read` scope.
# Unarchive a product
Source: https://docs.bachs.io/api-reference/products/unarchive-a-product
/docs/openapi/openapi.json post /v1/products/{product_id}/unarchive
Restores an archived product to active status so it can be used again. This is idempotent. Requires the `products:write` scope.
# Update a product
Source: https://docs.bachs.io/api-reference/products/update-a-product
/docs/openapi/openapi.json patch /v1/products/{product_id}
Updates a product. You can change its name, description, metadata, media, price, and (if not yet set) its `billing_cycle` and `trial_period`. A `billing_cycle` is immutable once set, so a recurring product's interval cannot be changed. Create a new product for a different cadence. Requires the `products:write` scope.
# List banks
Source: https://docs.bachs.io/api-reference/reference/list-banks
/docs/openapi/openapi.json get /v1/reference/banks
The banks an account can name as a payout destination. Use the `code` from this list when you resolve an account number or submit `payout_destination`.
# List business structures
Source: https://docs.bachs.io/api-reference/reference/list-business-structures
/docs/openapi/openapi.json get /v1/reference/business-structures
The legal structures a registered business can declare as `company.structure`, for the resolved country. Each carries a `value` to submit, a display `label`, and a `description`. Which documents a structure owes depends on it. See [Requirements](/connect/requirements).
# List mobile money providers
Source: https://docs.bachs.io/api-reference/reference/list-mobile-money-providers
/docs/openapi/openapi.json get /v1/reference/momo
The mobile money operators an account can name as a payout destination.
# List product categories
Source: https://docs.bachs.io/api-reference/reference/list-product-categories
/docs/openapi/openapi.json get /v1/reference/product-categories
The industry categories a business can declare as `business_profile.product_category`. Returns a flat `categories` list of `{value, label}` and the same values grouped into `sections` for display. Submit the `value`. See [Requirements](/connect/requirements).
# Create a refund
Source: https://docs.bachs.io/api-reference/refunds/create-refund
/docs/openapi/openapi.json post /v1/refunds
Create a refund for a completed payment. Only one refund can be created per charge.
# Retrieve a refund
Source: https://docs.bachs.io/api-reference/refunds/get-refund
/docs/openapi/openapi.json get /v1/refunds/{refund_id}
Retrieve a single refund by its ID.
# Retrieve a refund by charge
Source: https://docs.bachs.io/api-reference/refunds/get-refund-by-charge
/docs/openapi/openapi.json get /v1/refunds/by-charge/{payment_id}
Retrieve the refund associated with a specific payment by `payment_id`.
# List refunds
Source: https://docs.bachs.io/api-reference/refunds/list-refunds
/docs/openapi/openapi.json get /v1/refunds
Retrieve a paginated list of refunds for your account, ordered from most recent to oldest.
# The refund object
Source: https://docs.bachs.io/api-reference/refunds/object
A refund returns money to a customer for a payment you already collected. Refunds settle asynchronously and are reported through the refund.* webhook events.
# Cancel a subscription
Source: https://docs.bachs.io/api-reference/subscriptions/cancel-subscription
/docs/openapi/openapi.json delete /v1/subscriptions/{subscription_id}
Cancel a subscription immediately, or at the end of the current period with cancel_at_period_end. Returns the updated subscription.
# Retrieve a subscription
Source: https://docs.bachs.io/api-reference/subscriptions/get-subscription
/docs/openapi/openapi.json get /v1/subscriptions/{subscription_id}
Retrieve a single subscription with its product, price, items, and billing dates.
# List subscriptions
Source: https://docs.bachs.io/api-reference/subscriptions/list-subscriptions
/docs/openapi/openapi.json get /v1/subscriptions
List subscriptions for your account, newest first. Filter by customer or status.
# The subscription object
Source: https://docs.bachs.io/api-reference/subscriptions/object
A subscription bills a customer for a recurring product on a fixed cadence. It is created when a customer completes a checkout for a recurring product, and it renews automatically until canceled.
# Update a subscription
Source: https://docs.bachs.io/api-reference/subscriptions/update-subscription
/docs/openapi/openapi.json patch /v1/subscriptions/{subscription_id}
Change a subscription. Send exactly one intent: change the plan, move a trial, or change the payment method. Returns the full updated subscription.
# Success responses
Source: https://docs.bachs.io/api-reference/success-responses
The shape and status code of a successful Bachs API response, for single objects and lists.
When a request succeeds, the Bachs API returns the resource directly with a `2xx` status code. Objects are returned flat, without a wrapper. List endpoints return an `items` array alongside a `pagination` object.
For error responses, see [Errors](/errors).
## Status codes
| Action | Method | Status | Body |
| - | - | - | - |
| Create a resource | `POST` | `201` | The created object, including generated fields. |
| Retrieve a resource | `GET` | `200` | The requested object. |
| List resources | `GET` | `200` | An `items` array and a `pagination` object. |
| Update a resource | `PATCH` | `200` | The updated object, including unchanged fields. |
| Delete a resource | `DELETE` | `204` | Empty. No body is returned. |
## Single object
A retrieve, create, or update returns the object at the top level. Read its fields directly, with no wrapper to unpack.
```json theme={"dark"}
{
"id": "prod_5568ed9ab9d04dd7a31d",
"name": "Pro plan",
"status": "active",
"created_at": "2026-07-13T15:28:39.874Z"
}
```
A `POST` returns `201` with every field of the new object, including any the server generated (`id`, `created_at`) or filled with defaults.
## List
Every list endpoint returns the same shape: an `items` array of objects and a `pagination` object. The array is always named `items`, never the resource name.
```json theme={"dark"}
{
"items": [
{
"id": "cust_a1b2c3d4e5f6",
"email": "ada@example.com",
"name": "Ada Lovelace",
"created_at": "2026-07-13T14:00:00.000Z"
}
],
"pagination": {
"next_cursor": "cur_20",
"prev_cursor": null,
"has_more": true,
"limit": 20,
"offset": 0,
"returned": 1,
"total": 47
}
}
```
Each entry in `items` is the same object shape returned by that resource's retrieve endpoint. See [Pagination](/guides/pagination) for how to page through results.
## Delete
A successful `DELETE` returns `204 No Content` with an empty body. There is nothing to parse. Treat the `204` status itself as confirmation.
## Request ID
Every response, success or error, includes an `x-request-id` header. Log it, and include it when you contact support. See [Errors](/errors#request-id) for details.
# Create a transfer
Source: https://docs.bachs.io/api-reference/transfers/create-a-transfer
/docs/openapi/openapi.json post /v1/transfers
Move funds between your platform balance and an account you own. This debits the source balance immediately and cannot be cancelled. Transfers draw on available balance only, move a single currency, and never take a balance below zero. See the [Split payments](/connect/split-payments) guide for the full flow.
# Get a transfer
Source: https://docs.bachs.io/api-reference/transfers/get-a-transfer
/docs/openapi/openapi.json get /v1/transfers/{transfer_id}
Retrieve a single transfer your platform was a party to. A transfer between two accounts you do not own returns `404` rather than `403`, so the response never confirms that an unrelated id exists.
# List transfers
Source: https://docs.bachs.io/api-reference/transfers/list-transfers
/docs/openapi/openapi.json get /v1/transfers
Returns transfers your platform was a party to, newest first.
# Create a virtual account
Source: https://docs.bachs.io/api-reference/virtual-accounts/create-a-virtual-account
/docs/openapi/openapi.json post /v1/virtual-accounts
Creates a fixed bank account number in one currency, or returns the virtual account that already exists for that currency. See [Virtual accounts](/guides/virtual-accounts/overview) for capability setup, requirements, deposits, and fees.
# Get a virtual account
Source: https://docs.bachs.io/api-reference/virtual-accounts/get-a-virtual-account
/docs/openapi/openapi.json get /v1/virtual-accounts
Returns the virtual account for one currency. Returns `404` with `NOT_FOUND` when the account has no virtual account in that currency.
# The virtual account object
Source: https://docs.bachs.io/api-reference/virtual-accounts/object
A fixed bank account number belonging to your platform or a connected account. It does not expire, and each deposit becomes a payment on that account.
# Create a webhook endpoint
Source: https://docs.bachs.io/api-reference/webhooks/create-a-webhook-endpoint
/docs/openapi/openapi.json post /v1/webhooks/endpoints
Register a URL to receive webhook events, and choose which events to subscribe to. The signing secret is returned once in the response. Requires the `webhooks:write` scope.
# Delete a webhook endpoint
Source: https://docs.bachs.io/api-reference/webhooks/delete-a-webhook-endpoint
/docs/openapi/openapi.json delete /v1/webhooks/endpoints/{endpoint_id}
Delete a webhook endpoint. It stops receiving events immediately. Requires the `webhooks:write` scope.
# List events for an endpoint
Source: https://docs.bachs.io/api-reference/webhooks/list-events-for-an-endpoint
/docs/openapi/openapi.json get /v1/webhooks/endpoints/{endpoint_id}/events
List the events delivered (or attempted) to a specific endpoint. Requires the `webhooks:read` scope.
# List webhook endpoints
Source: https://docs.bachs.io/api-reference/webhooks/list-webhook-endpoints
/docs/openapi/openapi.json get /v1/webhooks/endpoints
List all webhook endpoints for your account. Requires the `webhooks:read` scope.
# List webhook events
Source: https://docs.bachs.io/api-reference/webhooks/list-webhook-events
/docs/openapi/openapi.json get /v1/webhooks/events
List all webhook events for your account, across every endpoint. Requires the `webhooks:read` scope.
# The webhook endpoint object
Source: https://docs.bachs.io/api-reference/webhooks/object
A webhook endpoint is a URL Bachs delivers events to, together with the set of events it is subscribed to. Manage endpoints with an API key that has the webhooks scopes, or from your dashboard.
The **signing secret** is returned in the create-endpoint response. To read it again, call `GET /v1/webhooks/endpoints/{endpoint_id}/secret` with an API key that has `webhooks:read`, or view it in the dashboard, which needs permission to manage webhooks. Treat it like a password. If it leaks, [rotate the secret](/api-reference/webhooks/rotate-an-endpoints-signing-secret) to generate a new one.
# Replay a webhook event
Source: https://docs.bachs.io/api-reference/webhooks/replay-webhook-event
/docs/openapi/openapi.json post /v1/webhooks/replay
Replay a previously generated webhook event by creating a new outbound delivery attempt. Use this when your endpoint missed or rejected an earlier delivery and you need Bachs to send that event again.
Common errors:
- `400 BAD_REQUEST`: No supported lookup field was provided. Resolution: provide at least one of `event_id`, `charge_id`, or `reference`.
- `401 UNAUTHORIZED`: Authorization is missing, invalid, or revoked. Resolution: send a valid bearer credential.
- `404 NOT_FOUND`: No matching webhook event could be resolved for your lookup values. Resolution: verify IDs/references and retry.
# Resend an event to an endpoint
Source: https://docs.bachs.io/api-reference/webhooks/resend-an-event-to-an-endpoint
/docs/openapi/openapi.json post /v1/webhooks/endpoints/{endpoint_id}/events/{event_id}/resend
Re-deliver a past event to a specific endpoint. Requires the `webhooks:write` scope.
# Retrieve a webhook endpoint
Source: https://docs.bachs.io/api-reference/webhooks/retrieve-a-webhook-endpoint
/docs/openapi/openapi.json get /v1/webhooks/endpoints/{endpoint_id}
Retrieve a single webhook endpoint by ID. Requires the `webhooks:read` scope.
# Retrieve a webhook event
Source: https://docs.bachs.io/api-reference/webhooks/retrieve-a-webhook-event
/docs/openapi/openapi.json get /v1/webhooks/events/{event_id}
Retrieve a single event's full payload and delivery attempts. Requires the `webhooks:read` scope.
# Retrieve an endpoint event
Source: https://docs.bachs.io/api-reference/webhooks/retrieve-an-endpoint-event
/docs/openapi/openapi.json get /v1/webhooks/endpoints/{endpoint_id}/events/{event_id}
Retrieve one event's full payload and delivery attempts for a specific endpoint. Requires the `webhooks:read` scope.
# Retrieve endpoint delivery metrics
Source: https://docs.bachs.io/api-reference/webhooks/retrieve-endpoint-delivery-metrics
/docs/openapi/openapi.json get /v1/webhooks/endpoints/{endpoint_id}/metrics
Retrieve delivery success and failure counts for an endpoint over a time range. Requires the `webhooks:read` scope.
# Rotate an endpoint's signing secret
Source: https://docs.bachs.io/api-reference/webhooks/rotate-an-endpoints-signing-secret
/docs/openapi/openapi.json post /v1/webhooks/endpoints/{endpoint_id}/rotate-secret
Generate a new signing secret for an endpoint. The old secret stops working immediately, so update your verification before rotating. Requires the `webhooks:write` scope.
# Update a webhook endpoint
Source: https://docs.bachs.io/api-reference/webhooks/update-a-webhook-endpoint
/docs/openapi/openapi.json patch /v1/webhooks/endpoints/{endpoint_id}
Update an endpoint's name, URL, or subscribed events, or turn it on or off. Only the fields you send are changed. Send `{"enabled": true}` to turn back on an endpoint we turned off after repeated failures; see [When an endpoint keeps failing](/guides/webhooks/overview#when-an-endpoint-keeps-failing). Requires the `webhooks:write` scope.
# Authentication
Source: https://docs.bachs.io/authentication
Authenticate Bachs API requests with sandbox and production API keys using Bearer authorization.
Complete these steps before sending your first authenticated API request:
From the dashboard, you would see a developer section in the sidebar or when you click on your Company Name at the top left corner of the sidebar
Click on the "Create secret key" button on the dashboard under the API keys section
After clicking on the button, you would required to configure some certain things about your key such as a name, the required scopes -
this refers to the scopes permitted for the key. The key would not be able to access any endpoint that its scope does not have access to,
## Sandbox vs Production
Bachs provides two separate deployments, each with its own URL and API keys:
### Sandbox (`sk_sandbox_...`)
* A dedicated deployment for development and testing
* Charges are simulated, so no real money is moved
* Available immediately when you sign up
* Sandbox data is completely isolated from production
### Production (`sk_live_...`)
* The production deployment that processes real charges
* Real money moves through payment providers
* Requires business verification and approval
* Available after completing onboarding
Sandbox and production data are completely isolated. Charges, customers, and balances created with a sandbox key never appear in your production environment.
***
Never commit API keys to source control, logs, screenshots, client apps, or support tickets.
## API Key Scopes
API key scopes control which endpoints a key can access. Assign only the scopes your integration needs.
If a key is missing a required scope for an endpoint, the request is denied.
### Scope format
Scopes follow the pattern:
`:`
Where:
* `` is the API domain (for example `payments`, `payouts`, `refunds`, `disputes`, `webhooks`)
* `` is the permitted operation (typically `read` or `write`)
### Examples
* `payments:read`
* `payments:write`
* `payouts:read`
* `webhooks:write`
### Requirements
* Grant only the minimum scopes required by your integration.
* Use `read` scopes for retrieval/listing endpoints.
* Use `write` scopes for create/update/delete or action endpoints.
* If an endpoint requires a scope your key does not have, the request will be rejected.
```bash cURL theme={"dark"}
curl https://api.bachs.io/v1/balances \
-H "Authorization: Bearer $BACHS_API_KEY"
```
```javascript Node.js theme={"dark"}
const response = await fetch("https://api.bachs.io/v1/balances", {
headers: {
Authorization: `Bearer ${process.env.BACHS_API_KEY}`
}
});
```
```python Python theme={"dark"}
import os
import requests
requests.get(
"https://api.bachs.io/v1/balances",
headers={"Authorization": f"Bearer {os.environ['BACHS_API_KEY']}"},
)
```
```go Go theme={"dark"}
req, _ := http.NewRequest("GET", "https://api.bachs.io/v1/balances", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BACHS_API_KEY"))
```
Prefix conventions are strict: `sk_sandbox_` keys route to the sandbox deployment, while `sk_live_` keys route to production and process real money.
## Security Best Practices
### Keep Your API Keys Secret
API keys carry significant privileges and should be treated like passwords:
* **Never** commit API keys to version control (Git, GitHub, etc.)
* **Never** expose API keys in client-side code (JavaScript, mobile apps)
* **Never** share API keys in public forums or support tickets
* **Always** use environment variables or secure secret management systems
### Use Sandbox Keys During Development
* Build and test your integration against the sandbox deployment using `sk_sandbox_` keys
* Switch to production keys only when you're ready to process real charges
* Never mix sandbox and production keys in the same deployment
### Rotate Keys Regularly
* If you suspect a key has been compromised, revoke it immediately from your dashboard
* Consider rotating keys periodically as part of your security practices
* You can only have one active API key per environment at a time
***
# Prompts
Source: https://docs.bachs.io/build/ai/prompts
Three tested prompts that get a coding assistant to build Bachs one-time payments or subscriptions in your app, or review your integration before go-live. Nothing to install.
Three prompts, each for one complete job. Copy a prompt into a coding assistant that can work with your app's code, such as Claude Code, Cursor, GitHub Copilot or Codex. Each prompt tells the assistant what to read, what to build, which rules to follow, and what to test. Then use the checks under each prompt to confirm the result yourself.
**Nothing to install.** If you use Bachs on many tasks, the [Bachs skill](/build/ai/skills) gives your assistant the same guidance automatically.
Starting a new app? [Choose a starter template](/build/starters/overview) first, then use a prompt to adapt it.
## Before you start
* Have an app your assistant can inspect, and decide what customers will buy.
* Get a [sandbox API key](/authentication). Keep it in your server's environment; do not paste it into a chat.
* Let your assistant read the linked docs. If it cannot open them, use **Copy page** on each guide and paste the content alongside the prompt.
* Use the [Bachs CLI](/cli/overview) to [forward sandbox webhooks to your machine](/developer-portal/local-testing).
Checkout return URLs must be public. For a fully local app, omit checkout redirects and return to the app manually after paying; the CLI still forwards webhooks locally. Use a public deployment or tunnel if you need an automatic return.
Every prompt includes its own integration rules. You can use it without creating an `AGENTS.md` file or installing a skill.
## Build with the SDK
For a Node.js or TypeScript server, use the [official Bachs SDK](https://github.com/bachsdev/bachs-node). It handles API requests, typed responses, and webhook signature verification. Your app still owns authentication, order state, access, and delivery recovery.
The [Next.js SaaS starter](https://github.com/bachsdev/bachs-nextjs-saas) uses the SDK and includes its tested package while the npm release is being prepared. The current npm 0.0.1 package is a placeholder. Follow the starter's package instructions instead of installing that version.
Connect onboarding, account context, and marketplace splits currently use the documented API because these operations are not yet supported by the SDK.
## Choose a prompt
| You want to | Use |
| - | - |
| Sell an item, booking or digital product once | [Accept a one-time payment](#accept-a-one-time-payment) |
| Charge for ongoing access to your product | [Sell subscriptions](#sell-subscriptions) |
| Check an integration before real customers pay | [Review before go-live](#review-before-go-live) |
Building a marketplace? Its prompt is on the [marketplace use case](/build/use-cases/marketplace#build-it-with-an-ai-assistant), because Connect is not yet in the SDK.
## Accept a one-time payment
**What you'll build:** a customer chooses an item, pays on Bachs checkout, and your app fulfils the order after confirming payment.
**Have ready:** what you sell, its price and currency, and the action that completes an order. Products can be created in the dashboard or through the [Products API](/guides/products/overview).
```text Prompt theme={"dark"}
Add Bachs one-time payments to this app. Inspect its framework, authentication,
order records, and payment code first. Reuse those conventions. Ask for the
item, currency, or fulfilment decision if missing.
For a Node.js or TypeScript server, use the official SDK for checkout and
webhooks.constructEvent for signature verification. Read its supported methods
and release instructions at https://github.com/bachsdev/bachs-node and
https://github.com/bachsdev/bachs-nextjs-saas/tree/main/vendor.
Check the installed version; npm 0.0.1 is only a placeholder. Do not reimplement
the SDK transport. For another server language, use the documented API.
Read:
https://docs.bachs.io/guides/checkout/checkout-sessions.md
https://docs.bachs.io/guides/webhooks/overview.md
https://docs.bachs.io/build/use-cases/digital-products.md
https://docs.bachs.io/guides/webhooks/events/collection-succeeded.md
https://docs.bachs.io/guides/idempotency.md
Use https://sandbox-api.bachs.io with BACHS_API_KEY on the server. Explain
dashboard setup, including products and webhook events.
Create checkout on the server with product_cart and our order reference.
Choose prices and product IDs on the server. Save the checkout ID and expected
amount and currency with the order, then redirect to checkout_url. For local testing, omit checkout return URLs; if configured, they must be public.
Verify X-Bachs-Signature-V2 on the raw body before parsing JSON, including
timestamp tolerance and every v1 signature. Fulfil once on checkout.completed
or collection.succeeded, whichever arrives first, after retrieving the checkout
and matching it to the order, amount, and currency. Store event IDs durably; make
fulfilment and event completion atomic or recoverable. Return 5xx on handling
failure. The success page reads our order state and handles a delayed webhook;
visiting it never marks an order paid.
Keep secrets server-side and amounts as decimal strings. Persist an
Idempotency-Key for each write operation. Reconcile an uncertain write before
retrying with the same key and unchanged request.
Test success, unsuccessful checkout, a bad signature, duplicate delivery, and
failed processing followed by redelivery. Report what you actually ran, what
I must configure, and how to complete a real sandbox checkout. Read linked
docs for missing details; do not invent API behavior.
```
### Check the result
* A successful sandbox checkout pays the correct order and fulfils it once.
* Opening the success URL before paying leaves the order unpaid.
* An unsuccessful checkout does not fulfil the order.
* Redelivery cannot repeat fulfilment; failed handling can recover on redelivery.
* An unrelated payment or virtual-account deposit cannot pay the order.
After this works, add an [overlay checkout](/guides/checkout/overlay-checkout), [local pricing](/guides/products/local-pricing), or [refunds](/guides/refunds).
## Sell subscriptions
**What you'll build:** monthly and yearly plans, access that follows subscription state, and a **Manage billing** button for the customer portal.
**Have ready:** signed-in users, your plans, and a decision about access during a failed renewal. The current [subscription guide](/guides/subscriptions/overview) supports USD card billing; [free trials](/guides/subscriptions/trials) are in beta.
```text Prompt theme={"dark"}
Add Bachs subscriptions to this app. Inspect its authentication, database,
access checks, and billing code first. Reuse them. Ask for missing plan details
or the access policy during payment recovery.
For a Node.js or TypeScript server, use the official SDK for products,
checkout, customer portal sessions, and webhooks.constructEvent. Read
https://github.com/bachsdev/bachs-node and the package instructions at
https://github.com/bachsdev/bachs-nextjs-saas/tree/main/vendor.
Check the installed version; npm 0.0.1 is only a placeholder. Keep persistent
keys and business state in our app; handle SDK outcomeUnknown by reconciling.
For another server language, use the documented API.
Read:
https://docs.bachs.io/build/use-cases/saas-subscriptions.md
https://docs.bachs.io/guides/webhooks/overview.md
https://docs.bachs.io/guides/idempotency.md
Use https://sandbox-api.bachs.io and server-side BACHS_API_KEY. Omit checkout return URLs for local testing; configured redirects must be public. Explain how
to create recurring products in the dashboard or API, configure portal settings,
and forward the five webhook events named in the use case.
Map a plan key to a product ID on the server. Create checkout with product_cart,
customer, billing_currency, and metadata.user_id. Recurring checkout starts the
subscription; there is no create-subscription endpoint. If the user already
has access, direct them to manage billing.
Verify X-Bachs-Signature-V2 on the raw body, with timestamp tolerance and all
v1 signatures. From customer.subscription.created, updated, and deleted, save
the customer ID and full subscription state against our user. Deduplicate
durably and prevent older events overwriting newer state, including concurrent
deliveries. Commit state and event completion together; return 5xx on failure.
Decide access from stored state, including trialing, active, past_due, and
cancellation. The success page only reads that state. Create each portal
session on the server for the signed-in user's own customer ID. Check the
documented portal settings and cancellation defaults.
Keep secrets server-side and amounts as decimal strings. Persist one
Idempotency-Key per write operation; reconcile uncertain writes before retrying.
Test activation, duplicate and out-of-order events, failed handling followed by
redelivery, renewal failure, and cancellation at period end. Report tests
actually run, remaining setup, and real sandbox checkout steps. Read linked
docs for missing details; do not invent behavior.
```
### Check the result
* A sandbox payment saves the user's customer ID and subscription, then grants access.
* A redirect alone cannot grant access.
* Duplicate or older events cannot undo a later cancellation.
* **Manage billing** opens the signed-in user's own customer portal.
* Scheduled cancellation keeps access for the remaining paid period; immediate cancellation removes it.
* Renewal failure follows your access policy and can recover after a successful retry.
The [full walkthrough](/build/use-cases/saas-subscriptions) includes requests, webhook examples, and a go-live checklist. Add [trials](/guides/subscriptions/trials) or [plan changes](/guides/subscriptions/manage) after the basic flow works.
## Review before go-live
**What you get:** a list of problems in your Bachs integration, by file and line and ordered by risk, with what correct code does. The assistant does not change your code.
**Have ready:** an integration you have built, with or without an assistant. Run this before you switch to production keys, and again after large changes.
```text Prompt theme={"dark"}
Review this codebase's Bachs payment integration before we go live. Do not
change any code.
First read:
https://docs.bachs.io/go-live.md
https://docs.bachs.io/guides/webhooks/overview.md
https://docs.bachs.io/guides/checkout/checkout-sessions.md
https://docs.bachs.io/guides/idempotency.md
Then read our integration: checkout, webhook handling, success or return pages,
billing or portal routes, and configuration.
Report each problem with its file and line, ordered by risk, with one sentence
on why it matters and what correct code does. Check at least:
- secret keys or webhook secrets that could reach the browser, logs or the
repository
- prices, amounts or product IDs taken from the client
- amounts sent as numbers instead of decimal strings with a currency
- orders fulfilled or access granted anywhere other than verified webhook
handling
- a payment fulfilled without matching it to our order, amount and currency
- webhook bodies parsed before the signature is checked, a missing timestamp
check, or only the first v1 signature checked
- missing protection against duplicate or out-of-order webhook events
- 4xx responses returned when our own handling fails (Bachs does not retry
them)
- POST or PATCH requests without a persisted Idempotency-Key
- requests the Bachs docs say are invalid
- sandbox URLs, keys or secrets that must change for production
Then list anything you could not check and why. Do not describe this review as
a sandbox test.
```
### Check the result
* Every problem names a file and line you can open.
* Fix the problems, then run the prompt again until it reports none you disagree with.
* A review reads code. It does not prove a payment works. Complete a real sandbox payment, including a failure case, before you go live. See [Test payment outcomes](/integrate/sandbox#test-payment-outcomes).
Then follow [Go live](/go-live), or [Take Connect live](/connect/go-live) for a marketplace: create production resources, use the production key and API URL, and register a webhook endpoint with its own signing secret.
# Bachs skill
Source: https://docs.bachs.io/build/ai/skills
Install the Bachs skill once, and your AI coding assistant follows the Bachs rules on every task: payments, subscriptions, marketplaces and pre-launch reviews.
A **skill** is a file of instructions that your AI coding assistant reads before it writes code. The Bachs skill tells assistants such as Claude Code, Cursor, GitHub Copilot and Codex which Bachs docs to read, which mistakes to avoid, and how to test the result in the sandbox.
You install it once per project. After that, describe what you want, for example "add subscriptions to this app", and your assistant follows the Bachs rules without you pasting them in.
**Don't use skills?** You don't need them. The [prompts](/build/ai/prompts) carry the same guidance as copy-paste text, and you can [paste the skill into any chat](#install).
## Skill or prompt?
| | Prompts | Skill |
| - | - | - |
| Setup | None | One command per project |
| Best for | A single task, or trying Bachs for the first time | Building and maintaining a Bachs integration over time |
| How you use it | Copy the prompt for your task | Describe your task in your own words |
| Covers | One-time payments, subscriptions and a pre-launch review. The marketplace prompt is on its [use case page](/build/use-cases/marketplace#build-it-with-an-ai-assistant). | All four jobs below |
## What the skill does
| Job | What your assistant builds or checks |
| - | - |
| One-time payments | Hosted checkout, order fulfilment from verified webhooks |
| SaaS subscriptions | Plans, access from subscription state, the customer portal |
| Marketplace payments | Seller accounts with Connect, the platform fee, seller payouts |
| Review before go-live | A report of problems by file and line, without changing code |
## Install
Run the command for your tool from your project's root folder.
Install the skill as a plugin, so you can update it later with one command:
```text Claude Code theme={"dark"}
/plugin marketplace add bachsdev/bachs-skills
/plugin install bachs@bachs
```
Or save the file into your project:
```bash Terminal theme={"dark"}
curl -fsSL --create-dirs -o .claude/skills/bachs/SKILL.md https://raw.githubusercontent.com/bachsdev/bachs-skills/main/skills/bachs/SKILL.md
```
Cursor reads skills from the same folder as Claude Code:
```bash Terminal theme={"dark"}
curl -fsSL --create-dirs -o .claude/skills/bachs/SKILL.md https://raw.githubusercontent.com/bachsdev/bachs-skills/main/skills/bachs/SKILL.md
```
Copilot reads skills from the same folder as Claude Code:
```bash Terminal theme={"dark"}
curl -fsSL --create-dirs -o .claude/skills/bachs/SKILL.md https://raw.githubusercontent.com/bachsdev/bachs-skills/main/skills/bachs/SKILL.md
```
```bash Terminal theme={"dark"}
curl -fsSL --create-dirs -o .agents/skills/bachs/SKILL.md https://raw.githubusercontent.com/bachsdev/bachs-skills/main/skills/bachs/SKILL.md
```
1. Download `bachs-skill.zip` from the [latest release](https://github.com/bachsdev/bachs-skills/releases/latest).
2. In Claude, open your skills settings and upload the zip.
3. Turn the skill on.
Open the [skill file](https://raw.githubusercontent.com/bachsdev/bachs-skills/main/skills/bachs/SKILL.md), copy all of it, and paste it at the start of your conversation, before you describe your task.
On Windows, run the commands in PowerShell with `curl.exe` instead of `curl`.
## Use it
Describe what you want to build. Your assistant loads the skill when the task is about Bachs:
```text Example theme={"dark"}
Add Bachs subscriptions to this app, with monthly and yearly plans.
```
```text Example theme={"dark"}
Review our Bachs integration before we go live.
```
In Claude Code you can also call it directly: `/bachs` when you saved the file, or `/bachs:bachs` when you installed the plugin.
Keep your sandbox API key in your server's environment. Never paste it into a chat.
## Update
* **Claude Code plugin:** run `/plugin marketplace update bachs`.
* **Saved file:** run the install command again.
The [changelog](https://github.com/bachsdev/bachs-skills/blob/main/CHANGELOG.md) lists what changed in each version.
## What the skill says
The skill is plain Markdown. This is the current version from the [bachs-skills repository](https://github.com/bachsdev/bachs-skills):
```md SKILL.md theme={"dark"}
---
name: bachs
description: Build Bachs payment integrations (one-time checkout, SaaS subscriptions, marketplace payments with Connect) or review an existing Bachs integration before go-live. Use for checkout, webhook handling, billing access, seller onboarding, testing these flows, and pre-launch reviews.
---
# Build with Bachs
Implement the requested Bachs flow in the user's existing app. Use the current
docs for API fields, product availability, and account requirements.
## Understand the app
Read project instructions and existing authentication, order or billing records,
payment code, and tests. Reuse the app's framework and data layer.
Establish the requested flow, currency, product or plan, and account ownership.
Ask only for missing decisions that affect implementation. Explain the proposed
flow briefly, then carry out the authorized work.
## Read the relevant workflow
Read the selected guide and the references needed for the task. If web access
is unavailable, ask the user to paste the pages using Copy page. Do not invent
endpoints, fields, events, or eligibility.
| Task | Start here |
| --- | --- |
| One-time payment | https://docs.bachs.io/guides/checkout/checkout-sessions.md |
| SaaS subscriptions | https://docs.bachs.io/build/use-cases/saas-subscriptions.md |
| Marketplace | https://docs.bachs.io/build/use-cases/marketplace.md |
| Review before go-live | https://docs.bachs.io/go-live.md and the rules below |
Shared references:
- Webhooks: https://docs.bachs.io/guides/webhooks/overview.md
- Local testing: https://docs.bachs.io/developer-portal/local-testing.md
- Write recovery: https://docs.bachs.io/guides/idempotency.md
- Page index: https://docs.bachs.io/llms.txt
Include dashboard setup such as products, capability requests, webhook endpoints,
or portal settings. Separate dashboard setup from routes in the user's app.
## Use the official SDK when supported
For a Node.js or TypeScript server, use the official @bachs/sdk for products,
checkout sessions, subscriptions, customer portal sessions and webhook
verification. Read https://github.com/bachsdev/bachs-node before choosing methods
or types. Construct Bachs with apiKey and an explicit environment. Use
webhooks.constructEvent on the raw body and request headers; keep event
persistence and business decisions in the app.
Check the package's actual exports and version. The npm 0.0.1 package is a
placeholder, not the tested implementation. Until the 1.0.0 implementation is
released on npm, the public starter includes its packaged copy and provenance:
https://github.com/bachsdev/bachs-nextjs-saas/tree/main/vendor
Do not install the placeholder or add a dependency on a local sibling repository.
After the matching release is available, use its exact npm version.
The SDK sends writes once and reports outcomeUnknown on uncertain writes.
Persist operation keys in the app and reconcile before sending again. Do not
build another transport, response decoder or signature verifier for supported
SDK operations. Do not log full provider error bodies or portal URLs.
The current SDK does not support Connect account context, seller onboarding or
checkout split fields. Use documented API requests for those marketplace
operations; do not invent SDK methods or pass unsupported fields through casts.
Other server languages can use the documented API.
## Integration rules
- Start in the sandbox: https://sandbox-api.bachs.io with an sk_sandbox_ key.
Production uses https://api.bachs.io and an sk_live_ key. Keep secrets on the
server in environment variables. Never log them or put them in client code.
- Send amounts as decimal strings with an ISO currency at its precision.
Choose or validate prices and product mappings on the server.
- Checkout redirects must be publicly accessible, even in sandbox. For a fully
local app, omit them and return manually after payment. Use a public deployment
or tunnel for automatic return; CLI webhook forwarding is separate.
- Link the local order or user to checkout and save Bachs IDs. Match a payment
to its order, expected amount, and currency before fulfilling it. A virtual
account deposit without an order reference does not pay an order automatically.
- Verify X-Bachs-Signature-V2 against the original raw request body before
parsing JSON. Check timestamp tolerance and all v1 signatures as documented.
- Fulfil orders and grant access from verified webhook state. A success redirect
or browser event is only a display signal.
- Deduplicate event IDs durably and prevent older state replacing newer state,
including concurrent deliveries. Commit state and event completion atomically
in the app's database, or use a durable queue with equivalent recovery.
Failed processing must remain eligible for redelivery.
- Return 2xx after handling or durably accepting an event; return 5xx on processing
failure. Follow the documented retry policy: 408 and 429 are exceptions to the
usual non-retry behavior for 4xx.
- Persist one Idempotency-Key per business operation for public POST/PATCH calls.
Reuse it with the same request when recovery establishes a retry is needed.
A timeout or 5xx is an uncertain outcome. Reconcile before resubmitting;
only successful JSON responses are cached.
## Workflow decisions
### One-time payment
Use hosted checkout first unless an overlay is requested. Create it on the
server using product_cart and a local order reference. Fulfil once from
collection.succeeded after matching the order. Show pending, paid, and
unsuccessful outcomes from stored order state.
### SaaS subscriptions
A recurring product checkout creates the subscription; there is no separate
create-subscription endpoint. A recurring checkout needs a customer. Confirm
supported billing methods and currencies in the current guide.
Save the customer and full subscription state against the signed-in user from
customer.subscription.created, updated, and deleted events.
Make trialing, active, past_due, and cancellation access policy explicit.
Create each portal session on the server for the user's own customer.
Check dashboard settings for card updates and plan switching, and read current
cancellation semantics before implementing API cancellation.
### Marketplace
Confirm destination charges fit the business: the platform owns the sale and
the seller sub account receives a share at settlement. Read
https://docs.bachs.io/connect/choose-your-integration.md if the business should
own the sale instead. Read onboarding, capabilities, refunds, and payouts.
Start with one seller per order unless the user needs and the docs support
another arrangement. Save its account ID and choose destination and fee on
the server. Let the destination charge generate its transfer at settlement;
do not add a second manual transfer.
Payment, seller balance credit, and payout delivery are separate states. A
payout uses the seller's balance and its usable destination. This is not an
escrow integration.
### Review before go-live
When asked to review, do not change code unless the user asks. Read the
integration, then report each problem with file and line, ordered by risk:
secret keys that can reach the client; amounts sent as numbers; prices or
product IDs taken from the client; fulfilment or access granted outside verified
webhook handling; webhook bodies parsed before signature verification; missing
duplicate-event or older-event protection; 4xx returned when the app's own
handling fails; writes without a persisted Idempotency-Key; sandbox URLs, keys
or webhook secrets that must change for production. Say which checks you could
not complete and why. Do not call a code review a sandbox test.
## Verify and hand off
Test observable behavior for the selected flow: success, unsuccessful payment,
invalid signatures, duplicate delivery, older events arriving late, processing
failure followed by redelivery, and uncertain writes. For subscriptions include
renewal failure and cancellation; for marketplaces include blocked onboarding,
settlement, and payout failure.
Use existing test tools and the sandbox instructions. A synthetic webhook checks
handling, not completed checkout or settlement. Do not describe mocks or a code
review as a real sandbox payment.
Report changes, tests actually run, dashboard setup still needed, and unverified
steps. Production transactions and deployment require the user's authorization.
```
## Other ways to give your assistant the docs
* **Ask a question.** The bar at the bottom of every docs page answers questions from the Bachs docs.
* **Copy page.** The menu at the top of every page copies it as Markdown, or opens it in ChatGPT or Claude.
* **Markdown pages.** Add `.md` to any docs URL to get the page as Markdown.
* **Docs index.** [`llms.txt`](https://docs.bachs.io/llms.txt) lists every page with a one-line summary. [`llms-full.txt`](https://docs.bachs.io/llms-full.txt) has every page in one file.
# Build with Bachs
Source: https://docs.bachs.io/build/overview
Use cases, starter templates, AI workflows, and working examples for building products with Bachs.
Build a product that accepts payments, sells subscriptions, or helps other businesses get paid. Choose a use case to understand the flow, a starter template to begin with working code, or an AI workflow to add Bachs to your existing app.
## Choose where to start
| You want to | Start here |
| - | - |
| Understand how Bachs fits into your product | [Use cases](#use-cases) |
| Start a new app with a working integration | [Starter templates](/build/starters/overview) |
| Add Bachs to an app with a coding assistant | [Build with AI](#build-with-ai) |
| Try a working integration or explore community tools | [Examples](#examples) |
## Use cases
Each walkthrough explains how Bachs features fit together, what your app needs to handle, and how to test the flow in the sandbox.
| Use case | What you'll build | Built on |
| - | - | - |
| [SaaS subscriptions](/build/use-cases/saas-subscriptions) | Monthly and yearly plans, access from webhooks, self-service billing. | Products, checkout, subscriptions, customer portal |
| [Digital products](/build/use-cases/digital-products) | Fixed-price and pay-what-you-want downloads, delivered once the payment is confirmed. | Products, guest checkout, webhooks |
| [Marketplace](/build/use-cases/marketplace) | Your platform collects a sale and allocates the seller's share at settlement. | Connect, destination charges |
| [Platform for businesses](/build/use-cases/saas-platform) | Businesses on your platform sell to their own customers, and you take a fee. | Connect, direct charges, platform fees |
The two Connect use cases need the `connect` capability on your account. See [Become a platform](/connect/become-a-platform). Follow the linked [Guides](/introduction) for individual features and the API reference for request details.
## Starter templates
[Browse starter templates](/build/starters/overview) to choose working code by framework and use case. Each template explains what is included, how to run it in the sandbox, and what to adapt for your product.
The [Next.js SaaS starter](https://github.com/bachsdev/bachs-nextjs-saas) is available now. It uses the official Bachs SDK for subscription checkout, product setup, customer billing, and webhook verification.
## Build with AI
* [Prompts](/build/ai/prompts): three tested prompts for one-time payments, subscriptions and a review before go-live. Copy one into your assistant; nothing to install.
* [Bachs skill](/build/ai/skills): install once, and your assistant follows the Bachs rules on every task, including marketplaces.
For your assistant, every docs page is available as Markdown by adding `.md` to its URL. The [docs index](https://docs.bachs.io/llms.txt) lists the pages and their purpose.
## Examples
* [Live demo](/demo): try one-time payments, subscriptions, trials, and pay-what-you-want in the Bachs sandbox.
* [Community projects](/community/overview): explore SDKs, plugins, and boilerplates contributed by developers.
# Starter templates
Source: https://docs.bachs.io/build/starters/overview
Choose working starter code by framework and use case, run it in the Bachs sandbox, and adapt it to your product.
Start a new app with a working Bachs integration. Choose a template for your framework and the payment flow your product needs, then follow its setup instructions and test it in the sandbox.
## Available templates
| Template | Framework | Use case | Included |
| - | - | - | - |
| [Next.js SaaS](https://github.com/bachsdev/bachs-nextjs-saas) | Next.js App Router, TypeScript | Subscription access | Monthly and yearly checkout, verified webhooks, access checks, customer portal, and automated tests |
| [Next.js digital products](https://github.com/bachsdev/bachs-nextjs-digital-products) | Next.js App Router, TypeScript | One-time downloads | Fixed-price and pay-what-you-want products, guest checkout, delivery checked against Bachs, secret download links, and automated tests |
### Next.js SaaS
Use this starter for a product that charges for ongoing access, such as a software tool or a membership app.
The starter uses the [official Bachs SDK](https://github.com/bachsdev/bachs-node) for checkout, product setup, customer portal sessions, and webhook verification. It saves subscription state and grants access after a verified webhook. A success redirect cannot grant access.
You need Node.js 22 or later, a [sandbox API key](/authentication), and the [Bachs CLI](/cli/overview). See the repository's [README](https://github.com/bachsdev/bachs-nextjs-saas#readme) for setup and SDK package instructions.
This is a local development starter. Every visitor shares a demo user, and billing data is stored in a local JSON file. Replace authentication and storage before deploying. Add your product's features and choose its access policy.
### Next.js digital products
Use this starter to sell something people download, such as an ebook, a template pack or a course, at a fixed price or pay what you want.
The starter uses the [official Bachs SDK](https://github.com/bachsdev/bachs-node) for checkout, product setup and webhook verification. Buyers check out as guests. A download is released only after a verified webhook and a read of the checkout from Bachs confirm the payment matches the order. See the repository's [README](https://github.com/bachsdev/bachs-nextjs-digital-products#readme).
This is a local development starter. Orders are stored in a local JSON file, and buyers find their orders through a browser cookie. Replace storage and email buyers their order link before deploying.
## Start building
1. Open the template's repository and select **Use this template** to create your own copy.
2. Follow its README to configure sandbox products, start the webhook listener, and run the app. Authenticate the CLI to the same sandbox account as the app.
3. Complete a sandbox checkout. In the SaaS starter, confirm that access activates and **Manage billing** opens the right customer's portal. In the digital products starter, confirm that the order turns paid and the download appears.
4. Adapt the app to your product. Replace the demo components, run the included tests, and follow the go-live checklist on the template's use case page.
For a fully local app, the starters leave checkout return URLs unset. Return to the app manually after payment. Automatic return requires a public URL; webhook forwarding through the CLI still works locally.
## Learn the flow
* [SaaS subscriptions](/build/use-cases/saas-subscriptions): the objects, requests, webhook events, and access decisions in the SaaS starter.
* [Sell digital products](/build/use-cases/digital-products): the checkout, delivery checks and edge cases in the digital products starter.
* [Prompts](/build/ai/prompts#sell-subscriptions): use a coding assistant to adapt subscription billing to your app.
* [Live demo](/demo): try other billing models in the sandbox.
# Sell digital products
Source: https://docs.bachs.io/build/use-cases/digital-products
Sell ebooks, templates, courses or software at a fixed price or pay what you want, and release the download only when Bachs confirms the payment.
You sell something people download: an ebook, a template pack, a course or a software licence. In this guide you'll create your products, send a buyer to checkout without asking them to sign up, and release the download only after Bachs confirms the payment. By the end you'll have a store that charges a fixed price or lets the buyer name their price, and that delivers each order exactly once.
## How it fits together
```mermaid theme={"dark"}
sequenceDiagram
participant C as Buyer
participant A as Your store
participant B as Bachs
C->>A: Clicks "Buy"
A->>A: Save the order and an Idempotency-Key
A->>B: Create a checkout (reference = order ID)
B-->>A: checkout_url
A-->>C: Redirect to checkout_url
C->>B: Enters email, pays
B->>A: Webhook: checkout.completed
A->>B: Retrieve the checkout
B-->>A: status completed, amount, currency
A->>A: Match to the order, mark it paid
C->>A: Opens the order page
A-->>C: Download link
```
Your store owns the orders and the files. Bachs owns the payment. The store releases a file only when Bachs reports the checkout as completed, for the amount the order expects.
## What you'll use
| Object | Its job in this build | Reference |
| - | - | - |
| Product | One thing you sell, at a fixed price or pay what you want. | [The product object](/api-reference/products/object) |
| Checkout session | The hosted page where the buyer enters their email and pays. Its `reference` is your order ID. | [The checkout session object](/api-reference/checkout-sessions/object) |
| Webhook endpoint | The route on your server that Bachs tells when a checkout completes. | [Set up webhooks](/guides/webhooks/overview) |
## Before you start
* A **sandbox API key** (`sk_sandbox_...`) with `products:write` and `payments:write`. See [Authentication](/authentication) and [Permissions](/api-reference/permissions).
* The **Bachs CLI**, to forward webhooks to your machine. See [Install the CLI](/cli/overview#install).
* A server-side app. The examples use Node.js with the [official Bachs SDK](https://github.com/bachsdev/bachs-node); every call is also a plain API request.
Every request goes to `https://sandbox-api.bachs.io`, so nothing moves real money while you build.
## Steps
Create one product for each thing you sell. A fixed-price product needs an `amount`. A pay-what-you-want product uses `price_type: "custom"`, with a `minimum_amount` and a `preset_amount` that the checkout shows first.
```bash Fixed price theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/products \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Idempotency-Key: create-field-guide" \
-H "Content-Type: application/json" \
-d '{
"name": "The Field Guide",
"price": { "currency": "USD", "amount": "19.00" }
}'
```
```bash Pay what you want theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/products \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Idempotency-Key: create-template-pack" \
-H "Content-Type: application/json" \
-d '{
"name": "The Template Pack",
"price": {
"currency": "USD",
"price_type": "custom",
"minimum_amount": "5.00",
"preset_amount": "10.00"
}
}'
```
Keep each product ID, and keep its price or minimum in your own catalog too. Your store checks the amount paid against it before it delivers anything. See [Products](/guides/products/overview).
When a buyer clicks **Buy**, save an order first, with its own ID and an Idempotency-Key. Then create the checkout with the order ID as its `reference`. The browser sends only which product it wants; your server chooses the product ID.
Leave out `customer`. The checkout page asks for the buyer's email, so nobody needs an account with your store.
```ts app/api/checkout/route.ts theme={"dark"}
const order = await saveOrder({ product: "guide", productId, idempotencyKey: randomUUID() });
const checkout = await bachs.checkoutSessions.create(
{
product_cart: [{ product_id: productId, quantity: 1 }],
reference: order.id,
metadata: { order_id: order.id },
success_url: "https://store.example.com/orders/" + order.id, // must be public
},
{ idempotencyKey: order.idempotencyKey },
);
await saveCheckout(order.id, checkout.checkout_id);
return Response.json({ checkout_url: checkout.checkout_url });
```
A `reference` is unique for good on your account, even after its checkout expires, so one order has exactly one checkout. If the request times out, keep the order and its key: if the buyer paid, the webhook still names the order. See [Idempotency](/guides/idempotency).
Bachs refuses `localhost` and private network addresses for `success_url` and `cancel_url`, in the sandbox too. While you build on your own machine, leave them out and go back to your store yourself after paying.
```bash Terminal theme={"dark"}
bachs listen --forward-to localhost:3000/api/webhooks/bachs \
--events checkout.completed,collection.succeeded,checkout.expired
```
Put the signing secret it prints (`whsec_...`) in your environment as `BACHS_WEBHOOK_SECRET`. See [Test webhooks locally](/developer-portal/local-testing).
`checkout.completed` is sent once a checkout is paid, and `collection.succeeded` once its payment succeeds. Either can arrive first, and either can arrive more than once. Handle both the same way, and deliver each order once:
1. Verify the signature on the raw body.
2. Find the order from `data.reference`, or from `data.checkout_id`.
3. If the order is already paid, stop.
4. Retrieve the checkout from Bachs. Continue only if its `status` is `completed`. If it isn't yet, answer `503` so Bachs sends the event again later.
5. Check that the checkout's `currency` and `amount` match the order: the exact price for a fixed-price product, at least the minimum for pay what you want. If they don't, hold the order for review instead of delivering it.
6. Mark the order paid and record the event ID, in one database transaction.
```ts app/api/webhooks/bachs/route.ts theme={"dark"}
import { webhooks, BachsWebhookError } from "@bachs/sdk";
export async function POST(req: Request) {
let event;
try {
event = webhooks.constructEvent(new Uint8Array(await req.arrayBuffer()), req.headers, process.env.BACHS_WEBHOOK_SECRET!);
} catch (err) {
if (err instanceof BachsWebhookError) return new Response("Invalid webhook", { status: 400 });
throw err;
}
if (!["checkout.completed", "collection.succeeded"].includes(event.type)) return new Response("OK");
try {
const order = await findOrder(event.data.reference, event.data.checkout_id); // your own store
if (!order || order.paid) return new Response("OK");
const checkout = await bachs.checkoutSessions.get(String(event.data.checkout_id));
if (checkout.status !== "completed") return new Response("Not completed yet", { status: 503 });
if (!amountMatches(order, checkout.amount, checkout.currency)) {
await holdForReview(order.id, event.id); // do not deliver
return new Response("OK");
}
await markPaid(order.id, event.id, checkout.customer_details?.email); // one transaction
return new Response("OK");
} catch {
return new Response("Could not handle event", { status: 500 }); // Bachs retries 5xx
}
}
```
The checkout's `amount` is the total in the product's currency. For pay what you want, it is the price the buyer chose. Compare amounts as whole minor units, not floating-point numbers.
Never deliver from the `success_url` page. The buyer can open it without paying. The order page should only read the order your webhook saved, and show the download once it is paid.
Give each order a long random secret, and serve the file only to a request that carries the order's secret, for an order that is paid. Keep the files outside your public folder, so the only way to them is through that check.
Email the buyer a link to their order page after payment. The buyer's email is on the checkout's `customer_details`.
1. Start your store and `bachs listen`.
2. Buy each product. Pay with any test card number, for example `4242 4242 4242 4242`. For pay what you want, change the price on the checkout page.
3. Watch `checkout.completed` and `collection.succeeded` arrive. The order turns paid and the download appears.
4. Pay with `4000 0000 0000 0002` to see a declined card. The checkout stays open and nothing is delivered.
See [Test payment outcomes](/integrate/sandbox#test-payment-outcomes) for the other test cards.
## Webhooks to handle
| Event | What it means | What your store does |
| - | - | - |
| [`checkout.completed`](/guides/webhooks/events/checkout-completed) | The checkout is paid. Sent once per checkout. | Check with Bachs, then deliver the order. |
| [`collection.succeeded`](/guides/webhooks/events/collection-succeeded) | The checkout's payment succeeded. | The same as `checkout.completed`. Whichever arrives first delivers the order. |
| [`checkout.expired`](/guides/webhooks/events/checkout-expired) | The checkout was not paid in time. | Show the order as expired, but don't close it: a payment can still arrive and complete it. |
You don't need `collection.failed`. It is sent when one payment attempt fails, but the checkout stays open and the buyer can try again.
## Edge cases
The webhook still arrives and the order is marked paid. Email the buyer their order link, so they can get the file without returning to the same browser.
Delivery is at least once and not in order. Record each event ID with the order change in one transaction, and stop when the order is already paid. Whichever event arrives first delivers the order; the other changes nothing.
A buyer can still pay a checkout after you receive `checkout.expired`, for example with a bank transfer sent before it expired. The checkout then completes and `checkout.completed` follows. Don't treat expiry as final, and don't reuse the order for a new checkout.
A card always charges the full amount. A bank transfer can arrive short. The checkout then does not complete, so no fulfilment event is sent and the order stays unpaid. You can [refund](/guides/refunds) the amount received. For crypto, a short payment sends [`collection.underpaid`](/guides/webhooks/events/collection-underpaid), and the buyer can send the rest.
Bachs refuses a pay-what-you-want price below the product's `minimum_amount`. Your store checks the amount again anyway, and holds any order that does not match for review.
Refund the payment with [Issue a refund](/guides/refunds), using the `charge_id` from `collection.succeeded` or from the checkout's `charge`. A payment can carry one refund, so decide on a partial refund before you send it. Revoke the download when the refund is paid.
The checkout may exist. Keep the order and its Idempotency-Key, and don't create another checkout for it. If the buyer paid, the webhook names the order through its `reference`.
## Go-live checklist
* [ ] Your account is verified. See [Go live](/go-live).
* [ ] You created your products again in production and updated their IDs. Sandbox and production share nothing.
* [ ] Your catalog prices match the products in Bachs.
* [ ] Your server uses an `sk_live_` key and `https://api.bachs.io`.
* [ ] `success_url` points to a public page on your store.
* [ ] You registered your production webhook endpoint for `checkout.completed`, `collection.succeeded` and `checkout.expired`, and put its signing secret in your environment.
* [ ] Orders, event IDs and download secrets are in a real database, not a local file.
* [ ] Buyers receive an email with the link to their order.
## Start from working code
The [Next.js digital products starter](https://github.com/bachsdev/bachs-nextjs-digital-products) is this guide as a working store: a fixed-price product, a pay-what-you-want product, guest checkout, delivery checked against Bachs, and secret download links, with tests for each rule on this page.
## Build it with an AI assistant
The [one-time payment prompt](/build/ai/prompts#accept-a-one-time-payment) builds this flow into your own app.
## Next steps
* [Accept a payment](/guides/checkout/checkout-sessions): every checkout option, including restricting payment methods.
* [Add an overlay checkout](/guides/checkout/overlay-checkout): keep buyers on your page while they pay.
* [Sell in local currencies](/guides/products/local-pricing): show buyers prices in their own currency.
* [Issue a refund](/guides/refunds): refund a purchase in full or in part.
# Build a marketplace
Source: https://docs.bachs.io/build/use-cases/marketplace
Run a full marketplace sale in the sandbox, from creating a seller to watching its share land.
Your customers buy from your marketplace, so the sale is yours: the charge lands in your balance, and each seller's share moves down to it when the charge settles. In this guide you'll create a seller, sell something, take the payment, and watch the seller's share arrive. By the end you'll have run a full destination charge without moving real money.
## How it fits together
```mermaid theme={"dark"}
sequenceDiagram
participant S as Seller
participant P as Your platform
participant C as Customer
participant B as Bachs
P->>B: Create the seller's account
B->>P: Webhooks: account.updated, capability.updated
C->>P: Buys from the seller
P->>B: Create a checkout naming the seller (transfer_data)
C->>B: Pays on the hosted checkout
B->>P: Webhook: checkout.completed
Note over B: The charge settles
B->>P: Webhook: transfer.created (the seller's share)
S->>B: Withdraws to its own destination
```
The sale is yours, so the money lands in your balance first. Bachs moves the seller's share to the seller's balance when the charge settles, and the seller withdraws from there.
## What you'll use
| Object | Its job in this build | Reference |
| - | - | - |
| Account | The seller. It receives money and withdraws it, but never takes payments itself. | [Accounts](/connect/accounts) |
| Checkout session | Your platform's checkout. `transfer_data` names the seller and its share. | [Destination charges](/connect/split-payments/destination) |
| Transfer | The seller's share, moved at settlement. | [Transfers](/connect/transfers) |
| Payout destination | Where the seller withdraws to. | [Payouts on Connect](/connect/payouts) |
| Webhook endpoint | Tells you about payments and about your sellers' accounts. Set `event_source` to `connect` or `all`. | [Connect events](/guides/webhooks/overview#connect-events) |
## Before you start
* A **sandbox API key** (`sk_sandbox_...`). See [Authentication](/authentication).
* The `connect` capability `active` on your account. See [Become a platform](/connect/become-a-platform).
Every request below goes to `https://sandbox-api.bachs.io`. Going live is the same calls against `https://api.bachs.io` with an `sk_live_` key. See [Take Connect live](/connect/go-live).
## Steps
A marketplace seller receives money; it never takes payments of its own. So it only needs the `recipient` configuration, and its onboarding stays short. Naming no payment-accepting capability is what keeps it that way.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/accounts \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"contact_email": "ada@example.com",
"display_name": "Ada Stores",
"country": "NG",
"entity_type": "individual",
"configuration": { "recipient": {} }
}'
```
```json Response theme={"dark"}
{
"id": "acct_Shi4LnKkKht5bmbS",
"name": "Ada Stores",
"country": "NG",
"entity_type": "individual",
"capabilities": {
"payouts": { "status": "active", "requested": true },
"transfers": { "status": "active", "requested": true },
"conversions": { "status": "active", "requested": true }
},
"configuration": { "recipient": {} },
"balance_currencies": [],
"is_active": true
}
```
Copy the `id`. Every step below uses it as the seller's account id.
`transfers` is what lets the seller receive its share, and `payouts` is what lets it withdraw. In sandbox both are active immediately; in live they start `pending_review` until a reviewer enables them. See [Capabilities](/connect/capabilities) and [Testing Connect](/connect/testing).
The product is **yours**, not the seller's: you are the merchant of record. Price it in NGN, which settles the same day.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/products \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"name": "Handwoven basket",
"description": "Sold by Ada Stores on the marketplace.",
"price": { "currency": "NGN", "amount": "100000.00" }
}'
```
```json Response theme={"dark"}
{
"id": "prod_a3ef11afb6c446f9ba41",
"name": "Handwoven basket",
"price": { "currency": "NGN", "price_type": "fixed", "amount": "100000.00" },
"status": "active"
}
```
Copy the product `id` for the next step.
`transfer_data.destination` is what makes this a destination charge: the sale is yours, and the account you name is paid out of it. `platform_fee` is the part you keep; naming `transfer_data.amount` instead fixes what the seller receives, with your platform keeping the rest. See [Destination charges](/connect/split-payments/destination) for both.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"product_cart": [
{ "product_id": "prod_a3ef11afb6c446f9ba41", "quantity": 1 }
],
"transfer_data": { "destination": "acct_Shi4LnKkKht5bmbS" },
"platform_fee": "20000.00",
"customer": { "email": "jane@example.com" },
"success_url": "https://example.com/thank-you",
"cancel_url": "https://example.com/checkout"
}'
```
```json Response theme={"dark"}
{
"checkout_id": "chk_yMpCSunbArKaSnzJ",
"status": "open",
"amount": "100000.00",
"currency": "NGN",
"platform_fee": "20000.00",
"checkout_url": "https://sandbox-checkout.bachs.io/c/SfxP7y-gSkzJsZs"
}
```
Copy the `checkout_id` and `checkout_url`.
Out of the `100000.00` the customer pays, `20000.00` is yours and the rest is the seller's.
Name only an account you own. Any other id is refused, in sandbox and in live.
In your integration you redirect the customer to `checkout_url` and they pay on the page we host. To finish this guide, open the link and complete the payment there.
This account is in sandbox, so the payment is simulated, no funds move. See [Test payment outcomes](/integrate/sandbox#test-payment-outcomes).
The simulated outcome finalizes a second or two later. Read the checkout back until `status` is `completed`:
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions/chk_yMpCSunbArKaSnzJ \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
```json Response theme={"dark"}
{
"checkout_id": "chk_yMpCSunbArKaSnzJ",
"status": "completed",
"payment_status": "succeeded",
"amount": "100000.00",
"currency": "NGN"
}
```
In your integration you would not poll for this. Subscribe to [checkout.completed](/guides/webhooks/events/checkout-completed) instead and react when it arrives.
The seller's share moves as a transfer when the charge settles, never sooner. Filter by the seller's account id:
```bash Request theme={"dark"}
curl "https://sandbox-api.bachs.io/v1/transfers?connected_account_id=acct_Shi4LnKkKht5bmbS" \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
```json Response theme={"dark"}
{
"items": [
{
"id": "tr_b13a83695e65423fa8854fab",
"source": "org_bd31b6b3037d404ebe116ec69d955ee3",
"destination": "acct_Shi4LnKkKht5bmbS",
"amount": "100000.000000000000000000",
"currency": "NGN",
"status": "paid",
"kind": "payout",
"source_charge_id": "ch_ff10c7a1b6b4427caf347d748fd4a183",
"created_at": "2026-08-12T19:41:37.021329+00:00"
}
],
"pagination": {
"next_cursor": null,
"prev_cursor": null,
"has_more": false,
"limit": 50,
"offset": 0,
"returned": 1,
"total": 1
}
}
```
`status` is `paid` once settlement has posted the movement. `source_charge_id` ties the share back to the sale that produced it, which is what you show a seller asking where an amount came from.
The transfer carries the full `100000.00` the customer paid, not the `80000.00` the seller ends up with: because you named your own cut with `platform_fee`, that cut settles separately, readable at [Platform fees](/connect/platform-fees). Naming `transfer_data.amount` instead would fix the seller's share directly, and the transfer would carry only that. See [Destination charges](/connect/split-payments/destination) for both forms.
The share is the seller's own money now, in its own balance. Send `X-Account-Id` to read an account's balance instead of yours:
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/balances \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "X-Account-Id: acct_Shi4LnKkKht5bmbS"
```
```json Response theme={"dark"}
{
"account_id": "acct_Shi4LnKkKht5bmbS",
"balances": [
{ "currency": "NGN", "available_balance": "80000.00", "pending_balance": "0.00" },
{ "currency": "USD", "available_balance": "0.00", "pending_balance": "0.00" }
]
}
```
Drop the header to read your own, which went up by `19850.00`. That is the whole sale accounted for:
| | NGN |
| - | - |
| Customer paid | 100000.00 |
| Seller's share | 80000.00 |
| Your fee, less our processing fee | 19850.00 |
| Our processing fee | 150.00 |
Your `20000.00` fee is what you keep, and our fee comes out of it because the sale is yours. See [Processing fees](/connect/processing-fees).
The seller held no NGN until this sale. An account holds a currency once money arrives in it.
## What you built
A sale that belongs to your marketplace, split with the seller who fulfilled it, and a transfer you can point at to explain where the money went.
The seller can withdraw its balance once it has given you a payout destination, which is one of the requirements it still owes. Read what is outstanding from `requirements.currently_due` on the account, and see [Onboard through the API](/connect/guides/api-onboarding) for collecting it, or [Onboarding](/connect/onboarding) to hand the whole flow to us.
Because the sale is yours, a refund or a lost dispute debits **you**, not the seller. See [Refunds and disputes](/connect/marketplaces/refunds-and-disputes) before you go live.
## Webhooks to handle
Create your endpoint with `event_source` set to `connect` or `all`, or it receives nothing about your sellers' accounts. On an event from a seller's account, the top-level `account` field names the seller.
| Event | What it means | What your platform does |
| - | - | - |
| [`account.updated`](/guides/webhooks/events/account-updated) | A seller's onboarding state changed. | Read `requirements.currently_due` and ask the seller for what is missing. |
| [`capability.updated`](/guides/webhooks/events/capability-updated) | A capability on a seller's account changed status. | Let the seller sell or withdraw once the capabilities it needs are `active`. |
| [`checkout.completed`](/guides/webhooks/events/checkout-completed) | The customer paid. | Read the checkout back, then mark the order paid. |
| [`transfer.created`](/guides/webhooks/events/transfer-created) | The seller's share moved to its balance at settlement. | Record the seller's earnings. |
| [`payout.paid`](/guides/webhooks/events/payout-paid), [`payout.failed`](/guides/webhooks/events/payout-failed) | A seller's withdrawal arrived or failed. | Show the seller its payout status. |
See [Monitor onboarding](/connect/guides/monitor-onboarding) for reacting to onboarding without polling.
## Edge cases
A seller can withdraw only once it has given you a payout destination and its `payouts` capability is `active`. In production, capabilities start `restricted` until a reviewer enables them, so wait for `capability.updated` rather than assuming. See [Requirements](/connect/requirements).
The sale is yours, so a refund debits your balance, not the seller's. Decide how you recover the seller's share before you go live. See [Refunds and disputes](/connect/marketplaces/refunds-and-disputes).
You are liable, as the owner of the charge. The disputed amount is set aside from your available balance while the dispute is open, and the dispute fee is charged when it opens. See [Disputes on Connect](/connect/disputes).
The seller's share moves when the charge settles, not when the customer pays. Show the seller its earnings from `transfer.created`, and don't send a second, manual transfer for the same sale.
You receive `payout.failed`, and the amount returns to the seller's balance. Ask the seller to check its destination. A destination is used only once it is usable. See [Payouts on Connect](/connect/payouts).
## Go-live checklist
* [ ] Your platform has completed registered-business compliance and `connect` is `active` in production. See [Take Connect live](/connect/go-live).
* [ ] Your production keys have only the scopes you use, such as `connected_accounts:write` and `payouts:write`.
* [ ] Your production webhook endpoint has `event_source` set to `connect` or `all`, and its signing secret is checked against a live delivery.
* [ ] Your integration waits for capabilities to become `active` in production instead of assuming them.
* [ ] Every transfer and withdrawal sends an `Idempotency-Key`, and a `5xx` is checked before it is retried.
* [ ] Your team knows that refunds and lost disputes debit your balance.
## Build it with an AI assistant
Give this prompt to a coding assistant that can work with your app's code. It builds the flow on this page into your own app. For prompts that need nothing from Connect, and the rules to give your assistant, see [Build with AI](/build/ai/prompts).
**What you'll build:** your platform collects a payment, keeps its fee, and the seller's share reaches the seller's Bachs balance at settlement. The seller can then pay out to its own approved destination.
**Have ready:** the [Connect capability](/connect/become-a-platform), seller records, your fee, and the collection currency. Start with **one seller per order**. If each business should own its sale, use [Platform for businesses](/build/use-cases/saas-platform).
```text Prompt theme={"dark"}
Add a single-seller marketplace payment flow with Bachs Connect. Inspect our
onboarding, orders, authentication, database, and payment code. Confirm that
our platform owns the sale and takes a fee. Ask for missing currency, fee,
or seller-onboarding decisions.
Read the SDK's current scope at https://github.com/bachsdev/bachs-node.
Connect account context, seller onboarding and checkout splits are not supported
by the current SDK. Use documented API calls for these operations; never invent
SDK methods or force unsupported fields through type casts. A Node.js server can
use the SDK's webhooks.constructEvent for signature verification.
Read:
https://docs.bachs.io/build/use-cases/marketplace.md
https://docs.bachs.io/connect/onboarding.md
https://docs.bachs.io/connect/capabilities.md
https://docs.bachs.io/connect/marketplaces/refunds-and-disputes.md
https://docs.bachs.io/connect/payouts.md
https://docs.bachs.io/guides/webhooks/overview.md
https://docs.bachs.io/guides/idempotency.md
Use https://sandbox-api.bachs.io with BACHS_API_KEY on the server. Omit checkout return URLs for local testing; configured redirects must be public. Explain
Connect setup and the capabilities and requirements each seller needs.
Create and save a recipient sub account for each seller, and implement the
documented onboarding path. Choose the seller, product, and platform fee on the
server from our order. Create the platform checkout with
transfer_data.destination and platform_fee, linked to our order.
Verify X-Bachs-Signature-V2 on the raw body, with timestamp tolerance and all
v1 signatures. Confirm payment from verified events matched to the order,
amount, and currency. Deduplicate durably, keep state updates recoverable,
and return 5xx on processing failure.
Track payment, settlement into the seller's balance, and payout delivery
separately. The destination charge creates its transfer at settlement:
do not send a second manual transfer. Use documented account context for
the seller's balance and destination. Gate payouts on active capabilities
and a usable destination owned by that seller. A payout create response
does not confirm delivery.
Keep secrets server-side and amounts as decimal strings. Persist operation
idempotency keys and reconcile uncertain writes before retrying. Explain who
bears refunds and disputes. Do not describe this flow as escrow.
Test a paid sale and split, duplicate events, incomplete onboarding, a
destination awaiting approval, and a failed payout. Report which tests used
mocks and which ran in sandbox, remaining setup, and how to reconcile the
payment with the seller's balance and our fee.
```
### Check the result
* The correct seller account ID is saved against each seller.
* The payment is accounted for across the seller's share, platform fee, and processing fee.
* Settlement creates one seller transfer; the app does not send another manually.
* Blocked capabilities or an unusable destination prevent a payout.
* The app tracks payout status through delivery or failure.
* Your team understands the platform's responsibility for refunds and disputes.
Before going live, read [Connect payouts](/connect/payouts) for withdrawals and [refunds and disputes](/connect/marketplaces/refunds-and-disputes).
## Next steps
* [Build a SaaS platform](/build/use-cases/saas-platform), where the account sells instead of you
* [Payouts](/connect/payouts)
* [Destination charges](/connect/split-payments/destination)
* [Platform fees](/connect/platform-fees)
* [Take Connect live](/connect/go-live)
# Build a SaaS platform
Source: https://docs.bachs.io/build/use-cases/saas-platform
Run a full SaaS sale in the sandbox, where your account is the one selling and your cut comes back to you.
Each business on your platform transacts with its own customers, who often do not know you exist. So the account is the merchant of record: the charge is its sale, the money lands in its balance, and your cut comes back to you. In this guide you'll create an account that sells, take a payment as that account, and read your fee back. By the end you'll have run a full direct charge without moving real money.
## How it fits together
```mermaid theme={"dark"}
sequenceDiagram
participant A as Business (account)
participant P as Your platform
participant C as Customer
participant B as Bachs
P->>B: Create the business's account
B->>P: Webhooks: account.updated, capability.updated
C->>A: Buys from the business on your platform
P->>B: Create a checkout as the account (X-Account-Id, platform_fee)
C->>B: Pays on the hosted checkout
B->>P: Webhook: checkout.completed (account field set)
Note over B: The charge settles in the account's balance
P->>B: Read your fee from /v1/platform_fees
A->>B: Withdraws to its own destination
```
The sale belongs to the business, so the money lands in its balance. Your fee comes out of its proceeds and settles to you as its own record.
## What you'll use
| Object | Its job in this build | Reference |
| - | - | - |
| Account | The business. It takes payments, holds the money and withdraws it. | [Accounts](/connect/accounts) |
| `X-Account-Id` | Makes a request act as the account, so the checkout is the business's sale. | [Acting as an account](/connect/acting-as-an-account) |
| Checkout session | The business's checkout. `platform_fee` is your cut. | [Direct charges](/connect/split-payments/direct) |
| Platform fee | Your cut, as its own record once the charge settles. | [Platform fees](/connect/platform-fees) |
| Webhook endpoint | Tells you about your accounts' payments and onboarding. Set `event_source` to `connect` or `all`. | [Connect events](/guides/webhooks/overview#connect-events) |
## Before you start
* A **sandbox API key** (`sk_sandbox_...`). See [Authentication](/authentication).
* The `connect` capability `active` on your account. See [Become a platform](/connect/become-a-platform).
Every request below goes to `https://sandbox-api.bachs.io`. Going live is the same calls against `https://api.bachs.io` with an `sk_live_` key. See [Take Connect live](/connect/go-live).
## Steps
This account takes its own payments and gets paid out, so it needs both personas. Name `merchant` and `recipient` as keys in `configuration`, and name each capability it needs under the persona it belongs to.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/accounts \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"contact_email": "studio@example.com",
"display_name": "Bright Studio",
"country": "NG",
"entity_type": "individual",
"configuration": {
"merchant": {
"capabilities": {
"card_collection": { "requested": true },
"bank_transfer": { "requested": true },
"mobile_money": { "requested": true }
}
},
"recipient": {
"capabilities": {
"payouts": { "requested": true },
"transfers": { "requested": true }
}
}
}
}'
```
```json Response theme={"dark"}
{
"id": "acct_neoYPwWLKjICOBnZ",
"name": "Bright Studio",
"country": "NG",
"entity_type": "individual",
"capabilities": {
"card_collection": { "status": "active", "requested": true },
"bank_transfer": { "status": "active", "requested": true },
"mobile_money": { "status": "active", "requested": true },
"payouts": { "status": "active", "requested": true },
"transfers": { "status": "active", "requested": true }
},
"configuration": { "merchant": {}, "recipient": {} },
"balance_currencies": [],
"is_active": true
}
```
Copy the `id`. Every step below uses it as the account id.
The response lists exactly the five capabilities named above, one status per capability. In sandbox they are granted immediately; in live they start `pending_review` until a reviewer enables them. See [Capabilities](/connect/capabilities) and [Testing Connect](/connect/testing).
Holding a currency decides what the account settles in. A new account holds only USD. This walkthrough sells a subscription in NGN, so give the account NGN: its takings then stay in NGN instead of converting to USD on the way to the balance. Today holding the price currency is also required to create a recurring checkout at all (an unheld one is refused with `BASE_CURRENCY_NOT_HELD_BY_ORG` until renewals settle to USD like one-time charges do), so an account selling an NGN plan has to hold NGN first either way. Not every currency Bachs collects in can be held as a balance, so check [Balance currencies](/for-you/supported-currencies#balance-currencies) before you build against one.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/accounts/acct_neoYPwWLKjICOBnZ \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{ "balance_currencies": { "NGN": true } }'
```
```json Response theme={"dark"}
{
"id": "acct_neoYPwWLKjICOBnZ",
"name": "Bright Studio",
"balance_currencies": ["NGN"]
}
```
USD is always held, cannot be turned off, and does not appear in the list. See [Accounts](/connect/accounts#currencies-the-account-holds) for the field, and [Balance currencies](/for-you/supported-currencies#balance-currencies) for what you can ask for.
Skip this step and the checkout below is refused with `BASE_CURRENCY_NOT_HELD_BY_ORG`. It is the account's own currencies that decide this, not yours.
The account is selling, so the product is the account's. `X-Account-Id` is what makes a call act as the account rather than as you.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/products \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "X-Account-Id: acct_neoYPwWLKjICOBnZ" \
-H "Content-Type: application/json" \
-d '{
"name": "Studio session",
"description": "A one-hour booking.",
"price": { "currency": "NGN", "amount": "50000.00" }
}'
```
```json Response theme={"dark"}
{
"id": "prod_be4d87e4f01a4570a98a",
"organization_id": "acct_neoYPwWLKjICOBnZ",
"name": "Studio session",
"price": { "currency": "NGN", "price_type": "fixed", "amount": "50000.00" },
"status": "active"
}
```
Copy the product `id`. Note `organization_id`: the product belongs to the account.
`X-Account-Id`, on its own, is what makes this a direct charge: the account becomes the merchant of record and the sale lands in its balance. `platform_fee` is your cut, taken out of its proceeds.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "X-Account-Id: acct_neoYPwWLKjICOBnZ" \
-H "Content-Type: application/json" \
-d '{
"product_cart": [
{ "product_id": "prod_be4d87e4f01a4570a98a", "quantity": 1 }
],
"platform_fee": "10000.00",
"customer": { "email": "buyer@example.com" },
"success_url": "https://example.com/thanks",
"cancel_url": "https://example.com/cancel"
}'
```
```json Response theme={"dark"}
{
"checkout_id": "chk_B1Pa8HgYyRNvyvht",
"status": "open",
"amount": "50000.00",
"currency": "NGN",
"platform_fee": "10000.00",
"checkout_url": "https://sandbox-checkout.bachs.io/c/oudFvcBOWo5vBPM"
}
```
Copy the `checkout_id` and `checkout_url`.
This checkout is the **account's**, not yours. Every later read of it needs `X-Account-Id` too. Without the header the answer is `404 Checkout not found`, which reads as though the checkout was never created.
In your integration the account's customer pays on the page we host at `checkout_url`. To finish this guide, open the link and complete the payment there.
This account is in sandbox, so the payment is simulated, no funds move. See [Test payment outcomes](/integrate/sandbox#test-payment-outcomes).
Read the checkout back until `status` is `completed`. Note the `X-Account-Id`: this is the account's checkout.
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions/chk_B1Pa8HgYyRNvyvht \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "X-Account-Id: acct_neoYPwWLKjICOBnZ"
```
```json Response theme={"dark"}
{
"checkout_id": "chk_B1Pa8HgYyRNvyvht",
"status": "completed",
"payment_status": "succeeded",
"amount": "50000.00",
"currency": "NGN",
"platform_fee": "10000.00"
}
```
In your integration you would not poll for this. Subscribe to [checkout.completed](/guides/webhooks/events/checkout-completed) instead. A direct charge's event originates with the account, so your webhook endpoint needs `event_source` set to `connect` or `all` to receive it. See [Connect events](/guides/webhooks/overview#connect-events).
Your fee is not a transfer: it settles as its own record once the charge settles, never sooner. Read it from `GET /v1/platform_fees`:
```bash Request theme={"dark"}
curl "https://sandbox-api.bachs.io/v1/platform_fees?charge=ch_536484a789f84d58840e85ce1e8b1a84" \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
```json Response theme={"dark"}
{
"items": [
{
"id": "pf_dee4d8fea7834083a086",
"charge": "ch_536484a789f84d58840e85ce1e8b1a84",
"collected_from": "acct_neoYPwWLKjICOBnZ",
"earned_by": "org_bd31b6b3037d404ebe116ec69d955ee3",
"amount": "10000.00",
"currency": "NGN",
"amount_refunded": "0.00",
"refunded": false,
"created_at": "2026-08-12T20:13:31.238555+00:00"
}
],
"pagination": {
"next_cursor": null,
"prev_cursor": null,
"has_more": false,
"limit": 50,
"offset": 0,
"returned": 1,
"total": 1
}
}
```
`collected_from` is the account and `earned_by` is you, because the sale was theirs and the fee is yours. `charge` ties it back to the sale.
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/balances \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "X-Account-Id: acct_neoYPwWLKjICOBnZ"
```
```json Response theme={"dark"}
{
"account_id": "acct_neoYPwWLKjICOBnZ",
"balances": [
{ "currency": "NGN", "available_balance": "39850.00", "pending_balance": "0.00" },
{ "currency": "USD", "available_balance": "0.00", "pending_balance": "0.00" }
]
}
```
The whole sale, accounted for:
| | NGN |
| - | - |
| Customer paid | 50000.00 |
| The account keeps | 39850.00 |
| Your platform fee | 10000.00 |
| Our processing fee | 150.00 |
The account bears our processing fee because it is the merchant of record. That is the default, and [Processing fees](/connect/processing-fees) covers moving it. Your `10000.00` arrives whole.
## How this differs from a marketplace
The same two calls, with the parties swapped.
| | This page | [Marketplace](/build/use-cases/marketplace) |
| - | - | - |
| Merchant of record | The account | You |
| What makes it so | `X-Account-Id` | `transfer_data.destination` |
| Where the charge lands | The account's balance | Yours |
| Which way the fee moves | Account to you, its own record at `/v1/platform_fees` | You to seller, a transfer with `kind: payout` |
| Who bears a refund or lost dispute | The account | You |
| Who pays our processing fee, by default | The account | You |
If you are still choosing, see [Choose your integration](/connect/choose-your-integration).
## What happens next
The account can withdraw its balance once it has given you a payout destination, which is one of the requirements it still owes. Read what is outstanding from `requirements.currently_due` on the account.
A refund or a lost dispute debits the **account** here, not you. That is the point of this shape, and the reason its onboarding is longer than a marketplace seller's. See [Refunds](/connect/refunds) and [Disputes](/connect/disputes).
## Webhooks to handle
Create your endpoint with `event_source` set to `connect` or `all`, or it receives nothing from your accounts. An event from an account carries a top-level `account` field naming it.
| Event | What it means | What your platform does |
| - | - | - |
| [`account.updated`](/guides/webhooks/events/account-updated) | An account's onboarding state changed. | Read `requirements.currently_due` and ask the business for what is missing. |
| [`capability.updated`](/guides/webhooks/events/capability-updated) | A capability on an account changed status. | Let the business take payments once its payment capabilities are `active`, and withdraw once `payouts` is. |
| [`checkout.completed`](/guides/webhooks/events/checkout-completed) | A customer paid the business. | Read the checkout back as the account, then update the business's order. |
| [`payout.paid`](/guides/webhooks/events/payout-paid), [`payout.failed`](/guides/webhooks/events/payout-failed) | The business's withdrawal arrived or failed. | Show the business its payout status. |
Your platform fee has no event of its own. Read it from [`/v1/platform_fees`](/connect/platform-fees) after the charge settles.
## Edge cases
A checkout as the account needs its payment capabilities `active`. In production, capabilities start `restricted` until a reviewer enables them, and a business that accepts payments owes more requirements than a marketplace seller. Wait for `capability.updated`. See [Requirements](/connect/requirements).
The sale is the business's, so a refund debits the account's balance, not yours. See [Refunds on Connect](/connect/refunds).
The account is liable. If it loses and its balance cannot cover the dispute, its balance goes negative and recovers from its future sales. See [Disputes on Connect](/connect/disputes).
By default the account bears Bachs's processing fee, because the sale is its own, and your platform fee arrives whole. See [Processing fees](/connect/processing-fees) to change this.
A recurring checkout needs the account to hold the price currency, as in the second step above. See [Balance currencies](/for-you/supported-currencies#balance-currencies).
## Go-live checklist
* [ ] Your platform has completed registered-business compliance and `connect` is `active` in production. See [Take Connect live](/connect/go-live).
* [ ] Your production keys have only the scopes you use, such as `connected_accounts:write` and `payouts:write`.
* [ ] Your production webhook endpoint has `event_source` set to `connect` or `all`, and its signing secret is checked against a live delivery.
* [ ] Businesses can sell only after their payment capabilities are `active` in production.
* [ ] Every request that acts for a business sends its `X-Account-Id`, and every write sends an `Idempotency-Key`.
* [ ] Businesses know that refunds and lost disputes debit their own balance.
## Build it with an AI assistant
The [Bachs skill](/build/ai/skills) covers Connect. Ask your assistant to build "a platform where each business sells as its own Bachs account, with a platform fee", and point it at this page: `https://docs.bachs.io/build/use-cases/saas-platform.md`.
## Next steps
* [Direct charges](/connect/split-payments/direct)
* [Platform fees](/connect/platform-fees)
* [Acting as an account](/connect/acting-as-an-account)
* [Take Connect live](/connect/go-live)
# Build a SaaS with subscriptions
Source: https://docs.bachs.io/build/use-cases/saas-subscriptions
Sell monthly and yearly plans, give access from webhooks, and let customers manage billing themselves. The full flow, end to end, in the sandbox.
You run a software product and want customers to pay for it every month or every year. In this guide you'll create your plans, send a signed-in user to checkout, give them access when Bachs tells you the subscription is active, and let them change or cancel their plan in the customer portal. By the end you'll have the complete subscription loop running in the sandbox, with three routes on your server and no billing logic of your own.
Subscriptions bill **USD cards** today. Free trials are in beta. See [Subscriptions](/guides/subscriptions/overview) and [Trials](/guides/subscriptions/trials).
For a working Node.js implementation, use the [Next.js SaaS starter](https://github.com/bachsdev/bachs-nextjs-saas). It uses the official Bachs SDK for checkout, products, customer portal sessions, and webhook verification. The HTTP examples below explain the same flow for any server language.
## How it fits together
```mermaid theme={"dark"}
sequenceDiagram
participant C as Customer
participant A as Your app
participant B as Bachs
C->>A: Clicks "Subscribe"
A->>B: Create a checkout session
B-->>A: checkout_url
A-->>C: Redirect to checkout_url
C->>B: Pays on the hosted checkout
B-->>C: Redirect to your success_url
B->>A: Webhook: customer.subscription.created
A->>A: Save the subscription, give access
Note over A,B: Each renewal: invoice.paid or invoice.payment_failed
C->>A: Clicks "Manage billing"
A->>B: Create a portal session
A-->>C: Redirect to the portal
```
Your app owns two things: who the user is, and whether they have access. Bachs owns everything about money: the card, the renewals, the retries, the receipts and the cancellation flow. The webhook is where the two meet.
## What you'll use
| Object | Its job in this build | Reference |
| - | - | - |
| Product | One plan at one cadence, for example "Pro, monthly". | [The product object](/api-reference/products/object) |
| Checkout session | The hosted page where the customer pays and the card is saved. | [The checkout session object](/api-reference/checkout-sessions/object) |
| Customer | The billing identity Bachs charges on every renewal. | [The customer object](/api-reference/customers/object) |
| Subscription | The ongoing relationship. Its `status` decides access. | [The subscription object](/api-reference/subscriptions/object) |
| Portal session | A signed-in link to the page where customers manage billing. | [Customer portal](/guides/customer-portal/overview) |
| Webhook endpoint | The route on your server that Bachs tells about every change. | [Set up webhooks](/guides/webhooks/overview) |
## Before you start
* A **sandbox API key** (`sk_sandbox_...`). See [Authentication](/authentication). If you restrict the key's [permissions](/api-reference/permissions), this build needs `products:write`, `payments:write` and `customers:write`.
* The **Bachs CLI**, to forward webhooks to your machine. See [Install the CLI](/cli/overview#install).
* An app with signed-in users. The examples use Next.js route handlers, but any server framework works the same way.
Every request below goes to `https://sandbox-api.bachs.io`, so you can run the whole flow without moving real money. Keep these values in your server's environment, never in the browser:
```bash .env theme={"dark"}
BACHS_API_URL=https://sandbox-api.bachs.io
BACHS_API_KEY=sk_sandbox_...
BACHS_WEBHOOK_SECRET=whsec_...
BACHS_PRO_MONTHLY=prod_...
BACHS_PRO_YEARLY=prod_...
APP_URL=http://localhost:3000
# Optional: public checkout return URLs. Leave empty for local development.
CHECKOUT_SUCCESS_URL=
CHECKOUT_CANCEL_URL=
```
## Steps
Create one product for each plan and cadence. A product with a `billing_cycle` is recurring, and its cadence cannot change after you create it, so a monthly and a yearly plan are two products.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/products \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Pro (monthly)",
"price": { "currency": "USD", "amount": "29.00" },
"billing_cycle": { "interval": "month", "frequency": 1 },
"metadata": { "plan": "pro" }
}'
```
```json Response theme={"dark"}
{
"id": "prod_8f3a2c9d1e4b7a6c5d0f",
"name": "Pro (monthly)",
"price": {
"currency": "USD",
"price_type": "fixed",
"amount": "29.00",
"preset_amount": null,
"minimum_amount": null,
"maximum_amount": null,
"currency_options": []
},
"billing_cycle": { "interval": "month", "frequency": 1 },
"trial_period": null,
"status": "active",
"metadata": { "plan": "pro" },
"created_at": "2026-10-09T09:00:00.000Z",
"updated_at": "2026-10-09T09:00:00.000Z",
"archived_at": null
}
```
Repeat with `"name": "Pro (yearly)"`, `"amount": "290.00"` and `"interval": "year"`. Put both IDs in your environment as `BACHS_PRO_MONTHLY` and `BACHS_PRO_YEARLY`. You can also create products in the dashboard; the IDs work the same way.
To offer a free trial, add `"trial_period": { "interval": "day", "frequency": 14 }` to the product. The card is saved at checkout and the first charge happens when the trial ends. A trial checkout sends `customer.subscription.created` with status `trialing`, and the first `invoice.paid` arrives when the trial ends. See [Offer a free trial](/guides/subscriptions/trials).
When a signed-in user clicks **Subscribe**, your server creates a checkout session and returns its `checkout_url`. The browser sends only the plan name. Your server decides which product that means, so nobody can change the price from the browser.
A subscription checkout needs a `customer`, because Bachs must know who to charge on every renewal. Send the user's email the first time, and their saved `customer_id` after that. Put your own user ID in `metadata`: Bachs copies a subscription checkout's metadata onto the subscription, so every subscription webhook tells you which of your users it belongs to.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"product_cart": [{ "product_id": "prod_8f3a2c9d1e4b7a6c5d0f", "quantity": 1 }],
"customer": { "email": "jane@example.com", "name": "Jane Doe" },
"billing_currency": "USD",
"metadata": { "user_id": "8812" },
"success_url": "https://app.example.com/billing/success",
"cancel_url": "https://app.example.com/pricing"
}'
```
```json Response theme={"dark"}
{
"checkout_id": "chk_2N3o4P5q6R7s8T9u",
"checkout_url": "https://checkout.bachs.io/c/V8xQ2mZpLj9RfTa",
"status": "open",
"expires_at": "2026-10-09T10:00:00Z",
"created_at": "2026-10-09T09:00:00Z"
}
```
The same call as a route in your app:
```js app/api/checkout/route.js theme={"dark"}
const PLANS = {
monthly: process.env.BACHS_PRO_MONTHLY,
yearly: process.env.BACHS_PRO_YEARLY,
};
export async function POST(req) {
const user = await getCurrentUser(req); // your own auth
const { plan } = await req.json();
const productId = PLANS[plan];
if (!productId) return Response.json({ error: "Unknown plan" }, { status: 400 });
const res = await fetch(`${process.env.BACHS_API_URL}/v1/checkout-sessions`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BACHS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
product_cart: [{ product_id: productId, quantity: 1 }],
customer: user.bachsCustomerId
? { customer_id: user.bachsCustomerId }
: { email: user.email, name: user.name },
billing_currency: "USD",
metadata: { user_id: String(user.id) },
success_url: process.env.CHECKOUT_SUCCESS_URL || undefined,
cancel_url: process.env.CHECKOUT_CANCEL_URL || undefined,
}),
});
if (!res.ok) return Response.json({ error: "Could not start checkout" }, { status: 502 });
const { checkout_url } = await res.json();
return Response.json({ checkout_url });
}
```
Checkout redirects must be public URLs; Bachs refuses localhost and private IPs, including in sandbox. For a fully local app, leave `CHECKOUT_SUCCESS_URL` and `CHECKOUT_CANCEL_URL` empty and return to your app manually after paying. To redirect back automatically, set them to a public deployment or tunnel. The CLI forwards webhooks independently of these return URLs.
In the browser, send the user to `checkout_url`. To keep them on your page instead, open the same URL in the [overlay checkout](/guides/checkout/overlay-checkout) with `Bachs.Checkout.open({ checkoutUrl })`.
Start the CLI and forward the events this build uses to your local webhook route:
```bash Terminal theme={"dark"}
bachs listen --forward-to localhost:3000/api/webhooks/bachs \
--events customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,invoice.paid,invoice.payment_failed
```
The CLI prints a signing secret (`whsec_...`) for this session. Put it in `BACHS_WEBHOOK_SECRET`. Your verification code is the same code you run in production; only the secret is different. See [Test webhooks locally](/developer-portal/local-testing).
Your webhook route does three things, in this order: verify the signature against the raw body, skip events you have already processed, then save the subscription.
```js app/api/webhooks/bachs/route.js theme={"dark"}
import crypto from "node:crypto";
// X-Bachs-Signature-V2 looks like "t=1760000000,v1=abc...,v1=def..."
function verify(header, rawBody, secret, toleranceSeconds = 300) {
if (!header) return false;
const parts = header.split(",").map((part) => part.split("="));
const timestamp = Number(parts.find(([key]) => key === "t")?.[1]);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// During a secret rotation there is one v1 value per valid secret.
return parts
.filter(([key]) => key === "v1")
.some(([, sig]) =>
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
);
}
export async function POST(req) {
const rawBody = await req.text(); // read before parsing JSON
const signature = req.headers.get("x-bachs-signature-v2");
if (!verify(signature, rawBody, process.env.BACHS_WEBHOOK_SECRET)) {
return new Response("Invalid signature", { status: 400 });
}
const event = JSON.parse(rawBody);
if (await alreadyProcessed(event.id)) return new Response("OK"); // your own store
switch (event.type) {
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const sub = event.data;
// Deliveries can arrive out of order: skip an event older than the one you saved.
const saved = await getSubscription(sub.metadata.user_id); // your own store
if (saved && Date.parse(saved.updatedAt) > Date.parse(event.created_at)) break;
await saveSubscription({
// your own store
userId: sub.metadata.user_id,
subscriptionId: sub.subscription_id,
customerId: sub.customer.customer_id,
productId: sub.product_id,
status: sub.status,
currentPeriodEnd: sub.current_period_end,
cancelAtPeriodEnd: sub.cancel_at_period_end,
updatedAt: event.created_at,
});
break;
}
case "invoice.payment_failed":
await showUpdateCardBanner(event.data.customer.customer_id); // optional
break;
}
await markProcessed(event.id);
return new Response("OK");
}
```
Then decide access from the saved status, everywhere in your app:
```js lib/billing.js theme={"dark"}
export function hasAccess(subscription) {
if (!subscription) return false;
// past_due: a renewal failed and Bachs is retrying. Keeping access
// during recovery is common; return false here if you prefer.
return ["trialing", "active", "past_due"].includes(subscription.status);
}
```
Each subscription event carries the whole subscription, so save its full state instead of applying changes one by one. Events are not guaranteed to arrive in order, so keep each event's `created_at` with the record and skip an event that is older than the one you saved. If you ever need to be certain of the current state, read it with [Retrieve a subscription](/api-reference/subscriptions/get-subscription).
If saving fails, let the route return a `5xx` so Bachs retries the delivery. A `4xx` response other than `408` or `429` is not retried, so use `400` only for a signature that does not match.
Never give access from the `success_url` redirect. The customer can close the tab before it loads, and anyone can open the URL. Your success page should read the user's subscription from your own database and show "Activating your plan..." until the webhook has arrived.
Add a **Manage billing** button that calls a route on your server. The route creates a [portal session](/guides/customer-portal/create-portal-session) for the user's `customer_id` and redirects to its `url`. In the portal, customers see their subscriptions and invoices and can cancel. A customer whose renewal failed can always update their card there. Updating a card at any other time, and switching plans, are off until you turn them on in your portal settings. Create a new session on every click, because sessions are short-lived.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/customers/cust_1a2b3c4d5e6f/portal-sessions \
-H "Authorization: Bearer $BACHS_API_KEY"
```
```json Response theme={"dark"}
{
"id": "psn_9f2c4a7b1d3e",
"url": "https://portal.bachs.io/s/6Yc0nQpR2vX1sK7fLbA9tE"
}
```
```js app/api/billing/portal/route.js theme={"dark"}
export async function POST(req) {
const user = await getCurrentUser(req); // your own auth
const res = await fetch(
`${process.env.BACHS_API_URL}/v1/customers/${user.bachsCustomerId}/portal-sessions`,
{ method: "POST", headers: { Authorization: `Bearer ${process.env.BACHS_API_KEY}` } },
);
if (!res.ok) return Response.json({ error: "Could not open billing" }, { status: 502 });
const { url } = await res.json();
return Response.redirect(url, 303);
}
```
Whatever the customer changes in the portal reaches you as the same webhooks as before, so the route from the previous step already handles it.
With your app and `bachs listen` running:
1. Sign in to your app, click **Subscribe**, and pay with the test card shown on the sandbox checkout page.
2. Watch the CLI: `customer.subscription.created`, `invoice.paid` and `customer.subscription.updated` arrive, and your route answers `200`.
3. Return to your app (manually if running locally without checkout redirects). Check that the user now has access and that their `customerId` is saved.
4. Click **Manage billing** and cancel the plan in the portal. By default the portal cancels at the end of the period: you receive `customer.subscription.updated` with `cancel_at_period_end: true`, and the user keeps access until `current_period_end`.
`bachs trigger` does not emit subscription events yet. To test this flow, complete a real sandbox checkout as above.
## Webhooks to handle
| Event | What it means | What your app does |
| - | - | - |
| [`customer.subscription.created`](/guides/webhooks/events/customer-subscription-created) | A customer completed a subscription checkout. | Save the subscription and the `customer_id`. Give access. |
| [`customer.subscription.updated`](/guides/webhooks/events/customer-subscription-updated) | Plan change, trial change, scheduled cancellation, card change or status change. | Save the new state. Access follows `status`. |
| [`customer.subscription.deleted`](/guides/webhooks/events/customer-subscription-deleted) | The subscription is `canceled` and will not renew. | Save the state. Remove access. |
| [`invoice.paid`](/guides/webhooks/events/invoice-paid) | A cycle was paid, including a successful retry. | Optional: record the payment or send your own receipt. |
| [`invoice.payment_failed`](/guides/webhooks/events/invoice-payment-failed) | A renewal charge failed. Bachs retries and emails the customer. | Optional: ask the user in your app to update their card. |
A subscription checkout also emits `collection.succeeded` and `checkout.completed`. You don't need them for this build.
## Edge cases
The webhook still arrives. That is why access comes from the webhook and your success page reads from your own database.
Delivery is at least once. Store each event's `id` and skip IDs you have already processed. Saving the full subscription state also makes a repeated event harmless.
Bachs retries a failed delivery several times over about 80 minutes, then stops. If your endpoint was down for longer, read the current state of your subscriptions with [List subscriptions](/api-reference/subscriptions/list-subscriptions), or redeliver past events with [`bachs events replay`](/cli/commands#events).
The subscription moves to `past_due` and Bachs retries three times: 1 day after the failure, then 3 days later, then 5 days later. It emails the customer after each failed attempt, and from the second email on, the email includes a link to update their card. If a retry succeeds, the subscription is `active` again. If all retries fail, the subscription is canceled, or marked `unpaid` if you choose that in your subscription settings. See [Payment recovery](/guides/subscriptions/failed-payments).
A cancellation from the portal, or from [Cancel a subscription](/api-reference/subscriptions/cancel-subscription) with `cancel_at_period_end: true`, keeps the subscription working until `current_period_end`. You receive `customer.subscription.updated` now and `customer.subscription.deleted` when it ends. Send `cancel_at_period_end: true` explicitly when you cancel through the API: without it, the cancellation is immediate. An immediate cancellation sends `customer.subscription.deleted` at once and does not refund automatically. See [Manage subscriptions](/guides/subscriptions/manage#cancel-a-subscription).
Customers can switch plans in the portal once you turn on plan switching and list the products they may move to in your portal settings. You can also [change the plan](/guides/subscriptions/manage#change-the-plan) through the API. The price difference is handled for you. See [Proration](/guides/subscriptions/proration).
Existing subscribers keep the amount they signed up with. A new price applies to new subscriptions only. To move an existing subscriber, change their plan.
Check your own records before you create a checkout. If the user already has a subscription with access, send them to the portal instead.
## Go-live checklist
* [ ] Your account is verified. See [Go live](/go-live).
* [ ] You created your products again in production and updated the product IDs. Sandbox and production share nothing.
* [ ] Your server uses an `sk_live_` key and `https://api.bachs.io`.
* [ ] You registered your production webhook endpoint for the five events above and put its signing secret in your environment. See [Set up webhooks](/guides/webhooks/overview).
* [ ] You chose what happens when payment retries run out. See [When recovery is exhausted](/guides/subscriptions/failed-payments#when-recovery-is-exhausted).
* [ ] You configured what customers can change in the portal, including plan switching if you offer it. See [Configuring the portal](/guides/customer-portal/overview#configuring-the-portal).
## Start from working code
The [Next.js SaaS starter](/build/starters/overview#nextjs-saas) implements this flow with route, webhook, persistence, and access tests. It includes a local development store and demo authentication; replace both before deploying.
The [live demo](/demo) runs this flow end to end on the sandbox: a server route creates checkout sessions for a fixed set of products, the overlay opens them, and webhooks drive fulfilment. See [how it is built](/demo#how-it-is-built).
## Build it with an AI assistant
Use the [subscription prompt](/build/ai/prompts#sell-subscriptions). Its prompt asks your assistant to inspect your app, build the checkout and billing flow, and test activation, recovery, and cancellation. It works without installing a skill.
Keep this walkthrough available to your assistant for the requests and examples. If it cannot open the page, use **Copy page** and paste the content alongside the prompt.
## Next steps
* [Subscriptions](/guides/subscriptions/overview): statuses and how renewals work.
* [Offer a free trial](/guides/subscriptions/trials): add a trial before the first charge.
* [Manage subscriptions](/guides/subscriptions/manage): change plans, update metadata and cancel through the API.
* [Customer portal](/guides/customer-portal/overview): what customers can do and how to configure it.
* [Charge in any currency](/guides/checkout/any-currency-checkout): sell one-time products in your customers' currencies.
* [Build a platform for businesses](/build/use-cases/saas-platform): let your own customers take payments through you with Connect.
# API changelog
Source: https://docs.bachs.io/changelog/api
Changes to the Bachs API: breaking changes, additions, and fixes.
### Added
* **Global Payouts through the existing payout API.** Bank destinations support NGN, GHS, KES, TZS, UGX, GBP, USD, CAD and EUR, with mobile money in GHS, KES and TZS and supported stablecoin wallets. Global Payouts is available to all Bachs users; account verification, an active `payouts` capability, destination approval and sufficient available funds still apply. These withdrawals are to the account holder's own destinations.
Register destinations with `POST /v1/payouts/destinations` and check `is_usable` before requesting a payout. Set the destination `type` explicitly for bank or mobile money destinations, and choose a `scheme` for USD and CAD bank destinations. See [Create a payout destination](/api-reference/payouts/create-payout-destination).
International bank and mobile money routes debit the available USD balance. For a different destination currency, request a conversion quote and send its `quote_id` with the destination ID, without `amount`, when creating the payout. USD-to-USD payouts use `amount` without a conversion quote. A successful create response confirms acceptance, not delivery; track the payout until `completed` or `failed`.
For connected accounts, use `X-Account-Id` with that account's own balance and approved destination. Add scheme-based international bank destinations through the destination API rather than the inline onboarding `payout_destination` field. See [Global Payouts](/guides/payouts/global-payouts) and [International bank payouts](/guides/payouts/withdraw-to-bank-abroad).
* **`enabled` on `PATCH /v1/webhooks/endpoints/{endpoint_id}`.** Send `false` to stop all deliveries to an endpoint, or `true` to turn it back on. Before this, the only way to stop deliveries from the API was to delete the endpoint. Turning an endpoint on clears its failure history. Events from while it was off are not sent, so [resend the ones you need](/api-reference/webhooks/resend-an-event-to-an-endpoint).
```json theme={"dark"}
{ "enabled": true }
```
* **`disabled_at` and `disabled_reason` on the webhook endpoint object.** Returned everywhere an endpoint is. We now turn off an endpoint whose deliveries have all failed for 72 hours, at least 48 hours after we email a warning. When we do, `enabled` is `false`, `disabled_at` is the time, and `disabled_reason` is `consecutive_failures`. Both are `null` while the endpoint is on, and when you turned it off yourself. See [When an endpoint keeps failing](/guides/webhooks/overview#when-an-endpoint-keeps-failing).
### Changed
* **Dashboard users need permission to manage webhooks to change them.** This covers adding, editing, enabling, disabling and deleting endpoints, rotating and viewing signing secrets, and resending events. API keys are unchanged: `webhooks:write` for changes and `webhooks:read` for reads, including the signing secret.
### Added
* **Virtual accounts.** `POST /v1/virtual-accounts` issues a fixed NGN bank account number for your platform or a connected account, and `GET /v1/virtual-accounts` reads it back. Each deposit becomes a payment with a `collection.succeeded` webhook whose `checkout_id` is `null`. Creating twice returns the same number rather than a second one. NGN is the only currency today.
The account needs an active `virtual_accounts` capability. Request it through the account API and complete the returned requirements, including the representative's BVN. The two new permissions, `virtual_accounts:read` and `virtual_accounts:write`, are not on keys that already exist. Add them to your key before your first call, or it returns `403`. See [Virtual accounts](/guides/virtual-accounts/overview).
* **`payment_method` now reports the rail used for a virtual account deposit.** It reads `NGN_BANK_TRANSFER`, the same corridor name a checkout writes for that rail. We also corrected the card description in the reference: a card payment reports `USD_CARD` or `NGN_CARD`, not a bare `CARD`.
* **`payment_method_details` on the payment object and collection webhooks.** Returned by `GET /v1/payments/{payment_id}` and sent on `collection.succeeded`, but not on the payment list. For a bank transfer, it names the sender (`sender_name`, `sender_bank`, `sender_account_number`), the interbank `session_id`, and the account number that received the money. Use these details to distinguish deposits into the same virtual account. It is `null` on payments taken before this shipped and on methods that provide no payer details. See [the payment object](/api-reference/payments/object).
### Added
* **`customer` is now optional on checkout session creation.** Omit it and the hosted checkout page collects the buyer's email and name before they can pay. It stays required for a subscription checkout, which has no later opportunity to collect it. See [Guest checkout](/guides/checkout/checkout-sessions#guest-checkout).
* **`customer_creation` on checkout session creation.** Decides whether a buyer who identifies themselves on the hosted page also becomes a customer record. `if_required`, the default, keeps them out of your directory: `customer` stays `null` and no `customer.created` or `customer.updated` webhook fires, while the buyer's email and name reach you on `customer_details` and their purchases group together in your dashboard. `always` creates the record, matched by email to an existing customer where you already have that address, and returns it on `customer` as normal. It is ignored for a subscription or `setup` checkout, which always create a customer, because recurring billing needs a durable record to keep the renewal card on. See [Whether a guest becomes a customer](/guides/checkout/checkout-sessions#whether-a-guest-becomes-a-customer-customer-creation).
```json theme={"dark"}
{ "customer_creation": "if_required" }
```
* **`customer_details` on the checkout session object and the `checkout.completed` webhook.** `{ email, name }`, present whenever an identity was collected, whether or not a customer record exists for it. `customer` is unchanged, so nothing that reads it breaks. What gains an identity today is every checkout that used to send `customer: null` and nothing else, such as a payment link with a fixed price. See [`customer` vs `customer_details`](/api-reference/checkout-sessions/object#customer-vs-customer-details).
### Changed
* **Two new error codes say why a live checkout can offer no payment method.** `CHECKOUT_HAS_NO_PAYMENT_METHOD` covered three different problems with one message, and named the wrong one for the two most common: an account that is not live yet, and an account whose methods have been restricted. Those now have their own codes, `ACCOUNT_NOT_ACTIVATED` and `ACCOUNT_PAYMENT_METHODS_RESTRICTED`, both still `400`, and returned everywhere a checkout is created.
`CHECKOUT_HAS_NO_PAYMENT_METHOD` still fires, now only when the account has no payment method configured at all, and `CHECKOUT_RESTRICTION_LEAVES_NO_PAYMENT_METHOD` is unchanged. If you branch on the old code to detect a not-yet-live account, add the two new codes. Sandbox is unaffected: every method is available there. See [Why a checkout has no payment method](/guides/payments/payment-method-support#why-a-checkout-has-no-payment-method).
### Added
* **`payment_method_options` on checkout sessions and payment links.** Restricts which payment methods a checkout offers, and which currencies each one is offered in. Keys are `card`, `bank_transfer`, `mobile_money`, and `crypto`. A method you leave out is not offered; a method you include with no `currencies` is offered in all of them. For `crypto` the currencies are asset codes such as `USDT_TRC20` rather than fiat codes.
```json theme={"dark"}
{ "payment_method_options": { "card": { "currencies": ["USD"] }, "bank_transfer": {} } }
```
Before this, you could turn a whole payment method on or off but not reach inside it, so "cards, but only in USD" was not expressible: you either took every card currency or dropped cards entirely. Restricting only ever narrows what the customer sees. It cannot add a method or a currency your account is not already enabled for, and if a restriction leaves nothing payable the request is rejected rather than creating a checkout nobody can complete. The restriction is enforced when the payment is priced and confirmed, not only on the page, so a request that names an excluded method or currency is refused with `PAYMENT_METHOD_NOT_ALLOWED`. See [Restrict methods and currencies](/guides/checkout/checkout-sessions#restrict-methods-and-currencies).
### Added
* **Three more dispute evidence fields.** `access_activity_log` (a text log of the customer's access or activity), plus `cancellation_policy_attachment_id` and `refund_policy_attachment_id` for the policy documents themselves. Both policy attachments take a `document_id` from [Upload Dispute Document](/api-reference/disputes/upload-dispute-document) and pair with the existing `cancellation_policy_disclosure` and `refund_policy_disclosure` text: the text states the policy, the file shows the customer was given it. Accepted on `PATCH /v1/disputes/{dispute_id}/evidence` and returned on [Get Dispute](/api-reference/disputes/get-dispute).
* **`billing_address` on the customer object.** A nested object with `line1`, `line2`, `city`, `state`, `postal_code`, and `country` (two-letter code), or `null` when unset. Accepted on `POST /v1/customers` and `PATCH /v1/customers/{customer_id}`, and returned everywhere a customer is, including `customer.created` and `customer.updated` webhooks and the customer embedded in subscription and invoice payloads.
* On update, the address is **replaced, not merged**: omit `billing_address` to leave it untouched, send `null` to clear it, or send an object to replace every component (anything you leave out becomes `null`). See [the customer object](/api-reference/customers/object).
* Disputes now arrive with `customer_email_address`, `customer_name`, and `billing_address` prefilled from the customer record where we have them. They remain ordinary editable evidence.
### Added
* **`checkout.completed`** and **`checkout.expired`** webhook events. `checkout.completed` fires whenever a checkout session finishes, across `payment`, `setup`, and `subscription` modes, and carries a new `payment_status` field (`paid` or `no_payment_required`) so you can tell a real payment apart from a free or setup completion in one event. `checkout.expired` fires when an open checkout lapses past its expiry without the customer completing it. See [`checkout.completed`](/guides/webhooks/events/checkout-completed) and [`checkout.expired`](/guides/webhooks/events/checkout-expired).
* A free (`$0`) checkout now settles charge-lessly: no charge is created, and the only event you'll get is `checkout.completed` with `payment_status: "no_payment_required"`. Previously this path minted a shell charge; there's nothing to reconcile against it now.
### Removed
* **`collection.abandoned`** is gone. Use `checkout.expired` instead, it's the direct replacement and fires from the same expiry sweep.
### Breaking
**The payment resource moved and its fields were renamed.** The old `payins` paths are gone.
| Before | After |
| - | - |
| `GET /v1/payments/payins` | `GET /v1/payments` |
| `GET /v1/payments/payins/{charge_id}` | `GET /v1/payments/{payment_id}` |
On the payment object:
* `charge_id` is now `payment_id`.
* `products` is now `line_items`.
* `organization_id` was removed.
* The response gained `billing_reason`, `subscription_id`, and a nested `invoice` object.
This object is also embedded as `charge` on the checkout detail response (`GET /v1/checkout-sessions/{id}`), so `checkout.charge.charge_id` becomes `checkout.charge.payment_id` and `checkout.charge.products` becomes `checkout.charge.line_items`.
**Other resources moved out of the `/v1/payments` namespace** so the payment resource owns its root:
| Before | After |
| - | - |
| `GET /v1/payments/payouts`, `GET /v1/payments/payouts/{id}` | `GET /v1/payouts`, `GET /v1/payouts/{id}` |
| `GET /v1/payments/payment-methods` | `GET /v1/payment-methods` |
| `GET /v1/payments/rails` | `GET /v1/payment-methods/rails` |
| `GET /v1/payments/supported-currencies` | `GET /v1/currencies/supported` |
| `GET /v1/payments/payout-supported-currencies` | `GET /v1/currencies/payout-supported` |
| `POST /v1/payments/webhooks/replay` | `POST /v1/webhooks/replay` |
| `POST /v1/payments/refunds`, `GET /v1/payments/refunds/{id}` | `POST /v1/refunds`, `GET /v1/refunds/{id}` |
**Subscription line items** now report pricing as `price_type` (`fixed`, `free`, `custom`) instead of `amount_type`. This applies to the subscription response and the `customer.subscription.*` webhook payloads.
**The customer object is now `{ email, name }`.** `first_name` and `last_name` were removed everywhere: customer responses, the `customer.*` webhook payloads and every embedded customer object, and the `POST`/`PATCH /v1/customers` request bodies. Any code reading `customer.first_name` breaks; read `customer.name` instead (split client-side if you need parts).
### Added
* **The overlay checkout SDK.** Load `bachs.js` from your checkout origin, or `npm install @bachs/js` for a typed loader, and open any checkout session in a modal with `Bachs.Checkout.open({ checkoutUrl })`. Lifecycle events included. See [Add an overlay checkout](/guides/checkout/overlay-checkout).
* **`name` is now optional** for a new customer on create checkout session; `email` alone is enough. If provided, it must be non-blank.
* **`customer_name`** and **`customer_email`** on invoice responses, so lists can show who was billed without a second lookup.
* **`success_url`** on create checkout session: the primary field for where a customer is redirected after a successful payment. `return_url` is kept as a deprecated alias.
* **`expires_in_minutes`** on create checkout session (1 to 1440, default 60) to control session lifetime.
* **`billing_reason`** on the payment object: `purchase`, `subscription_create`, `subscription_cycle`, or `subscription_update`.
* **`invoice`** and **`subscription_id`** on the payment object for subscription payments.
* **`metadata`** on the subscription object, on the subscription response and the `customer.subscription.*` webhook payloads. Set it by attaching metadata to the checkout session that starts the subscription (it propagates on success), or update it later with `metadata` on `PATCH /v1/subscriptions/{id}` (a standalone intent). Updates merge: sent keys are added or overwritten, a key sent with an empty-string value is removed, and sending `""` clears everything. The system `checkout_id` key is preserved and cannot be overwritten.
* **`search`** query parameter on list customers to filter by email or name.
### Fixed
* The payment object now returns **`fee_usd`** (previously documented as `fee`, which was never returned). The incorrect `settlement_currency` and `settlement_amount` fields were removed from the object; they were never returned.
* Customer IDs are documented with the correct `cust_` prefix (previously shown as `cus_`).
* Checkout examples in the quickstart used a `products` field and `first_name`/`last_name` on the customer; the real fields are `product_cart` and `name`, and the examples now say so.
### Breaking
**Webhook management moved to `/v1/webhooks` and is now API-key accessible.** The endpoint-management routes moved off the `/v1/developer/webhooks` path.
| Before | After |
| - | - |
| `/v1/developer/webhooks/endpoints` (and all sub-paths) | `/v1/webhooks/endpoints` (and all sub-paths) |
Any code calling the old `/v1/developer/webhooks/*` paths breaks; update the base path to `/v1/webhooks/*`. Authentication is unchanged for dashboard sessions, and API keys with the `webhooks:read` / `webhooks:write` scopes now work too.
### Added
* **Webhook Endpoint API.** You can now create, list, update, and delete webhook endpoints, rotate signing secrets, read delivery metrics, and list or resend events, all with an API key. Thirteen endpoints under `/v1/webhooks`. See [the webhook endpoint object](/api-reference/webhooks/object).
### Fixed
* The webhooks documentation was scattered across three places. It now lives in one Webhooks section: Concepts, Events, and the Endpoint API.
# Product updates
Source: https://docs.bachs.io/changelog/product
New features and improvements to Bachs.
## Global Payouts
Move revenue from your Bachs balance to your own bank accounts and mobile money wallets across more countries. Global Payouts is available to all Bachs users.
Bank payouts support NGN, GHS, KES, TZS, UGX, GBP, USD, CAD and EUR. Mobile money payouts are available in Ghana, Kenya and Tanzania, alongside supported USDT and USDC wallet payouts.
The method depends on your destination:
* Local bank transfers in Nigeria, Ghana, Kenya, Tanzania and Uganda.
* Faster Payments in the UK, ACH, Wire or RTP in the US, EFT or Interac in Canada, and SEPA in supported Eurozone countries.
* Mobile money in GHS, KES and TZS, and stablecoin payouts on supported networks.
If you collect revenue in dollars and operate elsewhere, you can choose where that revenue arrives. Save multiple bank accounts or wallets belonging to you, then choose an approved destination for each payout. Connected accounts can also withdraw their own balances to their own approved destinations.
International bank and mobile money payouts use your available USD balance. When the destination currency differs, review the conversion quote, payout fee and total debit before confirming. Account verification, destination approval and sufficient available funds are required.
[Get started with Global Payouts](/guides/payouts/global-payouts) · [Supported currencies and payout methods](/for-you/supported-currencies#withdrawals)
## We warn you when a webhook endpoint keeps failing
If every delivery to one of your webhook endpoints fails for 24 hours, we now email your account owner and admins. The email names the endpoint, says when it started failing, and gives the date we will turn it off.
If it is still failing after 72 hours, and at least 48 hours after that email, we turn the endpoint off and email you again. One successful delivery resets the count. Before this, nothing told you an endpoint was failing unless you watched the Events tab.
To turn an endpoint back on, open it under **Webhooks** in the developer portal and click **Enable endpoint**, or send `{ "enabled": true }` to the API. Events from while it was off are not sent on their own, so resend the ones you need. You can also turn an endpoint off yourself with the new **Enabled** switch.
[When an endpoint keeps failing](/guides/webhooks/overview#when-an-endpoint-keeps-failing) · [Retries](/guides/webhooks/overview#retries)
## Fixes and improvements
* NGN virtual account deposits now cost 1% of the amount, down from 1.5%. The cap stays at NGN 300. [Fees](/for-you/fees)
* We now require permission to manage webhooks before a team member can add, edit, enable, disable or delete an endpoint, see or rotate its signing secret, or resend events. The Owner, Super Admin and Developer roles have it by default. Before this, any team member could change your webhooks.
## Virtual accounts
Collect NGN bank transfers through a fixed account number for your business or a connected account. The number does not expire, and each deposit becomes a payment on the account that owns it.
For repeat payments, customers can send money straight from their banking app without starting another checkout. Share the account number and bank name, then track incoming deposits through your integration. If you need a transfer tied to a specific order, continue to use a bank-transfer checkout.
Request the `virtual_accounts` capability and complete the account's returned requirements, including the representative's BVN. Once the capability is active, retrieve or create the number through the API. Each account can hold one virtual account per currency; NGN is the only currency available today.
Your integration receives `collection.succeeded` when a deposit succeeds. Sender details, when supplied by the bank, help you reconcile transfers against your own records.
[Set up a virtual account](/guides/virtual-accounts/overview)
## Let buyers check out without you knowing who they are first
You no longer have to attach a customer when you create a checkout. Leave `customer` out and the hosted page asks the buyer for their email and name before they pay.
By default they do not join your customer directory, so one-time buyers you will never bill again do not clutter it. You still get their email and name, and their purchases still group together in your dashboard, so you can find them when they write in about a refund.
Pass `customer_creation: "always"` when you do want the record. The buyer is matched by email, so someone you already hold lands on their existing record rather than a second one.
```json theme={"dark"}
{ "customer_creation": "always" }
```
You still get the buyer either way. `customer_details` carries their email and name on the checkout object and on the `checkout.completed` webhook, whether or not a record was created. That is new for payment links with a fixed price, which previously gave you no identity at all on completion.
A subscription checkout is the exception: it always creates a customer, because recurring billing needs somewhere durable to keep the card on file for renewal.
[Whether a guest becomes a customer](/guides/checkout/checkout-sessions#whether-a-guest-becomes-a-customer-customer-creation)
## Choose the currencies behind each payment method
You can now decide not only which payment methods a checkout offers, but which currencies each of those methods is offered in. Accept cards in USD without accepting them in NGN, or offer bank transfer in every currency it supports while keeping cards to one.
```json theme={"dark"}
{
"payment_method_options": {
"card": { "currencies": ["USD"] },
"bank_transfer": {}
}
}
```
Naming a method with no currencies offers it in all of them, and leaving a method out of the list means it is not offered at all.
Before this, a payment method was all or nothing. If you wanted to stop taking cards in one currency, your only option was to switch cards off completely and lose the currencies you did want.
The same setting works on payment links, where it applies to every checkout the link creates.
[Restrict methods and currencies](/guides/checkout/checkout-sessions#restrict-methods-and-currencies)
## Overlay checkout
Your customers can now pay without leaving your site. Drop `bachs.js` on your page and any checkout session opens in a modal overlay: the hosted checkout, with every payment method and currency, on top of your own page.
```html theme={"dark"}
```
The SDK emits lifecycle events (`checkout.ready`, `checkout.completed`, `checkout.failed`, and more) so your page can react in real time, and it ships as a plain script tag or the [`@bachs/js`](https://www.npmjs.com/package/@bachs/js) npm package with full TypeScript types. There is no test/live switch to configure: the session URL carries its environment, so going live changes zero client code.
Before this, the only way to take a payment was redirecting the customer to the hosted page.
[Add an overlay checkout](/guides/checkout/overlay-checkout)
## A demo you can click
[snapkit.bachs.io](https://snapkit.bachs.io) is a fictional screenshot API whose pricing page runs entirely on Bachs, in the sandbox. One-time purchases, subscriptions, a 14-day free trial, quantity packs, and pay-what-you-want, each one a real checkout you can complete with a test card. A toggle opens every purchase in the overlay or as the hosted page, and you can check out with your own email to get the receipts.
[Tour the demo](/demo)
## Subscriptions
You can now sell recurring products and manage the subscriptions they create, end to end.
Give any product a `billing_cycle` and it becomes recurring. When a customer completes a checkout for that product, Bachs saves their card, bills the first cycle, and creates a subscription that renews automatically. Add a `trial_period` to defer the first charge.
Each subscription exposes its full lifecycle: its status (`trialing`, `active`, `past_due`, `unpaid`, `canceled`), the current billing period, the next charge date, and the line items being billed. You can:
* **Change the plan** by moving a subscription to a different product.
* **Move or end a trial** by setting `trial_end`.
* **Swap the payment method** and retry collection immediately if the subscription is past due.
* **Cancel** immediately, or at the end of the current period with `cancel_at_period_end`.
Mid-cycle plan changes settle through proration, and every renewal or change is recorded as an invoice you can retrieve.
[Subscriptions overview](/guides/subscriptions/overview) · [Subscriptions API](/api-reference/subscriptions/object)
## Payments: one record for every sale
Every payment a customer makes is now a single **payment** object, whether it is a one-time purchase or a subscription renewal. Each payment carries a `billing_reason` (`purchase`, `subscription_create`, `subscription_cycle`, `subscription_update`) so you always know why it was charged, plus the customer, line items, fees, refunds, and (for subscriptions) the invoice it collected.
[Payments API](/api-reference/payments/object)
## Control your checkout redirects
Checkout sessions now accept a `success_url` for where the customer lands after paying, and honor `cancel_url` when they abandon. You can also set `expires_in_minutes` (1 to 1440) to control how long a session stays open.
[Checkout sessions](/guides/checkout/checkout-sessions)
Webhooks got a real home this week: one clear section in the docs, and an API to manage endpoints without leaving your code.
## Manage webhook endpoints from the API
You can now create and manage webhook endpoints with an API key, not just from the dashboard. Register a URL, choose the events it should receive, rotate its signing secret, and inspect deliveries, all programmatically.
```bash theme={"dark"}
curl https://api.bachs.io/v1/webhooks/endpoints \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Production events",
"url": "https://api.example.com/webhooks/bachs",
"event_types": ["collection.succeeded", "collection.failed"]
}'
```
The response includes the signing secret once, on creation, so you can wire up signature verification straight away. You can also read delivery metrics per endpoint and resend a past event to debug an integration.
Before this, setting up a webhook meant clicking through the dashboard. Now you can script it, provision endpoints per environment, and manage them the same way you manage the rest of your integration. It needs an API key with the `webhooks:read` / `webhooks:write` scopes.
[Webhook endpoint API](/api-reference/webhooks/object) · [Setting up webhooks](/guides/webhooks/overview)
## Webhooks docs, in one place
The webhooks documentation was spread across three sections. It now lives in a single Webhooks area with three parts: **Concepts** (setup, signature verification, replay), **Events** (the payload for every event you can subscribe to), and the **Endpoint API**.
[Webhooks](/guides/webhooks/overview)
# Command Reference
Source: https://docs.bachs.io/cli/commands
Every Bachs CLI command, flag, and argument.
Run `bachs --help` to see the command list. Commands that take flags, such as `bachs listen`, support `--help`. For grouped commands such as `events`, `sessions`, and `endpoints`, run the group without a subcommand to see its operations, then add `--help` after the operation (for example, `bachs events list --help`). Run a resource name on its own to see its API operations.
Every command accepts `--api-key` to override your stored credentials for that invocation. It is left out of the tables below to keep them readable.
***
## login
Pairs this machine with your account through the browser. See [Log in](/cli/overview#log-in) for the full flow.
```bash Connect this machine theme={"dark"}
bachs login
```
| Flag | What it does |
| - | - |
| `--sandbox` | Pair against the sandbox rather than production. |
| `--device-name ` | Label shown on the approval screen. Defaults to your hostname. |
| `--api-key ` | Log in with a key instead of the browser. |
`--api-key` puts a credential in your shell history, and on Linux it is readable by other local users through the process list. Prefer the browser flow, or `BACHS_API_KEY` where no browser is available.
***
## logout
Removes the credential stored on this machine. It does not affect a key supplied through `BACHS_API_KEY` or `--api-key`.
```bash Forget the stored credential theme={"dark"}
bachs logout
```
Logging out removes the local credential but does not revoke the paired key. Revoke it from the [developer portal](https://app.bachs.io/developer/portal) if it should stop working immediately; otherwise it expires on its normal schedule.
***
## whoami
Shows which environment your stored credentials target. Useful before running anything that changes data.
```bash Check the active environment theme={"dark"}
bachs whoami
```
```text Response theme={"dark"}
Environment: sandbox
API base: https://sandbox-api.bachs.io
Key: sk_sandbox_a1b2c3d4…
```
***
## listen
Forwards live events to a port on your machine. The connection is opened outbound, so there is no public URL and no tunnel. See [Test webhooks locally](/developer-portal/local-testing) for the full guide.
```bash Forward every event theme={"dark"}
bachs listen --forward-to localhost:3000/webhooks
```
| Flag | What it does |
| - | - |
| `--forward-to`, `-f` | Local target, for example `localhost:3000/webhooks`. Required. |
| `--events`, `-e` | Comma-separated event types to forward. |
| `--all` | Forward every event type. The default when `--events` is omitted. |
| `--device-name ` | Label for this session in the dashboard. |
The target accepts the shapes people actually type. `localhost:3000/webhooks`, `:3000/webhooks`, and a full URL all work.
```bash Forward two event types theme={"dark"}
bachs listen --forward-to localhost:3000/webhooks --events collection.succeeded,refund.paid
```
Pass either `--events` or `--all`, not both. The CLI refuses rather than silently preferring one, so a filter you passed never does nothing without telling you.
***
## sessions
Lists forwarding sessions that are currently connected to your account and closes sessions you no longer need. This is useful when events are reaching another machine or a terminal you left running.
### sessions list
```bash List live forwarding sessions theme={"dark"}
bachs sessions list
```
### sessions close
```bash Close one forwarding session theme={"dark"}
bachs sessions close whls_8f2e40f6bdf84c1980e1e1f6407f3f8a
```
Closing a session stops delivery to that CLI connection. It does not change registered webhook endpoints.
***
## trigger
Emits a sample event so you can exercise a handler without making a real payment. The event goes through the ordinary delivery path, so it fans out to whatever is subscribed, records delivery attempts, and is signed like any other event.
```bash Emit a sample event theme={"dark"}
bachs trigger collection.succeeded
```
Sandbox only. A fake `collection.succeeded` in production could have you fulfil an order nobody paid for, so the CLI refuses rather than warning. In production, use [`bachs events replay`](#events) to redeliver a real past event.
The event type is a positional argument. These are supported:
| Group | Event types |
| - | - |
| Collections | `collection.succeeded`, `collection.failed` |
| Payouts | `payout.created`, `payout.paid`, `payout.failed` |
| Refunds | `refund.created`, `refund.paid`, `refund.failed` |
| Checkout | `checkout.completed`, `checkout.expired` |
| Customers | `customer.created`, `customer.updated` |
***
## events
Lists past events and redelivers them. Replay is a plain API call, so it needs no forwarding session and works against your registered destinations in production too.
### events list
```bash Recent events and how they went theme={"dark"}
bachs events list
```
| Flag | What it does |
| - | - |
| `--limit ` | How many events to show. Max 100, default 20. |
| `--type ` | Only this event type, for example `collection.succeeded`. |
| `--failed` | Only events whose last delivery failed. |
| `--undelivered` | Only events nothing was listening for. |
`--undelivered` is the one to reach for when an event seems to have vanished. It shows events that matched no destination at all, which usually means the endpoint filter does not include that type.
### events replay
```bash Redeliver one event theme={"dark"}
bachs events replay evt_3ab4e0d5d27445cf8a52ab3d8cb8f0b1
```
### events replay-failed
Redelivers events whose delivery failed, which saves replaying them one at a time after an outage on your side.
```bash See what would be replayed theme={"dark"}
bachs events replay-failed --dry-run
```
| Flag | What it does |
| - | - |
| `--dry-run` | List what would be replayed and stop. |
| `--limit ` | Most events to replay. Default 20. |
| `--type ` | Only this event type. |
Run `--dry-run` first. It costs nothing and tells you exactly what the real run will touch.
***
## endpoints
Manages the webhook destinations Bachs delivers to. These are the same destinations shown in the Developer Portal.
```bash Your webhook destinations theme={"dark"}
bachs endpoints list
```
```bash Register a destination theme={"dark"}
bachs endpoints create --url https://example.com/webhooks --events collection.succeeded,refund.paid
```
| Flag | What it does |
| - | - |
| `--url ` | HTTPS URL to deliver to. Required. |
| `--events ` | Comma-separated event types. Required. |
| `--name ` | Label for this destination. |
```bash Remove a destination theme={"dark"}
bachs endpoints delete whe_8f2e40f6bdf84c1980e1e1f6407f3f8a
```
***
## Resource commands
Every API operation is also a command, grouped by resource. Run a resource name on its own to see its operations:
```bash See what you can do with customers theme={"dark"}
bachs customers
```
```text Operations theme={"dark"}
Usage: bachs customers [flags]
Operations:
create Create a customer
get Retrieve a customer
list List customers
update Update a customer
```
The available resources:
### bachs-sdk (Rust)
Async, typed Rust SDK mirroring the Node.js SDK's resource layout.
By [@enigma-137](https://github.com/enigma-137)
`Rust` `SDK` `Async`
```bash theme={"dark"}
cargo add bachs-sdk
```
```rust theme={"dark"}
let bachs = Bachs::new(std::env::var("BACHS_API_KEY")?)?;
let customer = bachs.customers().get("cust_123456").await?;
```
Early stage, expect gaps and breaking changes. The repo is named `basch-rust-sdk` (typo); the crate is correctly `bachs-sdk`.
[Repo](https://github.com/enigma-137/basch-rust-sdk) · [crates.io](https://crates.io/crates/bachs-sdk) · MIT
### Payment Gateway for Bachs (WooCommerce)
Adds Bachs as a WooCommerce payment method, with redirect or popup checkout and webhook-verified fulfilment.
By [Ibrahim Nasir](https://profiles.wordpress.org/ibrahimkh4l33l) ([@ibrahimkh4l33l](https://profiles.wordpress.org/ibrahimkh4l33l))
`PHP` `WooCommerce` `Plugin`
Install from WordPress admin: **Plugins → Add New**, search `Payment Gateway for Bachs`.
One-time payments only in v1.0.0 (no subscriptions). Requires WordPress 6.2+, WooCommerce 8.0+, PHP 7.4+, SSL for live payments.
[Plugin page](https://wordpress.org/plugins/payment-gateway-for-bachs-for-woocommerce/) · v1.0.0
### bachs-skills (Claude Code)
A Claude Code skill that gives AI coding agents accurate knowledge of the Bachs API, plus a zero-dependency TypeScript client.
By [@shellhaki](https://github.com/shellhaki)
`TypeScript` `Claude Code` `Skill`
Follow [INSTALL.md](https://github.com/shellhaki/bachs-skills/blob/main/INSTALL.md) in the repo to add it to a Claude Code workspace. Once installed, it runs automatically in the background.
```typescript theme={"dark"}
import { BachsClient } from './bachs/client';
const client = new BachsClient({ apiKey: process.env.BACHS_API_KEY });
const session = await client.checkouts.create({
customer: { email: 'user@example.com' },
product_cart: [{ product_id: 'prod_abc123', quantity: 1 }],
success_url: 'https://your-site.com/success',
});
```
[Repo](https://github.com/shellhaki/bachs-skills)
Nothing here yet. If you've built a starter template or boilerplate on Bachs, be the first, see [Submit Your Project](/community/submit).
Want your project listed? See [Submit Your Project](/community/submit).
# Submit Your Project
Source: https://docs.bachs.io/community/submit
How to add your open-source project to the Bachs community directory.
* It's open-source with a permissive license.
* It's actually relevant to Bachs (SDK, plugin, integration, or boilerplate).
* It has a README with install instructions, usage, and its maintenance status.
Edit community/projects.mdx and add a card + modal pair under the right ``, using the same `SLUG` for both. Add the card inside the tab's card grid:
```mdx theme={"dark"}
PROJECT_NAME (LANGUAGE)One sentence on what your project does.By @your_github_handle`LANGUAGE` `FRAMEWORK` `CATEGORY`
```
Then add the matching modal after the card grid, inside the same ``:
````mdx theme={"dark"}
### PROJECT_NAME (LANGUAGE)
One sentence on what your project does.
By [@your_github_handle](GITHUB_PROFILE_URL)
`LANGUAGE` `FRAMEWORK` `CATEGORY`
```bash
INSTALL_COMMAND
```
One short usage example, the most common thing someone would do with it.
[Repo](REPO_URL) · LICENSE
````
The inner `prose` wrapper matters: without it the paragraphs render without spacing and run together. Keep the blank line right after the `
` opening tag too, or the heading won't parse as markdown.
Open a pull request with that change.
Prefer not to open a PR? [Open an issue](https://github.com/bachsHQ/bachsdocs-public/issues/new?template=community-project-submission.yml) with the "Community Project Submission" template instead. Include the repo URL, a one-sentence description, category (SDK, plugin, or boilerplate), primary language, an install/usage snippet, and license.
We review for relevance, accuracy, and safety. Approved entries are merged and show up in the [Projects Directory](/community/projects). We may remove entries later if they go unmaintained, are insecure, or turn out misleading.
# Accounts
Source: https://docs.bachs.io/connect/accounts
Create a financial identity for a seller or contractor, act on its behalf, and read its state.
An **account** is a financial identity you create and own, separate from your own balance and permissions. It holds a balance per currency, carries its own requirements, and has its own capabilities. You create it, you onboard it, and you can act on its behalf.
The seller or contractor behind it does not sign up for Bachs, and does not connect an account they already have. One API call brings the account into existence.
***
## Create an account
`POST /v1/accounts` · scope `connected_accounts:write` · [full field reference →](/api-reference/accounts/create-an-account)
`contact_email` is the only required field.
```bash theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/accounts \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"contact_email": "ada@example.com",
"display_name": "Ada Stores",
"country": "NG",
"entity_type": "individual",
"configuration": {
"merchant": {
"capabilities": { "card_collection": { "requested": true } }
},
"recipient": {
"capabilities": {
"transfers": { "requested": true },
"payouts": { "requested": true }
}
}
},
"responsibilities": { "fees": { "collector": "bachs" } }
}'
```
* `country` defaults to your platform's country, and decides which requirements the account is given.
* `entity_type` is `individual` or `company`. Any other value is rejected with `400`.
* `configuration` names the personas (`merchant`, `recipient`) the account is being created for, each as its own key. Nothing is applied for you, so name at least one persona and request at least one capability under it; don't create an account without both. Sending `"configuration": {}` is rejected with `422 VALIDATION_ERROR`, since an empty object is never what you mean. Naming a persona with no `capabilities` under it is not itself a capability request; nest capabilities under it, or leave `capabilities` out of a persona entirely to request everything that persona allows.
* Every capability lives inside `configuration..capabilities`, so naming one always names the persona it belongs to in the same request. `card_collection` belongs to `merchant`; `payouts` and `transfers` belong to `recipient`. Nesting a capability under the wrong persona fails with `400 capability_configuration_mismatch`: it is never silently corrected. In sandbox, whatever is granted is granted `active` immediately rather than `pending_review`; see [Testing](/connect/testing).
* Send `"capabilities": {}` under a persona when you want to apply it and request nothing yet, for a platform that creates the account now and collects requirements later.
* `responsibilities.fees.collector` decides who takes the Bachs processing fee on this account's **direct** charges. `bachs` (the default) means the fee comes out of the charge, so the account settles net. `platform` means you absorb it: the fee is debited from your balance and the account settles gross. It is fixed at creation and cannot be changed afterwards, and it is returned on every read of the account.
`platform` has no effect on a destination charge. There the account is the counterparty being paid out of a sale that is yours, so there is no fee of theirs for you to absorb; the setting is read but does not apply, and the charge settles as it otherwise would. Set it for accounts that sell in their own name.
An account can start out recipient-only and be given `merchant` later: `POST /v1/accounts/{account_id}` takes the same `configuration` shape, so naming `merchant` there applies it the same way naming it at creation would. See [Update an account](#update-an-account).
Omit `responsibilities` and the account settles net, which is what most platforms want. Take the fee on only when you mean to pay it:
```bash theme={"dark"}
# The platform absorbs the Bachs fee on every charge this account ever takes.
# Immutable once the account exists; there is no way to hand it back.
curl -X POST https://sandbox-api.bachs.io/v1/accounts \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"contact_email": "ada@example.com",
"responsibilities": { "fees": { "collector": "platform" } }
}'
```
`payouts`, `transfers` and `conversions` are the `recipient` capabilities, the ones any account needs just to hold and move money it has received. `card_collection` and the other payment-method capabilities are `merchant` capabilities: what gives an account the ability to accept payments in the first place. Name both personas in `configuration` if the account needs to do both. See [Capabilities](/connect/capabilities) for what each capability permits and how the two groups relate.
A field this endpoint does not read is not rejected. The request still returns `201`, and whatever that field was trying to set silently takes its default instead. Nothing in the response marks a field as ignored, so a stale integration can create accounts successfully while quietly missing the setting it thinks it sent. Check the response body against the [field reference](/api-reference/accounts/create-an-account) rather than assuming a field you sent was understood.
Bachs creates a service user to own the account and sends onboarding correspondence to `contact_email`. An account cannot create other accounts of its own; that returns `403`.
Treat the account id as an opaque string. New accounts are prefixed `acct_`, and accounts created before that convention are unprefixed. Store what the API returns and send it back unchanged rather than validating its shape.
***
## Act on behalf of an account
Send the account id in `X-Account-Id`. The request runs as that account.
```bash theme={"dark"}
curl https://sandbox-api.bachs.io/v1/balances \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "X-Account-Id: acct_3Wq8ZfT1yHnJ5sVe"
```
* The header works on any API-key endpoint, not only Connect ones.
* Naming an account you do not own returns `403` — your key authenticates fine, it is just not authorized for that account. A non-existent id and another platform's id return the same `403`, so the header cannot probe which ids are real. See [Acting as an account](/connect/acting-as-an-account).
* The account's own capabilities still apply. A restricted `payouts` capability blocks the withdrawal whether you call it or the account does.
There is no separate API key per account.
***
## Update an account
`POST /v1/accounts/{account_id}` · scope `connected_accounts:write` · [full field reference →](/api-reference/accounts/update-an-account)
One write path. Set the account's contact details, request capabilities, and supply requirement values in a single call. Omitted keys are left alone.
```bash theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"display_name": "Ada Stores Ltd",
"configuration": {
"recipient": {
"capabilities": { "conversions": { "requested": true } }
}
},
"fields": { "company": { "registration_number": "RC-1043221" } }
}'
```
* `display_name` and `contact_email` change what you set at creation.
* `configuration` requests capabilities, and cannot withdraw one. See [Capabilities](/connect/capabilities). It is the same shape as creation: naming a persona as a key is what applies it, if the account does not already have it, whether or not you nest a capability under its `capabilities`. `conversions` above works because `recipient` is either already applied or gets applied by this same call. Unlike creation, a persona named here with `capabilities` left out never blanket-requests: it only applies the persona.
* `fields` carries requirement values, keyed the way the account's requirements name them. See [Requirements](/connect/requirements).
* `balance_currencies` sets which currencies the account holds. See below.
### Currencies the account holds
Holding a currency decides what an account settles in, not what it can charge in. A checkout can be priced in any supported currency, held or not, and converts on the way to the balance. This is true for one-time and recurring (subscription) checkouts alike: a subscription priced in a currency the account does not hold still bills each cycle and settles the proceeds into the account's settlement currency (USD unless it holds the priced currency). A new account holds only USD, so give it a currency when you want its takings to stay in that currency rather than convert.
Not every currency Bachs collects in can be held as a balance. [Balance currencies](/for-you/supported-currencies#balance-currencies) lists the ones that can. Asking for any other is refused with `400`, and the error names the set that is accepted.
```bash theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{ "balance_currencies": { "NGN": true } }'
```
Send `true` to add a currency and `false` to remove one. Omit the field and nothing changes. USD is always held, cannot be removed, and does not appear in the response.
Skip this and a charge priced in that currency still goes through, but it converts and settles to the account's settlement currency (USD by default) rather than staying in the priced currency. It is the account's own currencies that matter here, not yours: an account you created holds only USD however many currencies your platform holds. (One current exception: a **recurring** checkout in an unheld currency is still refused with `BASE_CURRENCY_NOT_HELD_BY_ORG` until the settle-to-USD path ships for renewals.)
This only applies to an account that takes its own payments. An account that is paid through [transfers](/connect/transfers) receives whatever currency you send it, and holds it from that point.
A partial `fields` submission is never rejected for being partial. Everything valid is saved, and whatever is still missing or invalid comes back in the `requirements` block of the same response, so you can save an account holder's progress as they fill in a form.
***
## Persons
`/v1/accounts/{account_id}/persons` · scope `connected_accounts:read` / `connected_accounts:write`
The people behind an account: its representative, its beneficial owners, its directors. A company account needs its representative and every owner at or above the ownership threshold; an individual account has exactly one person.
| | |
| - | - |
| `GET /persons` | List them |
| `POST /persons` | Add one |
| `GET /persons/{person_id}` | Read one |
| `POST /persons/{person_id}` | Edit one, in place |
| `DELETE /persons/{person_id}` | Remove one |
```bash theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe/persons \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"first_name": "Ada",
"last_name": "Obi",
"dob": "1990-04-12",
"relationship": { "representative": true, "owner": true, "percent_ownership": 60 }
}'
```
**Roles are flags, not lists.** One person is commonly the representative, an owner and a director at once, so `relationship` carries all of them on the same person rather than repeating them across separate collections.
On edit, keys you omit are left alone; sending a key as `null` clears it. So `{"last_name": "Obi-Nwosu"}` renames without touching anything else, and naming one relationship flag does not reset the rest.
Requirement keys are anchored to the person: `persons.per_3a91c0d7.id_document` names exactly who needs to re-upload. Removing a person removes the requirements that were only about them.
The representative cannot be removed, because nothing would ask for a replacement and the account would stall. Make another person the representative first.
The response reports whether an ID number is held (`id_number_provided`) rather than echoing it, and never carries how the person was verified.
***
## Read an account
`GET /v1/accounts/{account_id}` · scope `connected_accounts:read` · [full field reference →](/api-reference/accounts/get-an-account)
Returns the account with its `capabilities`, `configuration`, `responsibilities`, and `requirements` blocks. `configuration` is keyed by persona (`merchant`, `recipient`) and, on a single-account read, reads as `{}` when none apply rather than `null`. List items do not carry it and return `null`, the same way they do for `capabilities` and `requirements`. `responsibilities` reads as `{ "fees": { "collector": "bachs" } }` unless the platform took the fee on at creation. `GET /v1/accounts` lists your accounts.
Fields with a standing rejection are listed in `requirements.errors`, each with the field key and a reason written for the account holder. Like the rest of `requirements`, they are only on a single-account read, so an index screen built from `GET /v1/accounts` cannot flag accounts needing attention; read the account to know.
Nothing outstanding in `requirements` does not mean a capability is active. Gate features on the capability's `status`. See [Capabilities](/connect/capabilities).
### include
`requirements.values` is not returned by default, since it needs a further resource load. Ask for it when you want to read back what has already been submitted:
```bash theme={"dark"}
curl "https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe?include=requirements.values" \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
It adds `requirements.values` and `requirements.persons`. Sensitive fields are listed as provided and never echoed back.
Comma-separated or repeated (`?include=a,b` and `?include=a&include=b` are the same). An unknown value returns `400 invalid_include` rather than being ignored, so a typo does not leave you waiting for a block that never arrives. Anything not asked for is **absent** from the response rather than `null`, since `null` could not be told apart from genuinely empty.
***
## Errors
Account endpoints return the standard [error envelope](/errors). Common cases:
* `FORBIDDEN` (403), your `connect` capability is not active. See [Become a platform](/connect/become-a-platform).
* `NOT_FOUND` (404), the account id is not one of your accounts.
* `VALIDATION_ERROR` (422), a field failed validation; inspect `errors[]`.
* `invalid_configuration` (400), `configuration` names a persona that does not exist. Valid keys are `merchant` and `recipient`.
***
## Related
* [Capabilities](/connect/capabilities)
* [Requirements](/connect/requirements)
* [Onboarding](/connect/onboarding)
* [Split payments](/connect/split-payments)
# Acting as an account
Source: https://docs.bachs.io/connect/acting-as-an-account
How you authenticate a request to act on an account's behalf, and what that authority does and does not cover.
## The header
Your platform authenticates with its own API key. On its own, a request made with that key acts as your platform: any charge, transfer, or balance read is yours.
Send `X-Account-Id` with an account's id and the request acts as that account instead.
```bash theme={"dark"}
curl https://sandbox-api.bachs.io/v1/balances \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "X-Account-Id: acct_3Wq8ZfT1yHnJ5sVe"
```
The header works on any API-key endpoint, not only the Connect ones. There is no separate key per account: one platform key, with `X-Account-Id` deciding whose behalf a given call is on.
An unread or misspelled header name is not rejected. If the API does not recognize the header, the request runs as your platform and returns success. A caller who omits `X-Account-Id`, or sends a stale header name, gets a successful call against the wrong party rather than an error. On a checkout this means the charge and the money land in your platform's balance instead of the account's.
## What it changes
The header changes who a call is made on behalf of, which changes who owns the result.
* A checkout created with `X-Account-Id` is a direct charge: the account is the merchant of record, and the sale lands in its balance. See [Direct charges](/connect/split-payments/direct).
* A transfer sent with `X-Account-Id` moves funds from that account's balance. See [Transfers](/connect/transfers).
Everything else about creating a checkout or a transfer is unchanged. The header only decides whose balance the call reads from and writes to.
## What it does not do
**It cannot reach an account that is not yours.** Naming an id that is not one of your accounts returns `403`, naming the header and the id you sent, rather than acting on it. The check is a direct parentage test: the named account's parent must be the account your key belongs to, or the request is rejected outright. There is no broader reach through a shared platform, an intermediate account, or any other relationship.
An id that does not exist and an id belonging to another platform return the same `403`, so the header cannot be used to find out which account ids are real. Your key itself is unaffected: the same request without the header succeeds.
**It does not grant the account anything it has not been set up for.** The header changes who a call runs as; it does not change what that party is allowed to do. An account still needs the capability for a payment method before a checkout run as that account can accept it, and still needs `payouts` before a withdrawal run as that account will succeed. See [Capabilities](/connect/capabilities).
**It does not change which scope your key needs.** The scope check is against your platform key's own scopes, the same ones it would need calling on its own behalf. Acting as an account does not require a different scope and does not grant one your key does not already have.
## Next steps
* [Accounts](/connect/accounts)
* [Direct charges](/connect/split-payments/direct)
* [Transfers](/connect/transfers)
* [Capabilities](/connect/capabilities)
# Balances
Source: https://docs.bachs.io/connect/balances
Read available and pending balances for your platform and any account.
Every account holds one balance per currency. Currencies are independent: an account can hold USD and still be unable to move NGN.
***
## Read a balance
`GET /v1/balances` · scope `balance:read` · [full field reference →](/api-reference/accounts/get-balances)
```bash theme={"dark"}
curl https://sandbox-api.bachs.io/v1/balances \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
```json theme={"dark"}
{
"account_id": "acct_7KpQ2mNv4XbR9dLc",
"balances": [
{ "currency": "NGN", "available_balance": "482500.00", "pending_balance": "125000.00" },
{ "currency": "USD", "available_balance": "1240.00", "pending_balance": "0.00" }
],
"total_balance_usd": "1620.45",
"pending_settlements_by_day": [
{ "date": "2026-08-09", "currency": "NGN", "total": "125000.00" }
]
}
```
Add `X-Account-Id` to read an account's balance. This works from the moment the account exists, whatever state its capabilities are in.
* **`available_balance`** funds transfers and withdrawals.
* **`pending_balance`** is charged money awaiting settlement. It cannot be moved.
* **`pending_settlements_by_day`** gives the date each pending amount becomes available, per currency. Schedule against it.
* **`total_balance_usd`** is a display total. No operation accepts it.
The response covers the currencies the account is configured to hold, always including USD, plus any currency with activity. A currency with neither is absent rather than zero.
A successful charge does not mean a movable balance. Transfers and withdrawals draw on `available_balance` only, so an operation sent before settlement is rejected with `INSUFFICIENT_BALANCE`.
***
## The zero floor
A balance never goes below zero. A transfer or withdrawal above `available_balance` is rejected rather than creating a debt. For a withdrawal, the figure to compare is `total_debited` (what the destination receives plus the fee), not `amount`.
* Transferring more than an account holds fails.
* Once an account has withdrawn, that balance cannot be recovered.
The floor applies per currency. A large USD balance does not fund an NGN transfer, and a transfer never converts. See [Split payments](/connect/split-payments).
A lost dispute is the one exception to the floor. It can deliberately drive `available_balance` negative: the shortfall is the debt, and it heals as the account's own future settlement credits land. While any currency is negative, every transfer and withdrawal for that account is blocked, not only in the negative currency. See [Disputes](/connect/disputes).
***
## Related
* [How money moves](/connect/money-movement)
* [Split payments](/connect/split-payments)
* [Payouts](/connect/payouts)
# Become a platform
Source: https://docs.bachs.io/connect/become-a-platform
Get the connect capability, which your account needs before it can create accounts.
`connect` is the capability that lets your account create and manage connected accounts. Upgrade your platform account to a registered business in the dashboard to use Connect in sandbox. Complete the business compliance requirements before Connect can be enabled for production use.
Upgrading to a registered business converts your account from an individual to a company. This is immediate and cannot be reversed.
Requirements resolve by country and entity type, so every capability you already hold is re-evaluated as a company and its company fields become due. Capabilities already active stay active.
***
## Enable Connect
Upgrade your platform account to a registered business in the dashboard. This makes Connect available in sandbox; you do not need to finish live compliance to build and test there. To use Connect in production, complete the business requirements and wait for review and enablement.
```mermaid theme={"dark"}
flowchart LR
R["Upgrade to registered business"] --> E["Entity type becomes company one way"]
E --> S["Use Connect in sandbox"]
E --> Q["Complete business requirements"]
Q --> V["Production review"]
V --> A["Connect active in production"]
V -->|outstanding| Q
```
You provide your business registration, ownership and control, what you sell, and a bank account in the business name. The exact fields depend on your country. See [Requirements](/connect/requirements).
***
## Check the status
`GET /v1/accounts/me`
```json theme={"dark"}
{
"id": "acct_7KpQ2mNv4XbR9dLc",
"name": "Northwind Technologies",
"country": "NG",
"enabled_capabilities": ["connect", "payouts"],
"balance_currencies": ["NGN", "USD"]
}
```
`enabled_capabilities` lists only what is active in the environment whose key you used. Check it with a sandbox key before creating test accounts, and with a live key after compliance and review before creating production accounts. While `connect` is absent in that environment, you cannot create accounts there. See [Capabilities](/connect/capabilities).
***
## What connect gates
Requires `connect` to be `active`:
* Creating an account
* Issuing an account link
* Writing an account's requirements
* Transferring to an account
Does not require it:
* Reading accounts, their balances, and their capabilities
* Charges collected by your existing accounts
* An account link already issued, so an account mid-onboarding can finish
* Transferring from an account back to you
`connect` governs creating and onboarding accounts. It does not restrict funds held in accounts you have already created.
***
## What you decide per account
`connect` active only gets you to `POST /v1/accounts`. Two choices on that call are worth deciding deliberately, because neither has an update path:
* **Which capabilities the account needs.** Naming a capability is what applies the persona behind it. See [Capabilities](/connect/capabilities).
* **Who collects the Bachs processing fee on that account's charges**, `responsibilities.fees.collector`: `bachs` by default, or `platform` if you absorb it. This is set at creation and fixed for the account's lifetime, because changing it later would retroactively disagree with ledger history that already settled under the old value. See [Processing fees](/connect/processing-fees).
***
## Related
* [Choose your integration](/connect/choose-your-integration)
* [Split payments](/connect/split-payments)
* [Create an account](/connect/guides/create-an-account)
* [Capabilities](/connect/capabilities)
* [Processing fees](/connect/processing-fees)
# Capabilities
Source: https://docs.bachs.io/connect/capabilities
What an account is permitted to do with money, and what each permission asks it to provide.
A **capability** is permission for one account to do one thing with money. Read each capability's status to decide whether the account can use it. Sandbox grants and automatic grants can differ from live requests and review.
> The capabilities you request decide which requirements the account has to complete. Request only what it needs.
## Two layers
A capability is not free-standing. Every capability in the registry belongs to at most one **configuration**, and a configuration is a persona: what kind of money mover the account is.
| Configuration | Means |
| - | - |
| `merchant` | The account accepts payments. |
| `recipient` | The account receives money: withdraws a balance, sends or gets transfers, converts currency. |
An account can only hold a capability whose configuration it has applied. In live mode, applying a configuration makes its capabilities requestable; use each returned capability's status to determine what is active. Sandbox creation grants the operational set allowed by the applied personas. See [Testing Connect](/connect/testing).
**No configuration is ever applied automatically, `recipient` included.** At creation and on update, you name every persona the account needs as a key in `configuration`; an account created with no `configuration` at all holds neither persona and cannot hold any capability.
## Recipient-configuration capabilities
| Capability | Permits |
| - | - |
| `payouts` | Withdrawing the balance to a bank account or wallet |
| `transfers` | Sending and receiving [transfers](/connect/transfers) |
| `conversions` | Converting between currencies the account holds |
These three govern what an account can do with a balance it already holds, and all require the `recipient` configuration. A separate set gates what it can accept in the first place.
### Merchant-configuration capabilities
| Capability | Permits |
| - | - |
| `card_collection` | Accepting cards charged in USD |
| `ngn_card_collection` | Accepting Nigerian cards charged in NGN |
| `bank_transfer` | Accepting bank transfer payments |
| `mobile_money` | Accepting mobile money payments |
| `crypto` | Accepting crypto payments |
| `virtual_accounts` | Issuing a fixed NGN bank account number to receive deposits |
Card acceptance is **two separate capabilities**, split by currency corridor. `card_collection` accepts Visa and Mastercard charged in USD (a global cardholder pays in dollars); `ngn_card_collection` accepts Nigerian-issued Visa and Mastercard charged in NGN (a local cardholder pays in naira). They are requested independently, so an account can hold either, both, or neither: a merchant selling to Nigerians locally can request only `ngn_card_collection`, one selling globally only `card_collection`, and one doing both requests both. Requesting `card_collection` alone does **not** enable naira cards, and vice versa.
The collection capabilities gate payment methods. For virtual-account activation, requirements and number creation, see [Virtual accounts](/guides/virtual-accounts/overview). An account needs the capability for a method `active` before a customer can pay it that way. The check runs when the payment method is resolved, on whichever party's checkout it is, not at checkout creation, so a request without the capability can be accepted and only fail once the customer reaches the hosted page. See [Direct charges](/connect/split-payments/direct) and [Destination charges](/connect/split-payments/destination) for who is checked on each shape.
### `connect`
| Capability | Permits | Held by |
| - | - | - |
| `connect` | Creating and managing accounts | Your platform |
`connect` belongs to neither configuration. It is not something an account requests; it is what your own platform holds so you can call the account endpoints at all. See [Become a platform](/connect/become-a-platform).
Recipient- and merchant-configuration capabilities are requested and read the same way, described below.
***
## Request a capability
At creation, on `POST /v1/accounts`, or afterwards on `POST /v1/accounts/{account_id}` · scope `connected_accounts:write`
The update endpoint accepts your own account as well as a connected account you own. Requesting a capability for your own account does not require creating a connected account first.
```json theme={"dark"}
{
"configuration": {
"recipient": {
"capabilities": { "payouts": { "requested": true } }
}
}
}
```
A live request does not establish approval. Read the capability's `status` and the account's returned requirements; `requested` is a boolean recording the request, not an enabled status.
In **sandbox**, account creation grants the operational capability set allowed by its applied personas as `active`, which can include capabilities you did not explicitly name. This is scoped to the account's applied personas: `payouts`, `transfers`, and `conversions` are granted only if `recipient` was named; `merchant` capabilities only if `merchant` was named. An account created with no `configuration` at all holds neither persona and cannot accept a payment, or hold any capability. See [Testing](/connect/testing).
An explicit live request for `payouts` requests that capability alone. Sandbox creation can grant a wider operational set, and recipient-capable accounts receive `conversions` automatically. See [Testing Connect](/connect/testing).
On update, `POST /v1/accounts/{account_id}`, the same `configuration` shape applies a persona the account does not already have: naming `merchant` with `card_collection` nested under its `capabilities`, on a recipient-only account, applies `merchant` for it, in the same call, and then requests `card_collection`. An account can start recipient-only and become a merchant later this way; nothing about it is a hard stop. Unlike creation, an omitted `capabilities` on update never blanket-requests: naming a persona there with no `capabilities` just applies the persona and requests nothing. In sandbox, whatever is named under `capabilities` grants `active` immediately, the same as at creation, but scoped to only what was named. See [Testing](/connect/testing).
`"requested": false` returns `400 capability_unrequest_unsupported` on update, `POST /v1/accounts/{account_id}`. There is no revocation path for an active capability. On create, `POST /v1/accounts`, `{"recipient": {"capabilities": {"payouts": {"requested": false}}}}` is accepted and means "request nothing": it is equivalent to omitting `payouts` from the map.
***
## Read the status
`GET /v1/accounts/{account_id}/capabilities` · scope `connected_accounts:read` · [full field reference →](/api-reference/accounts/list-capabilities)
Returns every capability applicable to the account, including ones it has never requested.
| `status` | Meaning |
| - | - |
| `active` | Enabled. The account can perform the action. |
| `restricted` | Not enabled. The default, and the state a freshly requested capability lands in. Read `status_details` for why. |
| `pending` | Reserved for a future automated-verdict path. Nothing sets it today; do not gate on it. |
| `unrequested` | The account has no record for this capability. |
| `unsupported` | The account is not eligible for this capability. |
Gate on `status == "active"`. Every other value denies the action.
`unrequested` is not stored. It is returned when no record exists, so you get a status rather than a `null`. It differs from `restricted`, which means requested and not enabled.
`requested` is a separate boolean saying whether the account ever asked. A `restricted` capability that was requested is in progress; one that was never requested is outside this account's setup.
### status\_details
`status_details` carries the machine-readable reason a capability is not `active`, and is `null` when the capability is `active`. Branch on `code` or `resolution`; show `message` to a human.
| `code` | `resolution` | Meaning |
| - | - | - |
| `requirements_outstanding` | `provide_info` | The account owes information. Read `requirements` to see what. |
| `platform_disabled` | `contact_support` | You turned this capability off. Only support can restore it. |
```json theme={"dark"}
{
"name": "payouts",
"status": "restricted",
"requested": true,
"status_details": [
{
"code": "requirements_outstanding",
"resolution": "provide_info",
"message": "Outstanding requirements. See the account's requirements."
}
]
}
```
When a capability is not active because information is outstanding, the fields are in the account's requirements. See [Requirements](/connect/requirements).
***
## Enabling
Completing every requirement makes an account eligible. A capability is enabled on review, not automatically, and a requirement falling due on an active capability does not disable it.
* Subscribe to `capability.updated` rather than polling. See [capability.updated](/guides/webhooks/events/capability-updated).
* An account with an empty `requirements`, or an `account.updated` event with an empty `outstanding`, has nothing left to provide. That is not the same as a capability being active. Check the capability's `status`.
***
## Related
* [Requirements](/connect/requirements)
* [Onboarding](/connect/onboarding)
* [Become a platform](/connect/become-a-platform)
# Choose your integration
Source: https://docs.bachs.io/connect/choose-your-integration
Decide whether the customer pays you or pays the account, before you build.
One decision shapes the rest of a Connect integration.
> When a customer pays, do they pay **you**, or the **account**?
Three questions answer it, and each stands on its own: who is the merchant of record, who pays the processing fee, and who carries a refund or a lost dispute. On this API the merchant of record decides the other two, so answering any one of them is enough to land on a shape. Changing shape later means re-onboarding every account.
***
## The two shapes
| | **You collect** | **The account collects** |
| - | - | - |
| Who the customer pays (merchant of record) | You | The account |
| Whose balance the charge lands in | Yours | Theirs |
| How the account is paid | You transfer its share after settlement | The charge is already theirs |
| Who pays the processing fee, by default | You, since you're the merchant of record | The account, since it is. See [Processing fees](/connect/processing-fees) for how to change who bears it or who collects it. |
| Capabilities the account needs | `transfers`, and `payouts` to withdraw | A payment-accepting capability, and `payouts` to withdraw |
| Requirements the account completes | Fewer | More |
| Who carries a refund or a lost dispute | You | The account |
| Recovering a transferred share | A transfer back, while the account still holds the balance | Not applicable |
**Most marketplaces should collect.** Customers buy from the marketplace, not from each seller individually, so the marketplace is the merchant of record and its accounts are recipients: they receive money rather than take payments. They never need a payment-accepting capability, which is why their onboarding is short.
**Most SaaS platforms should have the account collect.** Each business transacts directly with its own customers, who are often unaware your platform exists, so it is the merchant of record for those payments and needs a payment-accepting capability of its own.
If you are unsure which you are, ask whose brand the customer thinks they are buying from.
***
## You collect
The customer pays you. The charge settles into your balance, and you transfer each account its share. Your cut is whatever you do not transfer.
See [Split payments](/connect/split-payments) for the full flow.
* Requirements are shorter, so fewer accounts abandon onboarding.
* You control the checkout and the timing of every transfer.
* A refund or a lost dispute debits your balance for the full amount owed. A lost dispute can drive it negative; see [Disputes](/connect/disputes).
* If you have already transferred the share, recovering it means a transfer back, and a transfer never goes below zero: recovery fails once the account has withdrawn.
Transfers draw on `available_balance`. A charge lands in `pending_balance` and moves across on settlement, so a transfer sent before settlement fails with `INSUFFICIENT_BALANCE`. Trigger transfers on settlement. See [Balances](/connect/balances).
Delaying the split reduces recovery risk: the longer you hold a share, the more likely a dispute arrives while the balance is still reachable.
***
## The account collects
The customer pays the account. The charge lands in that account's balance.
* Refunds and disputes debit the account's balance, not yours.
* The account needs a payment-accepting capability, which lengthens its requirements. See [Capabilities](/connect/capabilities).
* You cannot transfer a charge you never held. Paying the account means transferring from your own balance, funded separately.
See [Platform fees](/connect/platform-fees).
***
## Deciding
Any one of these, answered on its own, points to a shape. If they disagree, weigh them: the merchant-of-record question usually carries the most, since the other two follow from it by default.
* **Who should be the merchant of record?** Whose brand does the customer think they are buying from. If they came to your marketplace and would recognise your name on the order, you collect. If each account transacts directly with customers it brought, who may not know your platform exists, the account collects.
* **Who should carry a refund or a lost dispute?** If you cannot absorb that risk across every account, put it on the account.
* **Who should pay the processing fee by default?** Whichever party you named merchant of record pays it unless you reassign it. See [Processing fees](/connect/processing-fees).
One more factor outside the three questions: an account made up of individuals rather than registered businesses has shorter requirements as a recipient than as a merchant. If your accounts are individuals and none of the questions above forces the account to collect, let you collect instead.
***
## Related
* [Build a marketplace](/build/use-cases/marketplace): the whole marketplace flow, end to end in the sandbox
* [Build a platform for businesses](/build/use-cases/saas-platform): the whole direct-charge flow, end to end in the sandbox
* [Split payments](/connect/split-payments)
* [Become a platform](/connect/become-a-platform)
* [Capabilities](/connect/capabilities)
* [How money moves](/connect/money-movement)
# Disputes on Connect charges
Source: https://docs.bachs.io/connect/disputes
Who is liable when an account's charge is disputed, and how the dispute affects each party's balance.
## Who a dispute debits
A dispute debits whoever owns the charge, the same rule a refund follows: decided once, at charge creation, by which shape you used.
| Shape | Who the charge belongs to | Who a lost dispute debits |
| - | - | - |
| [Direct](/connect/split-payments/direct) | The account | The account |
| [Destination](/connect/split-payments/destination) | Your platform | Your platform |
A charge you collect on your own platform and split with standalone transfers afterward is liable the same way a destination charge is: your platform owns the charge, so your platform is debited.
Opening a dispute debits a flat dispute fee immediately, regardless of how the dispute is eventually resolved. A won dispute does not refund it. While the dispute is open, the disputed amount is set aside from that party's available balance, so it cannot be paid out. A won dispute releases it. A lost dispute then takes the disputed amount; the fee does not charge again at resolution, since it already moved at open. See [Refunds](/connect/refunds) for the identical ownership rule applied there.
***
## What a lost dispute does to the debited balance
A lost dispute drains the debited party's available balance first, then its pending balance. If that is still not enough, the remainder drives the balance negative: the negative balance is the debt. It heals as the debited party's own future settlement credits land, the same balance climbing back toward zero the way any balance does.
This is a deliberate exception to the zero floor that governs transfers and withdrawals. A transfer or withdrawal is refused before it can take a balance below zero; a lost dispute is not refused, because the money has already left. See [Balances](/connect/balances).
***
## What a negative balance blocks
A negative balance in any one currency freezes every transfer and every withdrawal for that party, across every currency it holds, not only the one that went negative. Both are rejected with `ORGANIZATION_IN_DEBT` until the deficit clears.
This does not contradict the rule that a transfer or withdrawal never takes a balance below zero: that rule still holds for every movement. What changes is that no movement is allowed at all while any currency is in deficit.
The debit, and the resulting negative balance, land in the charge's settlement currency: a lost dispute on an NGN charge drives the NGN balance negative, specifically, and it heals only from that party's future NGN settlement credits. Draining money out in a different currency would remove the volume the debt is repaid from, so the freeze covers everything that party holds rather than only the currency in deficit. Concretely: a platform whose NGN balance goes negative from a lost dispute cannot transfer or withdraw USD either, even though the USD balance was never touched.
The freeze lifts on its own once the debited currency's balance clears; no separate action reopens it. See [Transfers](/connect/transfers) and [Payouts](/connect/payouts).
***
## The dispute fee
The fee is debited when the dispute is first recorded, not at resolution: opening a dispute charges it, win or lose. It comes out of the available balance first, then the pending balance, exactly as described above. A won dispute does not refund it. Because the fee moves at open, it can take the debited party's balance negative before the dispute is even decided, which is enough on its own to trigger the freeze described above.
A lost dispute then debits the disputed amount the same way, on top of the fee already charged at open. The fee itself is not charged a second time.
***
## On a direct charge
The account is liable for the full disputed amount, but it only ever received gross minus your platform fee. Work out the gap before an account asks about it: on a 20% platform fee, a lost dispute leaves the account short by more than it was ever credited for that sale, the same structural gap a refund leaves.
Because the debt is the account's own, it heals from the account's own future settlements. There is nothing for your platform to pursue.
***
## On a destination charge or separate transfers
Your platform is liable for the full amount, the same as it is for a refund on either shape. The account keeps the share it already received: a payout transfer that already moved to it is not reversed.
If you want the account's share back, there is no automatic route. Recovery is between you and the account, off Bachs: send it back yourself while the funds are still in the account's balance, the same way you would recover a share after a refund. See [Refunds](/connect/refunds) for that flow.
***
## What we do not do
There is no reserve held against future disputes: liability is decided at charge creation, by shape, and a lost dispute debits the balance.
***
## How you learn about a dispute
Whoever owns the charge is notified by email when a dispute is raised, and again on a reminder cadence while it still needs a response. On a direct charge that email goes to the account, not your platform.
A dispute also drives two webhook events on the charge owner: [`dispute.created`](/guides/webhooks/events/dispute-created) when it opens, and [`dispute.updated`](/guides/webhooks/events/dispute-updated) whenever its status actually changes, not on every inbound update from the network. The payload carries the dispute's id, charge id, amount and currency, status, reason, whether evidence is still editable, the response deadline, and the disputed charge's own amount and currency for comparison.
On a direct charge, these events originate in the account, the same as any other event of the account's. Your platform receives them only on an endpoint whose `event_source` is `connect` or `all`; the default for a new endpoint is `account`, so a platform that wants to see its accounts' disputes has to opt in explicitly, or it will silently receive nothing. See [Connect events](/guides/webhooks/overview#connect-events).
***
## Acting on a dispute through the API
Listing, reading, and responding to a dispute are all API-key authenticated: list disputes, read one, upload an evidence document, save an evidence draft, and submit it. Send `X-Account-Id` with the account's id to reach that account's own disputes, the same header that makes a charge or a transfer the account's; without it, the calls reach only your platform's disputes.
There is no endpoint that changes who a dispute is liable for, and none that debits a party other than the one the charge already belongs to. See [List Disputes](/api-reference/disputes/list-disputes), [Get Dispute](/api-reference/disputes/get-dispute), [Upload Dispute Document](/api-reference/disputes/upload-dispute-document), [Update Dispute Evidence](/api-reference/disputes/update-dispute-evidence), and [Submit Dispute](/api-reference/disputes/submit-dispute).
***
## Related
* [Refunds](/connect/refunds)
* [Split payments](/connect/split-payments)
* [Direct charges](/connect/split-payments/direct)
* [Destination charges](/connect/split-payments/destination)
* [Balances](/connect/balances)
* [Transfers](/connect/transfers)
* [Payouts](/connect/payouts)
* [Platform fees](/connect/platform-fees)
# Take Connect live
Source: https://docs.bachs.io/connect/go-live
What changes between sandbox and production for a platform, and what to check first.
After your platform is upgraded to a registered business, you can build with Connect in sandbox. Production use requires completed business compliance and an active live `connect` capability. Then point the same integration at `https://api.bachs.io` with an `sk_live_` key instead of `https://sandbox-api.bachs.io` with `sk_sandbox_`. Sandbox and production are isolated, so nothing crosses over: not accounts, not balances, not keys.
What changes is that everything is real. Verification is checked against real records, capabilities are enabled by a real reviewer, and a transfer moves real money that cannot be reversed.
***
## Before your first account
Complete your registered-business compliance requirements and wait for live Connect enablement. Your sandbox access does not authorize production use. Check your live account:
```bash theme={"dark"}
curl https://api.bachs.io/v1/accounts/me \
-H "Authorization: Bearer sk_live_..."
```
`capabilities.connect.status` has to be `active`. See [Become a platform](/connect/become-a-platform).
A production key needs `connected_accounts:write` to create accounts, `transfers:write` to move money, and `payouts:write` to withdraw. Grant what the integration uses and no more. See [Authentication](/authentication).
Endpoints do not carry across environments. Create the production endpoint with `event_source` set to `connect` or `all`, or you will receive nothing about your accounts. See [Monitor onboarding](/connect/guides/monitor-onboarding).
Signing secrets are per endpoint, so the live endpoint has a different one. A verification step that silently passes in sandbox and fails in production is the most common go-live failure.
***
## Before you move money
* **Trigger transfers on settlement, not on the charge succeeding.** This works in sandbox where balances settle quickly and fails in production where they do not. See [Balances](/connect/balances).
* **Send an `Idempotency-Key` on every transfer and withdrawal.** A retry without one pays twice.
* **Never treat a `5xx` as a failure.** Read the current state before retrying.
* **Decide when you transfer a seller's share.** Once it is transferred and withdrawn, a reversal cannot recover it. See [Split payments](/connect/split-payments).
***
## What does not change
The API paths and request shapes are the same. Production capability review, settlement timing, and real money movement still need live-specific handling.
## What does change
Capability grants do not. In sandbox, a capability whose persona the account has applied is granted `active` at creation, with no review. In production, the same request lands the capability `restricted`, and it only reaches `active` once a reviewer enables it after the account's requirements are complete. An integration that assumes a capability is usable immediately after creation, because that is what it saw in sandbox, will find it `restricted` in production until review finishes. See [Testing](/connect/testing) for exactly what sandbox grants and what it does not.
***
## Related
* [Sandbox](/integrate/sandbox)
* [Authentication](/authentication)
* [Monitor onboarding](/connect/guides/monitor-onboarding)
* [Split payments](/connect/split-payments)
# Onboard through the API
Source: https://docs.bachs.io/connect/guides/api-onboarding
Build your own onboarding interface by reading, filling, and submitting an account's requirements.
In this guide you'll read an account's requirements, resolve the reference data some fields need, submit values and documents, and watch the account move to review. By the end onboarding lives entirely inside your own product.
A recipient-only account's requirements are short: identity and a payout destination, little else. An account that also holds a merchant capability has a longer list, since accepting payments carries its own set of fields. Building this interface once means rendering whichever list a given account actually has, not assuming the short one.
This is a standing commitment. Requirements change as regulation changes, and a new requirement arrives as a field your interface does not render, so accounts stall with nothing visibly wrong. The [hosted link](/connect/guides/hosted-onboarding) picks those changes up on its own.
## Before you start
* An account with capabilities requested. See [Create an account](/connect/guides/create-an-account).
* An API key with `connected_accounts:read` and `connected_accounts:write`.
## Steps
Read the account. `requirements.entries[]` is the field-level view: every outstanding field, its state, and the capabilities it holds up.
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
```json Response theme={"dark"}
{
"id": "acct_3Wq8ZfT1yHnJ5sVe",
"country": "NG",
"capabilities": {
"payouts": { "status": "restricted", "requested": true }
},
"requirements": {
"currently_due": ["persons.per_3a91c0d7.first_name", "payout_destination"],
"eventually_due": [],
"past_due": [],
"pending_verification": [],
"errors": [],
"entries": [
{
"field": "persons.per_3a91c0d7.first_name",
"status": "currently_due",
"restricts_capabilities": ["payouts"],
"resolution": "api",
"deadline": null,
"errors": []
}
]
}
}
```
Render `entries` as your form, and use `restricts_capabilities` to tell the account holder what each remaining field unlocks. See [Requirements](/connect/requirements) for every state.
Add `?include=requirements.values` to read back what has already been submitted. This is useful for prefilling an edit, and for resolving a person index like `persons.0` to a name.
Some fields cannot be typed freely. Fetch the valid values first.
```bash theme={"dark"}
curl "https://sandbox-api.bachs.io/v1/reference/banks?country=NG" \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
`momo` returns mobile money operators the same way. Before you submit a bank account, confirm it resolves to a real account name:
```bash theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/misc/bank-accounts/resolve \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{ "account_number": "0123456789", "bank_code": "058" }'
```
These are not account-scoped (the NG bank list is the same for every account), so there is no account in the path. `country` falls back to your own.
Resolving before submission turns a rejected requirement days later into an inline error while the account holder is still on the page.
Files are not part of this flow. A document requirement (a person's identity document, a certificate of incorporation) is collected through an [account link](/connect/onboarding): mint one, send the account holder to it, and they upload in the hosted flow. The upload lands against the account and satisfies the requirement without passing through you.
```bash theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe/account-links \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"type": "update",
"refresh_url": "https://yourapp.example/onboarding/refresh",
"return_url": "https://yourapp.example/onboarding/done"
}'
```
Everything else on this page stays server-to-server; only the files need the account holder present. See [Hosted onboarding](/connect/guides/hosted-onboarding).
Submitting is an account write: `POST /v1/accounts/{account_id}` with a `fields` body, keyed by the same field keys `requirements.entries[]` returned. Omitting a field leaves it untouched, so you can build up a submission over several calls. But every field you do send is validated together, and if any one of them is rejected the call fails with `400 INVALID_REQUIREMENT_FIELD` and none of the fields in it are saved, not even the ones that were fine. Contact details and capability requests in the same call are applied before the fields are validated, so a rejection does not undo them. A field that is only incomplete does not fail the call: it stays outstanding in `requirements`. See [Requirements](/connect/requirements#the-payout-destination-shape) for the `payout_destination` shape and its rejection codes.
The same call can set contact details and request capabilities, so a form submit is one round trip.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"fields": {
"persons": [
{
"first_name": "Ada",
"last_name": "Obi",
"relationship": { "representative": true }
}
],
"payout_destination": {
"currency": "NGN",
"account_number": "0123456789",
"account_name": "Ada Obi",
"bank_code": "058"
}
}
}'
```
```json Response theme={"dark"}
{
"id": "acct_3Wq8ZfT1yHnJ5sVe",
"requirements": {
"currently_due": [],
"eventually_due": [],
"past_due": [],
"pending_verification": ["persons.per_3a91c0d7.name", "payout_destination"],
"errors": []
}
}
```
An empty `currently_due` does not mean the account is finished. What was submitted sits in `pending_verification` until it is checked, so read every bucket, not only `currently_due`, before you tell the account holder there is nothing left to do.
Submitted fields move to `pending_verification`, and each entry's own `status` can further become `pending_review` once an automated check hands it to a reviewer. Anything rejected comes back as `currently_due` with an entry in `errors`, whose `reason` is written for display to the account holder. See [Requirements](/connect/requirements) for the full state diagram.
## What happens next
Once every requirement bucket is empty, a reviewer enables each capability, which arrives as `capability.updated`. Do not poll for it. See [Monitor onboarding](/connect/guides/monitor-onboarding).
## Errors
Submitting requirement values returns the standard [error envelope](/errors).
| Cause | Resolution |
| - | - |
| A submitted value is rejected, for example a bank code that is not a real code in the account's country (`INVALID_REQUIREMENT_FIELD`, 400). | Inspect `errors[]` (each entry has `field`, `message`, and `code`), fix the named fields, and resend the whole `fields` object again. No field from a rejected submission is saved; anything else in the same call (contact details, capability requests, profile changes) was applied before the fields were validated and stays applied. A field that is merely absent or incomplete does not cause this: it comes back as still outstanding in `requirements`. |
## Next steps
* [Verify an account's identity](/connect/guides/identity-verification)
* [Monitor onboarding](/connect/guides/monitor-onboarding)
* [Requirements](/connect/requirements)
# Create an account
Source: https://docs.bachs.io/connect/guides/create-an-account
Create an account for a seller or contractor, choose what it can do, and read back what it owes.
In this guide you'll create an account, decide whether it only receives money or also accepts payments, and read back the requirements that choice produced. By the end you'll have an account ready to onboard.
Most marketplaces only need an account that receives money: it gets paid out, moves transfers, converts currency. That account's requirements are short. An account that also accepts payments in its own name needs a merchant capability, and its requirements grow accordingly. Decide which one you're building before you call this endpoint, because it is the single biggest lever you have over whether the account finishes onboarding at all.
## Before you start
* A **sandbox API key** (`sk_sandbox_...`) with the `connected_accounts:write` scope. See [Authentication](/authentication).
* The `connect` capability `active` on your account. See [Become a platform](/connect/become-a-platform).
## Steps
`contact_email` is the only required field. Send `country` when the account is not in yours, because country decides which requirements it is given.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/accounts \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"contact_email": "ada@example.com",
"display_name": "Ada Stores",
"country": "NG",
"entity_type": "individual",
"configuration": {
"recipient": {
"capabilities": {
"transfers": { "requested": true },
"payouts": { "requested": true }
}
}
}
}'
```
```json Response theme={"dark"}
{
"id": "acct_3Wq8ZfT1yHnJ5sVe",
"name": "Ada Stores",
"parent_organization_id": "acct_7KpQ2mNv4XbR9dLc",
"country": "NG",
"entity_type": "individual",
"capabilities": {
"transfers": { "status": "active", "requested": true, "status_details": null },
"payouts": { "status": "active", "requested": true, "status_details": null }
},
"configuration": { "recipient": {} },
"responsibilities": { "fees": { "collector": "bachs" } },
"requirements": {
"currently_due": ["persons.per_3a91c0d7.first_name", "persons.per_3a91c0d7.id_document"],
"eventually_due": [],
"past_due": [],
"pending_verification": [],
"errors": []
},
"is_active": true,
"created_at": "2026-08-07T09:12:44.000Z"
}
```
This account can receive money (hold a transfer share and withdraw it), but it cannot accept a payment directly from a customer, since no merchant capability was requested and `merchant` was never named in `configuration`. `configuration` names only `recipient` here, and that is the only reason `transfers` and `payouts`, both `recipient` capabilities, were requestable at all. Nothing is applied that was not named, so always name at least one persona and request at least one capability under it; do not create an account without both. This is the shape most marketplaces want.
Name every persona the account needs as a key in `configuration`, and nest each capability under that persona's own `capabilities`: `{"merchant": {"capabilities": {"card_collection": {"requested": true}}}}`, never a bare `{"card_collection": {...}}` at the top level. A capability is only ever named inside the persona object it belongs to, so there is no way to name one without also naming its persona in the same request. Nesting it under the wrong persona's `capabilities` fails with `400 capability_configuration_mismatch`: it is never silently corrected. Naming a persona with `capabilities` left out entirely requests every capability that persona allows, live or sandbox, which is rarely what you want. Send `"capabilities": {}` under it when you deliberately want to apply the persona and request nothing yet.
This request runs in sandbox, where a capability whose persona is applied is granted `active` immediately instead of landing `restricted` for review, which is why the response above already shows `active`. This still respects personas: naming `merchant` in `configuration` and nesting a merchant capability under its `capabilities` here would grant it active the same way. In live mode the same request lands every requested capability `restricted` until a reviewer enables it. See [Testing](/connect/testing).
`responsibilities.fees.collector` is fixed at creation and cannot change afterward: `bachs` (shown above, and the default) takes the processing fee out of the account's own charges; `platform` has you absorb it instead. It only affects the account's own charges, not one it receives as the destination of yours. See [Accounts](/connect/accounts#create-an-account).
The response's `requirements` block lists the field keys the account has to provide. Read it again at any time:
```bash theme={"dark"}
curl https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
A recipient-only account like this one asks for less: no business profile, no payment-method fields, only the identity needed to move money to it. An account that also holds a merchant capability has a longer list, because accepting payments carries its own set of requirements. See [Requirements](/connect/requirements) for the field states and how they change.
Requesting a capability after creation uses the same `configuration` shape as creation: naming a persona as a key, with or without a capability nested under its `capabilities`, is what applies that persona if the account does not already have it. Another `recipient` capability works, since this account already has `recipient`:
```bash theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"configuration": {
"recipient": {
"capabilities": { "conversions": { "requested": true } }
}
}
}'
```
`"requested": false` returns `400` with `capability_unrequest_unsupported`. There is no way to unrequest a capability through the API.
So does a first merchant capability: this is how a recipient-only account becomes a merchant later, not only at creation:
```bash theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"configuration": {
"merchant": {
"capabilities": { "card_collection": { "requested": true } }
}
}
}'
```
Naming `merchant` as a key here applies it to this account, since it did not have it, then requests the `card_collection` nested under its `capabilities`, alone. `recipient` is untouched. Unlike creation, an omitted `capabilities` on update never blanket-requests: naming `merchant` with `capabilities` left out here would only apply the persona and request nothing. A capability named under the wrong persona, such as `card_collection` under `recipient`'s `capabilities`, fails with `400 capability_configuration_mismatch` instead: the shape only applies the persona you actually named, never a different one inferred from the capability.
In sandbox, a capability requested this way is granted `active` immediately, the same as at creation, scoped to only what you named: `card_collection` here, not the rest of what `merchant` allows. In live it lands `restricted` for review, same as everywhere else. See [Testing](/connect/testing).
## With this shape
* A recipient-only account, `configuration: {"recipient": {}}`, can be paid out, send and receive transfers, and convert currency, and nothing more. Its requirements are the short list this guide showed.
* Naming `merchant` as a key in `configuration`, with a merchant capability nested under its `capabilities`, at creation or later, adds that capability's requirements to the account. It does not remove `recipient` if the account has it; an account keeps every persona it has ever been given.
* `responsibilities.fees.collector` is fixed the moment the account exists. Decide it here; there is no endpoint to change it afterward.
## Errors
Account creation returns the standard [error envelope](/errors).
| Cause | Resolution |
| - | - |
| Your account's `connect` capability is not `active` (`FORBIDDEN`, 403). | Complete platform onboarding first. See [Become a platform](/connect/become-a-platform). |
| A field failed validation (`VALIDATION_ERROR`, 422). | Inspect `errors[]` in the response. |
| `configuration` named a persona that does not exist (`invalid_configuration`, 400). Valid keys are `merchant` and `recipient`. | Correct the key and resend. |
| A capability was nested under the wrong persona's `capabilities` (`capability_configuration_mismatch`, 400). | Check which persona the capability actually belongs to. See [Capabilities](/connect/capabilities). |
A field this endpoint does not read is not rejected: the request still returns `201`, and the field silently takes its default. See [Accounts](/connect/accounts#create-an-account).
## Next steps
* [Onboard with a hosted link](/connect/guides/hosted-onboarding)
* [Onboard through the API](/connect/guides/api-onboarding)
* [Monitor onboarding](/connect/guides/monitor-onboarding)
* [Capabilities](/connect/capabilities)
* [Act on behalf of an account](/connect/acting-as-an-account)
# Onboard with a hosted link
Source: https://docs.bachs.io/connect/guides/hosted-onboarding
Send an account to a hosted flow that collects its requirements for you.
In this guide you'll create an account link, send the account to it, and confirm from a webhook that onboarding finished. By the end the account's capabilities are enabled and you have not built a form.
The flow only asks for what the account's requirements currently name, so a recipient-only account moves through it in far fewer steps than one that also accepts payments. See [Create an account](/connect/guides/create-an-account) for that choice.
## Before you start
* An account. See [Create an account](/connect/guides/create-an-account).
* A webhook endpoint with `event_source` set to `connect` or `all`. See [Connect events](/guides/webhooks/overview#connect-events).
* Two URLs on your site, one to return to and one to refresh from.
## Steps
Use `type: "onboarding"` for a new account, or `update` to collect more from one that is already live. Both `refresh_url` and `return_url` are required.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe/account-links \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"type": "onboarding",
"refresh_url": "https://example.com/connect/refresh",
"return_url": "https://example.com/connect/done"
}'
```
```json Response theme={"dark"}
{
"id": "alnk_5e2c7b09a134f6c1d820",
"object": "connected_account_link",
"account": "acct_3Wq8ZfT1yHnJ5sVe",
"type": "onboarding",
"created": "2026-08-07T09:20:00.000Z",
"expires_at": "2026-08-07T10:20:00.000Z",
"url": "https://connect.bachs.io/setup/c/acct_3Wq8ZfT1yHnJ5sVe/al_p1n8QvZ7kD3xR2yL5mHtNqF9jAbW4es",
"previous_link_superseded": false
}
```
`id` identifies the link object itself; the credential that makes the URL work is the opaque token embedded in `url`'s last path segment, and it is single-use. Do not construct this URL yourself or persist it past its `expires_at`.
Creating a link invalidates the previous one, reported as `previous_link_superseded`. Create a link when the account is about to open it, not on every page render, or the link you emailed yesterday stops working.
Redirect the account holder to `url`, or email it to them. The flow asks for whatever is currently due and nothing more.
An expired or already-used link sends the account to `refresh_url`. Your handler there creates a new link and redirects again:
```bash theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe/account-links \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"type": "onboarding",
"refresh_url": "https://example.com/connect/refresh",
"return_url": "https://example.com/connect/done"
}'
```
There is no event that says "the hosted flow finished." The account lands on `return_url` when it finishes, abandons halfway, or closes the tab without either, so treat that redirect as a cue to show a status screen, not as confirmation of anything. Confirm from the account's own events instead.
```json account.updated theme={"dark"}
{
"type": "account.updated",
"organization_id": "acct_3Wq8ZfT1yHnJ5sVe",
"account": "acct_3Wq8ZfT1yHnJ5sVe",
"data": {
"account": "acct_3Wq8ZfT1yHnJ5sVe",
"outstanding": []
}
}
```
The return redirect is lost entirely if the browser never comes back, since nothing else fires it. Confirm from [account.updated](/guides/webhooks/events/account-updated) and [capability.updated](/guides/webhooks/events/capability-updated), which fire regardless of whether the browser returns.
## What happens next
An `account.updated` event with an empty `outstanding` means nothing is left for the account to provide, not that the account can transact. Wait for `capability.updated` with `status: "active"` before you unlock anything. See [Monitor onboarding](/connect/guides/monitor-onboarding).
## Errors
Account link endpoints return the standard [error envelope](/errors).
| Cause | Resolution |
| - | - |
| The account has no requirements yet, on `type: "update"` (`CONNECTED_ACCOUNT_REQUIREMENTS_NOT_FOUND`, 400). | Use `type: "onboarding"` for an account that has not requested anything yet. |
| The account is suspended or rejected (`CONNECTED_ACCOUNT_NOT_ELIGIBLE_FOR_LINK`, 400). | The account cannot be sent through onboarding until that is resolved. |
| Your platform is itself suspended (`CONNECTED_ACCOUNT_PLATFORM_SUSPENDED`, 403). | Resolve your own platform's standing before creating links for accounts under it. |
## Next steps
* [Monitor onboarding](/connect/guides/monitor-onboarding)
* [Onboard through the API](/connect/guides/api-onboarding)
* [Requirements](/connect/requirements)
# Verify an account's identity
Source: https://docs.bachs.io/connect/guides/identity-verification
Submit an account's representative as a person, attach their ID, and read the verification result.
An account's identity is carried by its **people**: the representative, and any owners or directors. You submit a person's details and their ID document, and a reviewer verifies them. This guide creates and reads the representative, attaches an ID, and reads the outcome, all through the [persons](/connect/accounts#persons) subresource of the account.
There is no separate identity endpoint or hosted verification session to drive. A person is created, given an ID document, and verified on review, and you learn the result from the account's requirements and a webhook.
## Before you start
* An account. See [Create an account](/connect/guides/create-an-account).
* An API key with `connected_accounts:read` and `connected_accounts:write`.
## Steps
A returning account holder should confirm rather than re-type. List the account's people and find the one whose `relationship.representative` is `true`. An empty list means no representative has been created yet.
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe/persons \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
```json Response theme={"dark"}
{
"items": [
{
"id": "per_3a91c0d7f6e2b8149a05",
"first_name": "Ada",
"last_name": "Obi",
"dob": "1990-04-12",
"phone": "+2348012345678",
"email": "ada@example.com",
"id_number_provided": true,
"relationship": { "representative": true, "owner": true, "director": false },
"verification": { "status": "pending", "document_provided": false },
"created_at": "2026-08-07T09:20:00.000Z",
"updated_at": "2026-08-07T09:20:00.000Z"
}
],
"total": 1,
"limit": 20,
"offset": 0
}
```
`verification.status` is the person's own state: `pending` until a reviewer decides, then `passed` or `failed`. `id_number_provided` and `document_provided` tell you what has been supplied without echoing the number or the file. Read one person on its own at `GET /v1/accounts/{account_id}/persons/{person_id}`.
Submit the person and flag them as the representative. Identifiers go in `id_numbers`, each naming its own scheme under `type` (`nin`, `bvn`, `passport`, a driver's licence) and carrying its own `issuing_country`. The same call updates an existing person: address it by id at `POST /v1/accounts/{account_id}/persons/{person_id}` and only the fields you send change.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe/persons \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"first_name": "Ada",
"last_name": "Obi",
"dob": "1990-04-12",
"email": "ada@example.com",
"phone": "+2348012345678",
"relationship": { "representative": true, "owner": true, "percent_ownership": 100 },
"id_numbers": [
{ "type": "nin", "value": "12345678901", "issuing_country": "NG" }
]
}'
```
```json Response theme={"dark"}
{
"id": "per_3a91c0d7f6e2b8149a05",
"first_name": "Ada",
"last_name": "Obi",
"id_number_provided": true,
"relationship": { "representative": true, "owner": true, "director": false },
"verification": { "status": "pending", "document_provided": false },
"created_at": "2026-08-07T09:20:00.000Z",
"updated_at": "2026-08-07T09:20:00.000Z"
}
```
Copy the person `id` (`per_...`). One human can hold several roles at once: a founder is commonly representative, owner and director, so `relationship` is a set of flags rather than separate people.
A document is a file plus a reference to it. Upload the file first, then attach it, so a mis-filed document is re-attached rather than re-uploaded and one file can satisfy two slots without being sent twice.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/utilities/uploads \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-F "file=@ada-nin.jpg" \
-F "scope=identity_document"
```
```json Response theme={"dark"}
{
"upload_id": "upl_7c2f9a10bd4e",
"file_name": "ada-nin.jpg",
"mime_type": "image/jpeg",
"file_size_bytes": 184320,
"created_at": "2026-08-07T09:22:00.000Z"
}
```
The upload returns an `upload_id` and nothing about a person yet. See [Upstream](/api-reference/overview) for the upload's full field reference. The file is not readable back through your API key: an attached ID is retrievable only through the admin review surface.
Point one of the person's document slots at the uploaded file. `document` is `primary_verification` for a government ID or `secondary_verification` for address evidence. A two-sided card is two files, each attached with its own `side`.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe/persons/per_3a91c0d7f6e2b8149a05/documents \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"file": "upl_7c2f9a10bd4e",
"document": "primary_verification",
"side": "front"
}'
```
```json Response theme={"dark"}
{
"id": "doc_5f0339fac1ae",
"person": "per_3a91c0d7f6e2b8149a05",
"document_type": "primary_verification",
"file_name": "ada-nin.jpg",
"uploaded_at": "2026-08-07T09:23:00.000Z"
}
```
The person's `verification.document_provided` is now `true`. Attaching a document does not verify it: a reviewer accepting it is what moves `verification.status` to `passed`.
A person's identity state lives on the person and on the account's requirements, not a separate status resource. Re-read the person for its own state, or read the account for the whole picture at once.
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe/persons/per_3a91c0d7f6e2b8149a05 \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
```json Response theme={"dark"}
{
"id": "per_3a91c0d7f6e2b8149a05",
"id_number_provided": true,
"relationship": { "representative": true, "owner": true, "director": false },
"verification": { "status": "passed", "document_provided": true, "failure_reason": null },
"updated_at": "2026-08-07T10:31:12.000Z"
}
```
| `verification.status` | Meaning |
| - | - |
| `pending` | Submitted, and a decision is owed. |
| `passed` | Verified. |
| `failed` | Did not pass. Read `failure_reason`. |
For the account-wide view, read `GET /v1/accounts/{account_id}`: its `requirements.entries[]` show which identity fields are still owed, and `persons[]` carries each person's `verification` block. See [Requirements](/connect/requirements).
## What happens next
A verified representative satisfies the identity fields on the account's requirements. Watch `requirements.entries[]` move, and wait for `capability.updated` before unlocking anything: a person passing is not the same as a capability being enabled. See [Monitor onboarding](/connect/guides/monitor-onboarding).
## Next steps
* [Onboard through the API](/connect/guides/api-onboarding)
* [Requirements](/connect/requirements)
# Monitor onboarding
Source: https://docs.bachs.io/connect/guides/monitor-onboarding
React to an account becoming able to transact, without polling.
In this guide you'll subscribe to your accounts' events and unlock features when a capability is enabled. By the end onboarding finishes on its own and your application finds out about it.
A capability is enabled by a reviewer on no fixed schedule, so polling is either too frequent to be useful or too slow to be timely. Subscribe instead.
A recipient-only account holds fewer capabilities, so it fires fewer of these events over its life than one that also accepts payments. The events themselves work the same way regardless of which capabilities an account holds.
## Before you start
* A publicly reachable HTTPS endpoint. See [Setting up webhooks](/guides/webhooks/overview).
* An API key with `webhooks:write`.
## Steps
Set `event_source` so the endpoint receives your accounts' events. Without it, an endpoint receives only your own account's events.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/webhooks/endpoints \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"name": "Connect onboarding",
"url": "https://example.com/webhooks/connect",
"event_source": "connect",
"event_types": ["account.updated", "capability.updated"]
}'
```
```json Response theme={"dark"}
{
"endpoint_id": "whe_6a2f81c07b39d420f915",
"name": "Connect onboarding",
"url": "https://example.com/webhooks/connect",
"enabled": true,
"event_types": ["account.updated", "capability.updated"],
"event_source": "connect",
"signing_secret": "whsec_5f2a...",
"created_at": "2026-08-07T09:20:00.000Z",
"updated_at": "2026-08-07T09:20:00.000Z"
}
```
`signing_secret` is returned once, on creation. Store it to verify deliveries; it is not returned on a later read of the endpoint. Use `all` on a single endpoint if you would rather handle your own events and your accounts' events in one place.
The event tells you the account's requirement state changed, and `data.outstanding` lists the field keys still being asked for. That list alone answers "is there anything left for this account to do".
```json account.updated theme={"dark"}
{
"type": "account.updated",
"organization_id": "acct_3Wq8ZfT1yHnJ5sVe",
"account": "acct_3Wq8ZfT1yHnJ5sVe",
"data": {
"account": "acct_3Wq8ZfT1yHnJ5sVe",
"outstanding": ["persons.per_3a91c0d7.id_document"]
}
}
```
Read the account id from `account`, or from `organization_id` when `account` is absent. On a Connect event `organization_id` is the account, not your platform.
The event carries only the keys. To label them, say which are your problem, and say which are waiting on us, read the account and use its `requirements` block:
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
```json Response theme={"dark"}
{
"id": "acct_3Wq8ZfT1yHnJ5sVe",
"requirements": {
"currently_due": [],
"eventually_due": [],
"past_due": [],
"pending_verification": ["persons.per_3a91c0d7.id_document"],
"errors": [],
"entries": [
{
"field": "persons.per_3a91c0d7.id_document",
"status": "pending_verification",
"restricts_capabilities": ["payouts"],
"resolution": "review",
"deadline": null,
"errors": []
}
],
"current_deadline": null
}
}
```
Each entry's `resolution` is the one that decides what your screen offers: `api` means the value is yours to send, so show an input. `review` means it is already with us, so show a wait state and no input, because re-sending it changes nothing.
That gives you the screen states below. Check them in this order and show the first one that matches:
| Condition | What the screen shows |
| - | - |
| `requirements.errors` is not empty | "Fix these", one row per error with its `reason`, which is written for the account holder to read. Each of these fields is also back in `currently_due`. |
| `past_due` or `currently_due` is not empty | "Provide these", one input per field. Label each with `restricts_capabilities` so the account holder sees what it unlocks, and show `current_deadline` as a banner when it is set. |
| Everything is empty except `pending_verification` | "We are checking your details", no inputs. There is nothing for the account holder to do. |
| `eventually_due` is not empty and nothing else is | "Nothing to do now", optionally listing what will be asked for later. |
| Every bucket is empty | "Submitted", not "you can start selling". See the warning below. |
Re-read the account on every `account.updated` rather than tracking state yourself. Requirements are recomputed on each read, so the account is the truth and the event is the trigger.
This is the event that says an account can do something.
```json capability.updated theme={"dark"}
{
"type": "capability.updated",
"organization_id": "acct_3Wq8ZfT1yHnJ5sVe",
"account": "acct_3Wq8ZfT1yHnJ5sVe",
"data": {
"account": "acct_3Wq8ZfT1yHnJ5sVe",
"capability": "payouts",
"status": "active",
"requested": true
}
}
```
Gate on `status == "active"` for the specific capability you need. Every other value denies the action.
If your endpoint was down, read the current state rather than replaying assumptions:
```bash theme={"dark"}
curl https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe/capabilities \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
You can also re-deliver past events. See [Replay events](/guides/webhooks/replay-events).
An empty `outstanding`, or an account whose requirement buckets are all empty, means nothing is outstanding, not that a capability is on. Unlocking features on `account.updated` will let an account act before it has been enabled.
## What happens next
A capability can change more than once over an account's life, including back to `restricted`. Handle `capability.updated` as a state change every time rather than a one-off unlock, and re-check before actions that move money.
## Errors
Endpoint creation returns the standard [error envelope](/errors).
| Cause | Resolution |
| - | - |
| `event_types` is empty, or names an event type Bachs does not emit (400). | Send at least one valid type. `account.updated` and `capability.updated` are the two this guide needs. |
## Next steps
* [Capabilities](/connect/capabilities)
* [capability.updated](/guides/webhooks/events/capability-updated)
* [Split payments](/connect/split-payments)
# Accept a payment for a seller
Source: https://docs.bachs.io/connect/marketplaces/accept-a-payment
Collect a sale as your platform and pay the seller its share.
In this guide you'll create a checkout that belongs to your platform, name a seller account as the destination, send the customer to it, and confirm the payment from a webhook. By the end the charge sits in your platform's balance and the seller's share has moved to its own.
This is a [destination charge](/connect/split-payments/destination). Your platform is the merchant of record, which is what a marketplace's customer expects: they bought from the marketplace, not from the seller directly. See [Choose your integration](/connect/choose-your-integration) if that does not describe your case.
## Before you start
* A seller account. It does not need a payment-accepting capability on this shape, and does not need `payouts` active until it withdraws. See [Create an account](/connect/guides/create-an-account).
* A product to sell. See [Products](/guides/products/overview).
* Your own `card_collection` (or whichever payment method you offer) active on your platform, since your platform is the one being checked, not the seller. See [Capabilities](/connect/capabilities).
## Steps
Do not send `X-Account-Id`. Name the seller in `transfer_data.destination` and set `platform_fee`: the seller receives what is left of the sale after it, so a destination charge with no fee leaves your platform nothing to pay Bachs's processing fee from.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"product_cart": [
{ "product_id": "prod_8f2a71c4e05b", "quantity": 1 }
],
"platform_fee": "2000.00",
"transfer_data": { "destination": "acct_3Wq8ZfT1yHnJ5sVe" },
"customer": { "email": "jane@example.com", "name": "Jane Doe" },
"success_url": "https://example.com/thank-you",
"cancel_url": "https://example.com/checkout"
}'
```
```json Response theme={"dark"}
{
"checkout_id": "chk_c48e2a917d3f",
"status": "open",
"source_type": "CHECKOUT_SESSION",
"amount": "19000.00",
"currency": "NGN",
"checkout_url": "https://checkout.bachs.io/c/Qm3vXk9Nf2LpR7c",
"platform_fee": "2000.00"
}
```
`checkout_id` names the checkout for your own records and for `GET` calls; `checkout_url` carries a separate token, not the id, so do not parse one out of the other.
Redirect them to `checkout_url`. Bachs hosts the payment page and collects the charge against your platform.
Bachs sends [checkout.completed](/guides/webhooks/events/checkout-completed) once the customer finishes. Check `data.payment_status`: `paid` means a charge was made. `organization_id` on the event is your platform, since the checkout was your platform's.
```json Event theme={"dark"}
{
"id": "evt_7f6e5d4c3b2a1908",
"type": "checkout.completed",
"created_at": "2026-08-12T09:15:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"checkout_id": "chk_c48e2a917d3f",
"status": "completed",
"mode": "payment",
"payment_status": "paid",
"amount": "19000.00",
"currency": "NGN",
"charge": {
"id": "ch_2f8a71c4e05b",
"amount": "19000.00",
"currency": "NGN",
"status": "succeeded"
},
"completed_at": "2026-08-12T09:15:00.000000+00:00"
}
}
```
No Connect event routing to worry about here: this event is your platform's own, so it arrives on any endpoint the same as any other event of yours. That is specific to this shape; a [direct charge](/connect/split-payments/direct)'s event originates with the account instead.
The seller's share settles as a transfer from your platform to the seller once the charge settles. List it with `kind=payout`.
```bash Request theme={"dark"}
curl "https://sandbox-api.bachs.io/v1/transfers?kind=payout&connected_account_id=acct_3Wq8ZfT1yHnJ5sVe" \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
```json Response theme={"dark"}
{
"items": [
{
"id": "tr_8c1e04a7b93f2d6540ab",
"source": "acct_7KpQ2mNv4XbR9dLc",
"destination": "acct_3Wq8ZfT1yHnJ5sVe",
"amount": "19000.00",
"currency": "NGN",
"status": "paid",
"metadata": {},
"kind": "payout",
"source_charge_id": "ch_2f8a71c4e05b",
"created_at": "2026-08-12T10:31:00.000Z"
}
],
"pagination": {
"next_cursor": null,
"prev_cursor": null,
"has_more": false,
"limit": 50,
"offset": 0,
"returned": 1,
"total": 1
}
}
```
`status` is `paid` once settlement has posted the movement, `pending` before it has. The transfer carries the full `"19000.00"` the customer paid, because you named your cut with `platform_fee`. Your `"2000.00"` settles separately, readable from `GET /v1/platform_fees`. If you instead fix the seller's share with `transfer_data.amount`, the transfer carries only that amount and your platform mints no separate fee record. See [Destination charges](/connect/split-payments/destination) for both forms.
## What happens next
The charge settles into your platform's `pending_balance`, then `available_balance`. The seller's share moves at the same time, into its own balance. From there the seller withdraws on its own `payouts` capability. See [Payouts](/connect/payouts).
## Next steps
* [Destination charges](/connect/split-payments/destination)
* [Platform fees](/connect/platform-fees)
* [Refunds and disputes](/connect/marketplaces/refunds-and-disputes)
* [Payouts](/connect/payouts)
* [Balances](/connect/balances)
# Marketplaces and SaaS platforms
Source: https://docs.bachs.io/connect/marketplaces/overview
Collect the sale as your platform, and pay each seller its share.
A marketplace's customer buys from the marketplace, not from any one seller. So the marketplace is the merchant of record: the charge lands in your platform's balance, and each seller account is a recipient rather than a payment-accepting merchant. See [Choose your integration](/connect/choose-your-integration) if you have not settled this yet, or if some of your sellers should collect their own charges instead.
This page maps the order to build in. Each step links to the page that teaches it; nothing here is restated.
***
## What you build, in order
A seller account here only ever needs `payouts`, to withdraw the share settlement gives it. It never needs a payment-accepting capability, so it never applies the `merchant` persona, and onboarding stays short. See [Create an account](/connect/guides/create-an-account).
Create the checkout as your platform and name the seller in `transfer_data.destination`. The charge is yours; the seller's share moves down to it on settlement. See [Accept a payment](/connect/marketplaces/accept-a-payment).
Your platform is the merchant of record, so a refund or a lost dispute debits your platform, not the seller. See [Refunds and disputes](/connect/marketplaces/refunds-and-disputes).
The seller's share settles into its own balance once the charge settles. From there it withdraws on its own `payouts` capability. See [Payouts](/connect/payouts).
***
## Money flow
```mermaid theme={"dark"}
flowchart LR
C["Customer"] --> P["Your platform balance"]
P -->|processing fee| Bachs["Bachs"]
P -->|seller's share| A["Seller balance"]
A --> B["Seller bank account"]
```
The charge is your platform's throughout. The seller's share is a consequence of settling that charge, not a separate sale it made.
***
## In this section
* [Accept a payment](/connect/marketplaces/accept-a-payment)
* [Refunds and disputes](/connect/marketplaces/refunds-and-disputes)
* [Destination charges](/connect/split-payments/destination)
* [Payouts](/connect/payouts)
# Refunds and disputes
Source: https://docs.bachs.io/connect/marketplaces/refunds-and-disputes
Who absorbs a reversal when your platform collected the charge, and what happens when your balance cannot cover it.
Your platform is the merchant of record on the shape a marketplace normally uses. A refund or a lost dispute reverses the charge against **your platform's balance**, not the seller's.
This page is written for the reader who is here because something already went wrong.
***
## Where the money comes from
```mermaid theme={"dark"}
flowchart LR
R["Refund or lost dispute"] --> A["Your available balance"]
A -->|not enough| P["Your pending balance"]
P -->|still not enough| D["Negative balance (the debt)"]
```
The reversal draws on your platform's `available_balance` first. If that does not cover it, the draw continues into `pending_balance`.
**A lost dispute can drive the balance negative, deliberately.** Anything still uncovered after both is debited from `available_balance` anyway, taking it negative: that negative balance is the debt, and it heals as your platform's own future settlement credits land. A refund cannot do this; an unfundable refund is refused instead. See [Disputes](/connect/disputes) for what a negative balance blocks in the meantime.
The seller keeps the share it already received. The payout transfer already sent to the seller is not reversed, so your platform bears the full amount, including what the seller was paid.
***
## Why this differs from the account collecting
Liability follows entirely from who was the merchant of record:
| | Who the customer paid | Who a reversal debits |
| - | - | - |
| Your platform collects | You | You |
| [The account collects](/connect/split-payments/direct) | The account | The account |
If the account collected instead, the reversal would be its own, and there would be nothing for you to recover. Here you collected, so you carry it, and recovering the seller's share back is a transfer you send yourself, off Bachs, that only succeeds while the seller still holds it. See [Disputes](/connect/disputes) for that recovery path in detail.
***
## What to do about it
**Watch your own balance, not the seller's.** The debt is your platform's, and it heals only from your own future settlement credits. See [Balances](/connect/balances).
**Do not transfer a seller's share the moment a charge settles, if you might need it back.** A share already transferred and withdrawn cannot be recovered with a transfer, which is refused before it takes the seller's balance below zero. Delaying the transfer gives you a window to reverse it instead. See [Split payments](/connect/split-payments) for the same timing logic applied generally.
**Watch for new disputes rather than waiting on one.** Your platform is notified by email when a dispute is raised, and again on a reminder cadence while it still needs a response, since your platform owns the charge. Poll [List Disputes](/api-reference/disputes/list-disputes) rather than relying on email alone.
***
## Related
* [Disputes](/connect/disputes)
* [Refunds](/connect/refunds)
* [Balances](/connect/balances)
* [Destination charges](/connect/split-payments/destination)
* [Choose your integration](/connect/choose-your-integration)
# How money moves
Source: https://docs.bachs.io/connect/money-movement
The paths money takes through a Connect integration, and whose balance holds it at each step.
Money enters through a charge, moves between balances through a transfer, and leaves through a withdrawal.
* Transfers and withdrawals draw on `available_balance` only.
* There is no path between two accounts. Moving value from one to another is two transfers through your platform.
***
## Enters
A charge lands in the balance of whichever account collected it.
* **You collect.** The charge is yours, and you transfer each account its share. See [Split payments](/connect/split-payments).
* **The account collects.** The charge is theirs. The account needs a payment-accepting capability, which lengthens its requirements.
See [Choose your integration](/connect/choose-your-integration).
***
## Settles
A charge arrives in `pending_balance` and moves to `available_balance` on settlement. Read `pending_settlements_by_day` for the date. See [Balances](/connect/balances).
A transfer sent before settlement fails with `INSUFFICIENT_BALANCE`. Trigger transfers on settlement, not on the charge succeeding.
***
## Moves
A [transfer](/connect/transfers) moves an amount between your `available_balance` and an account's, in either direction. It moves one currency and does not convert.
Tag related transfers with `transfer_group` to reconcile which charge funded which shares.
***
## Leaves
A withdrawal moves funds from `available_balance` to a bank account or wallet. Withdraw from an account by acting as it with `X-Account-Id`. It requires `payouts` active on the withdrawing account. See [Payouts](/connect/payouts).
An account's **first** payout is held for a one-time review, so it completes a few minutes later than the ones after it. This is per account and happens only until one payout has completed, then later payouts dispatch without the wait. See [Payouts](/connect/payouts#create-a-payout).
Do not treat a network or `5xx` error on a transfer or withdrawal as proof it was not created. Verify the current state before retrying, or retry with the same `Idempotency-Key`.
***
## Comes back
A refund debits the balance the charge landed in.
* If you collected, the refund debits your balance. Recover an already-transferred share with a transfer back, while the account still holds it.
* If the account collected, the refund debits theirs.
See [Split payments](/connect/split-payments).
***
## Related
* [Split payments](/connect/split-payments)
* [Balances](/connect/balances)
* [Transfers](/connect/transfers)
# Onboarding
Source: https://docs.bachs.io/connect/onboarding
Complete an account's requirements with a hosted link or through the API.
Onboarding completes an account's [Requirements](/connect/requirements) so its [capabilities](/connect/capabilities) can be enabled. There are two ways to do it. They collect the same information.
| | Hosted account link | API onboarding |
| - | - | - |
| What you build | A redirect | The full interface |
| New requirements appear automatically | Yes | No. You render them |
| Lifetime | Single use | Not applicable |
| Use it for | Most integrations | Onboarding inside your own product |
***
## Hosted account link
`POST /v1/accounts/{account_id}/account-links` · scope `connected_accounts:write` · [full field reference →](/api-reference/accounts/create-an-account-link)
```bash theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe/account-links \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"type": "onboarding",
"refresh_url": "https://example.com/connect/refresh",
"return_url": "https://example.com/connect/done"
}'
```
`type` is `onboarding` for a new account or `update` to collect more from an existing one. The account lands on `return_url` when it finishes or leaves, and on `refresh_url` when the link is no longer usable, where your handler creates a new link and redirects again.
Creating a link supersedes the previous one, reported as `previous_link_superseded`. A link generated on every page render invalidates the one you sent earlier. Create a link when the account is about to use it.
The return redirect does not confirm completion. It fires whether the account finished or abandoned the flow, and is lost if the browser never returns. Confirm from `account.updated` and `capability.updated`. See [Connect events](/guides/webhooks/overview).
***
## API onboarding
Read the account's requirements, render them, collect the values, and submit. Every endpoint is under `/v1/accounts/{account_id}/` and takes an API key. Documents are the exception: they are collected through an account link, in the hosted flow. See [Onboard through the API](/connect/guides/api-onboarding).
Requirements change as regulation changes. On the hosted link those changes appear on their own; here a new requirement is a field your interface does not render, and accounts stall. Budget for maintaining this.
***
## Finishing
A capability is enabled on review, not when a form is submitted. Subscribe to `capability.updated` and unlock the account's features when it arrives. See [Monitor onboarding](/connect/guides/monitor-onboarding).
***
## Related
* [Requirements](/connect/requirements)
* [Hosted onboarding](/connect/guides/hosted-onboarding)
* [Onboard through the API](/connect/guides/api-onboarding)
* [Capabilities](/connect/capabilities)
# Connect (Sub accounts)
Source: https://docs.bachs.io/connect/overview
Give each business or person who sells through you or gets paid by you a financial identity of their own, and move money between them.
**Connect (Sub accounts)** gives every business or person you work with a financial identity of their own, kept separate from yours but controlled by you. Each account holds its own balance, carries its own requirements, and has its own capabilities.
You create these accounts yourself, through the API. Nobody signs up for Bachs and there is no existing account to link. When an account has to provide information before a capability turns on, you send it a [hosted link](/connect/onboarding) or submit the requirements on its behalf.
The most common use is a split payment: the customer pays you once, and you pass each seller their share. See [Split payments](/connect/split-payments).
Upgrade your platform account to a registered business to use Connect in sandbox. Complete business compliance and get `connect` active in production before creating live accounts. See [Become a platform](/connect/become-a-platform).
***
## The parties
```mermaid theme={"dark"}
flowchart LR
P["Your platform the account you authenticate as"]
A["Account a seller, creator, or contractor"]
C["Customer"]
C -->|pays| A
C -->|pays| P
P <-->|transfers| A
P -->|creates and onboards| A
```
You authenticate with your own API key. To act as an account, send its id in the `X-Account-Id` header. See [Accounts](/connect/accounts).
***
## The five moving parts
**Accounts.** An account is a financial identity you create and own, holding money and permissions of its own. See [Accounts](/connect/accounts).
**Capabilities.** What an account can do with money, granted one at a time within the persona it has applied. See [Capabilities](/connect/capabilities).
**Requirements.** What an account provides before a capability is enabled. Requesting fewer capabilities means collecting less. See [Requirements](/connect/requirements).
**Balances.** Each account holds one balance per currency. See [Balances](/connect/balances).
**Money movement.** Money enters through a charge, moves between balances through a transfer, and leaves through a withdrawal. See [How money moves](/connect/money-movement).
***
## Build it in this order
See [Become a platform](/connect/become-a-platform).
You, or the account. See [Choose your integration](/connect/choose-your-integration).
Request only what the account needs. See [Create an account](/connect/guides/create-an-account).
Send the account a hosted link, or submit its requirements yourself. See [Onboarding](/connect/onboarding).
Collect, split, and withdraw. See [Split payments](/connect/split-payments).
Complete registered-business compliance, confirm `connect` is active in production, then switch to the live base URL and key. See [Take Connect live](/connect/go-live).
***
## Limits
* Sandbox grants do not establish live approval. Read each capability's status in the environment you are using and listen for `capability.updated`. See [Testing Connect](/connect/testing).
* Transfers move one currency only. Both balances must hold it.
* A transfer or withdrawal never takes a balance below zero; a lost dispute is the one exception. Once an account has withdrawn, you cannot pull those funds back. See [Disputes](/connect/disputes).
* Accounts cannot transfer to each other. Money moves between you and an account you own.
***
## In this section
* [Choose your integration](/connect/choose-your-integration)
* [Become a platform](/connect/become-a-platform)
* [Acting as an account](/connect/acting-as-an-account)
* [How money moves](/connect/money-movement)
* [Accounts](/connect/accounts)
* [Capabilities](/connect/capabilities)
* [Requirements](/connect/requirements)
* [Onboarding](/connect/onboarding)
* [Balances](/connect/balances)
* [Direct charges](/connect/split-payments/direct)
* [Destination charges](/connect/split-payments/destination)
* [Platform fees](/connect/platform-fees)
* [Processing fees](/connect/processing-fees)
* [Transfers](/connect/transfers)
* [Payouts](/connect/payouts)
* [Split payments](/connect/split-payments)
* [Marketplaces and SaaS platforms](/connect/marketplaces/overview)
* [Creator and contractor payouts](/connect/payout-networks)
* [Take Connect live](/connect/go-live)
# Creator and contractor payouts
Source: https://docs.bachs.io/connect/payout-networks
Pay a network of creators, affiliates, or contractors from money you collected centrally.
You collect all the money, then pay people out of your own balance. Creators, affiliates, drivers, contractors, referrers: the account exists to receive, never to sell.
There is often no charge behind any one payment at all: a payout run is your platform's own decision, not a reversal of a specific sale. Where that is your case, use [Paying an account without a charge](/connect/split-payments/separate-transfers) for the underlying transfer mechanics; this page is the recipient-network view of the same shape. If instead you are splitting a specific sale with someone who helped make it, start from [Split payments](/connect/split-payments) instead, since that decision is about a charge you may not have here.
***
## Create each recipient account
A recipient here never applies the `merchant` persona, because it never requests a capability that persona gates. That means no business or payment-method requirements, only the ones every `recipient` account has: itself and the people behind it, plus a destination to pay out to.
```json theme={"dark"}
{
"configuration": {
"recipient": {
"capabilities": {
"transfers": { "requested": true },
"payouts": { "requested": true }
}
}
}
}
```
`transfers` and `payouts` are both `recipient` capabilities: naming `recipient` as a key in `configuration` is what makes them requestable at all, in the same call. `transfers` is what lets the account receive a transfer sent to it on `POST /v1/transfers`; `payouts` is what lets it withdraw the balance afterward. Neither requires the `merchant` persona, so a recipient's requirements stay short. See [Create an account](/connect/guides/create-an-account) for the full request and response.
***
## Paying a batch
Send one transfer per recipient. There is no batch endpoint, and one call per recipient is what keeps a single failure from taking down a run.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/transfers \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: payrun_2026_08-acct_3Wq8ZfT1yHnJ5sVe" \
-d '{
"destination": "acct_3Wq8ZfT1yHnJ5sVe",
"amount": "48500.00",
"currency": "NGN",
"transfer_group": "payrun_2026_08",
"description": "August creator payout"
}'
```
```json Response theme={"dark"}
{
"id": "tr_8c1e04a7b93f2d6540ab",
"source": "acct_7KpQ2mNv4XbR9dLc",
"destination": "acct_3Wq8ZfT1yHnJ5sVe",
"amount": "48500.00",
"currency": "NGN",
"status": "paid",
"description": "August creator payout",
"metadata": {},
"transfer_group": "payrun_2026_08",
"kind": "manual",
"source_charge_id": null,
"created_at": "2026-08-12T10:31:00.000Z"
}
```
`kind` is `manual`: nothing settled this transfer out of a charge, you sent it directly. `source_charge_id` is `null` for the same reason.
Use the payout run as the `transfer_group` rather than a charge id, and derive the `Idempotency-Key` from the run and the recipient. A retried run then reconciles as one set and cannot pay anyone twice.
Transfers draw on `available_balance`. Check the balance before a run rather than discovering a shortfall partway through it, and remember each currency is funded separately. See [Balances](/connect/balances).
***
## After the run
Each transfer emits [transfer.created](/guides/webhooks/events/transfer-created). Recipients withdraw their own balances, which requires `payouts` active on the account. See [Payouts](/connect/payouts).
Recovering an overpayment is a transfer back, and only succeeds while the recipient still holds the balance:
```bash theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/transfers \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "X-Account-Id: acct_3Wq8ZfT1yHnJ5sVe" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: payrun_2026_08-acct_3Wq8ZfT1yHnJ5sVe-reverse" \
-d '{
"destination": "self",
"amount": "48500.00",
"currency": "NGN",
"transfer_group": "payrun_2026_08",
"description": "August creator payout, overpayment recovered"
}'
```
See [Transfers](/connect/transfers) for the full field reference and error table.
***
## Related
* [Paying an account without a charge](/connect/split-payments/separate-transfers)
* [Split payments](/connect/split-payments)
* [Transfers](/connect/transfers)
* [Payouts](/connect/payouts)
* [Capabilities](/connect/capabilities)
* [Create an account](/connect/guides/create-an-account)
# Payouts
Source: https://docs.bachs.io/connect/payouts
Move an account's balance out to a bank account, mobile money wallet, or crypto wallet.
A **payout** moves funds from an account's `available_balance` to that account holder's own bank account or wallet. It works the same from your platform balance and from an account's.
Creating a payout from an account requires `payouts` to be `active` on that account. Payouts are available to all Bachs users, subject to account verification and destination approval. See [Capabilities](/connect/capabilities) and [Supported currencies](/guides/payouts/global-payouts).
Payouts move money. Creating one debits the balance immediately. There is no cancellation endpoint. Verify the destination and amount before you send the request.
***
## Pay out as an account
Send the account id in `X-Account-Id`. The payout is created against that account's balance and its destinations.
```mermaid theme={"dark"}
flowchart LR
B["Account available balance"] -->|payout| D["Bank account, mobile money, or crypto wallet"]
```
`POST /v1/payouts/destinations` · scope `payouts:write` · [full field reference →](/api-reference/payouts/create-payout-destination)
`type` is `bank_account`, `mobile_money`, or `crypto_wallet`, and the fields depend on the type, currency and method. Send `type` explicitly when a currency supports both banks and mobile money. Local bank codes come from [List banks](/api-reference/reference/list-banks); international routing fields are covered in [Create a payout](/guides/payouts/payout-using-api). Nigerian bank details are resolved during registration, so a separate lookup is optional.
The first destination an account registers for a currency becomes that currency's **default** once it is usable, the destination [scheduled and instant payouts](/connect/balances) draw on. Registering a second destination does not change the default; set it explicitly if you want to switch.
Check `is_usable` in the registration response. USD bank accounts, mobile money and crypto wallets are automatically approved. Nigerian bank approval depends on account eligibility and successful resolution; other supported bank currencies need review in live mode. Sandbox destinations are approved automatically. If review is pending, poll [Get Destination](/api-reference/payouts/get-payout-destination) and continue when `is_usable` is `true`.
`POST /v1/payouts/quotes` · scope `payouts:write` · [full field reference →](/api-reference/payouts/create-payout-quote)
Required when `from_currency` and `to_currency` differ. The quote fixes the rate and expires, so create it immediately before the payout.
`POST /v1/payouts` · scope `payouts:write` · [full field reference →](/api-reference/payouts/create-payout)
Payouts are asynchronous. Subscribe to `payout.paid` and `payout.failed`, or poll [Get Payout](/api-reference/payouts/get-payout).
***
## Create a payout
```bash theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/payouts \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "X-Account-Id: acct_3Wq8ZfT1yHnJ5sVe" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: WD-20260807-0007" \
-d '{
"destination": "pd_7Kq2mNv4XbR9dLc0",
"amount": "7000.00",
"reference": "WD-20260807-0007"
}'
```
```json theme={"dark"}
{
"id": "pay_4Xr9dLc0mNv7Kq2B",
"status": "pending",
"amount": "7000.00",
"currency": "NGN",
"source_currency": "NGN",
"fee": "100.00",
"total_debited": "7100.00",
"destination": "pd_7Kq2mNv4XbR9dLc0",
"reference": "WD-20260807-0007",
"failure_reason": null,
"created_at": "2026-08-07T14:30:00.000Z"
}
```
`amount` is a decimal string, in the destination's currency, and is what the destination receives. The fee is charged on top, so the balance has to cover `total_debited`, not `amount`. `reference` is your own identifier and must be unique for the account. `destination` names a registered [payout destination](/api-reference/payouts/create-payout-destination), and the currency, rail and payment method all follow from it; a cross-currency payout passes `quote_id` instead of `amount`.
`status` starts at `pending`, the balance debit already applied, and moves to `processing` once the destination has been submitted. It ends at a terminal `completed` or `failed`. Each value is documented on the [Get Payout](/api-reference/payouts/get-payout) response.
**An account's first payout takes longer.** The first payout an account ever makes is held for a one-time review before it reaches a rail, so it can sit at `pending` for a few minutes longer than later ones. This is per account, not per destination: it is triggered by the account having no completed payout yet, so once one payout reaches `completed`, every later payout, including to a brand-new destination, dispatches without the extra wait. It applies to the accounts you create, including from the dashboard; a recipient-only account you register purely as a payee is not held. Track state from `payout.paid` and `payout.failed` rather than assuming a fixed delay.
A payout that fails at the destination is not always moved straight to `failed`. It can be held at `pending` for review instead, with the balance still debited, so `pending` does not always mean newly created. Track state from `payout.paid` and `payout.failed` rather than assuming a fixed delay.
Do not treat a network or `5xx` error as proof the payout was not created. Verify with [Get Payout](/api-reference/payouts/get-payout) before retrying, or retry with the same `Idempotency-Key`.
***
## Get an account able to pay out
An account needs `payouts` `active` before it can pay out, and a recipient-configuration account is the short path there: it only receives money, so it asks for a name and a destination and nothing else.
* Submit the account holder's **name** and the requirement blocking `transfers` clears, activating it.
* Submit a **payout destination whose account resolves at the bank** and it is approved by the system on the spot, clearing its requirement and activating `payouts`.
So an account with a real, resolvable bank account is live the moment you finish submitting, with no human in the loop. A destination that cannot be resolved waits for a reviewer instead: it sits `pending_review`, and `payouts` stays `restricted` until someone approves it.
Gate on the capability being `active`, never on an empty requirements list. An account can have nothing left to provide and still be unable to pay out, because its destination has not cleared. See [Capabilities](/connect/capabilities).
Both requirements go in one call:
```bash theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"fields": {
"persons": [
{ "first_name": "Ada", "last_name": "Obi", "relationship": { "representative": true } }
],
"payout_destination": {
"currency": "NGN",
"type": "bank_account",
"account_number": "0123456789",
"account_name": "ADA OKAFOR",
"bank_code": "058"
}
}
}'
```
A rejected submission saves nothing, not even its valid fields, so fix the field the error names and send the whole object again. See [Requirements](/connect/requirements).
For the full onboarding path, see [Create an account](/connect/guides/create-an-account). For dashboard and API instructions for creating a payout, see [Create a payout](/guides/payouts/payout-using-api).
***
## Payout destinations
A payout destination is where an account's money leaves to.
| Field | Notes |
| - | - |
| `type` | `bank_account`, `mobile_money`, or `crypto_wallet`. The fields you send depend on which, and it is inferred from the currency when omitted. |
| Approval | Approval depends on the destination and account. Some destinations need review (`pending_review`). Check `is_usable` before sending a payout. |
| Default | The first approved destination for a currency becomes that currency's default, the one scheduled payouts use. Set another as default explicitly to switch. |
| Currency | A destination holds one currency, and payouts route on it. |
An account can hold several destinations. Only one is the default per currency; the rest are addressable by id on a payout. You register them two ways, both ending at the same approved row: as a requirement field on the account, which is what activates `payouts` during onboarding, or directly with `POST /v1/payouts/destinations` once the account is live.
***
## Where the money comes from
A payout draws on `available_balance` on the account named in `X-Account-Id`, in the currency the destination is registered in, or, for a cross-currency payout, the source currency the quote names. In a [split payment](/connect/split-payments), that balance is the share you transferred, and it is available as soon as the transfer posts.
Once an account pays out its balance, you cannot recover it with a transfer. A payout never takes a balance below zero on its own, but a lost dispute can, and while any currency is negative every payout for that account is blocked, not only in that currency. See [Disputes](/connect/disputes). If you may need to reverse a share, transfer it later rather than earlier. See [Split payments](/connect/split-payments).
***
## Errors
Payouts return the standard [error envelope](/errors). Common cases:
* `FORBIDDEN` (403), the account's `payouts` capability is not `active`, or the key lacks `payouts:write`. See [Capabilities](/connect/capabilities).
* `PAYOUT_CURRENCY_NOT_ENABLED` (400), the requested route is disabled for this account. If this occurs for a supported route, contact support with the error details. See [Supported currencies](/guides/payouts/global-payouts).
* `DESTINATION_NOT_FOUND` (404), no destination with that id belongs to this account.
* `DESTINATION_PENDING_REVIEW` (400), the destination has not cleared review yet. Wait for `is_usable`.
* `DESTINATION_REJECTED` (400), the destination was rejected in review and never becomes usable. Register a new one.
* `ORGANIZATION_IN_DEBT` (400), the account has a negative balance in some currency, even one other than `from_currency`. Every payout is blocked until that currency's balance clears. See [Disputes](/connect/disputes).
* `INSUFFICIENT_BALANCE` (400), `available_balance` will not cover `amount` plus the fee. The response states the shortfall. Check the balance and the settlement date. See [Balances](/connect/balances).
* `QUOTE_REQUIRED` (400), the destination's currency differs from the balance being debited, so a `quote_id` is required.
* `QUOTE_EXPIRED` (400), the quote has lapsed. Create a new one and retry immediately.
* `VALIDATION_ERROR` (422), a field failed validation. Inspect `errors[]`.
* `IDEMPOTENCY_IN_PROGRESS` (409), a request with the same `Idempotency-Key` is still in flight. Retry after a short delay; the winner's response is replayed once it lands.
***
## Related
* [Split payments](/connect/split-payments)
* [Balances](/connect/balances)
* [Create a payout](/guides/payouts/payout-using-api)
* [Overview](/guides/payouts/overview)
* [Get Payout](/api-reference/payouts/get-payout)
# Platform fees
Source: https://docs.bachs.io/connect/platform-fees
How a platform fee is set, the bounds it must fall within, and when the platform's cut becomes available.
## What a platform fee is
A **platform fee** is your cut of a sale, taken from the account's proceeds. It comes out of what the account would otherwise keep, not out of Bachs's processing fee. Those are two different amounts leaving the charge for two different reasons, and conflating them will get your accounting wrong.
***
## Setting one
Send `platform_fee` on create-checkout, as an amount in the sale's base currency, the currency the price itself is set in:
```bash theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-H "X-Account-Id: acct_3Wq8ZfT1yHnJ5sVe" \
-d '{
"product_cart": [
{
"product_id": "prod_8f2a71c4e05b",
"quantity": 1,
"pricing": { "price_type": "fixed", "amount": "100000.00" }
}
],
"platform_fee": "20000.00",
"customer": { "email": "jane@example.com" },
"success_url": "https://example.com/thank-you",
"cancel_url": "https://example.com/checkout"
}'
```
`platform_fee` is always an amount, never a percentage. A percentage is pricing policy you already own somewhere; restating it on every checkout request would give it a second place to drift out of sync with the first. Send the amount you've already computed, in the product's base currency, even if the customer ends up paying in a different one. See [Currency](#currency) below.
`platform_fee` is available on both create-checkout surfaces: acting as the account with `X-Account-Id` (a [direct charge](/connect/split-payments/direct)) and naming the account in `transfer_data.destination` (a [destination charge](/connect/split-payments/destination)).
***
## Two ways to state the split
`platform_fee` says what you keep. There's a second field, `transfer_data.amount`, that says the opposite: what the account receives. Send exactly one. Sending both, or neither on a destination charge, is rejected before the customer pays.
| Field | States | Where |
| - | - | - |
| `platform_fee` | What your platform keeps | Top level, beside `transfer_data` |
| `transfer_data.amount` | What the account receives | Nested under `transfer_data`, alongside `destination` |
Both are amounts in the sale's base currency. Sending both is a `VALIDATION_ERROR` (400): they are two descriptions of the same split, and nothing about the request says which one should win.
The two forms are not interchangeable bookkeeping. Which one you send changes what the account is paid and what you can see afterward:
* **Lead with `platform_fee` (fee-first).** You state your cut; the account gets the rest. This is a [`PlatformFee`](#reading-it-back) record, readable at `GET /v1/platform_fees`. On a destination charge, the transfer that pays the account out carries the sale's full gross amount alongside that record, so the account's own read of the transfer shows the sale and your cut side by side.
* **Lead with `transfer_data.amount` (share-first).** You state what the account gets, and your platform keeps the rest of the sale. No `PlatformFee` record is created, because you never stated a cut, only a payout. The transfer that pays the account carries exactly that amount, its net, and nothing else. Read [Who pays the processing fee](#who-pays-the-processing-fee) before you set this close to the full sale: the amount you name is what the account receives in almost every case, but not in the one where the sale cannot fund our fee.
Pick fee-first when you need a queryable record of what you earned on every sale. Pick share-first when you only care that the account is paid a fixed amount and you are willing to let your own take float with it.
On a direct charge, use `platform_fee` only. There is no seller balance for `transfer_data.amount` to name a share of: the account is the merchant, and what it keeps is the gross minus your fee.
***
## What bounds it
All of these return the standard [error envelope](/errors) with status 400.
| Rule | Error |
| - | - |
| `platform_fee` must be greater than zero. | `INVALID_PLATFORM_FEE` |
| `platform_fee` must be less than the gross amount, so the account is left a share of the sale. | `INVALID_PLATFORM_FEE` |
| `transfer_data.amount` must be greater than zero. | `INVALID_PLATFORM_FEE` |
| `transfer_data.amount` must be less than the gross amount, so your platform keeps a share of the sale. | `INVALID_PLATFORM_FEE` |
| `platform_fee` and `transfer_data.amount` cannot both be sent. | `VALIDATION_ERROR` |
| A destination charge requires one of `platform_fee` or `transfer_data.amount`. With neither, there is nothing to say what the account is owed. | `INVALID_PLATFORM_FEE` |
| `X-Account-Id` and `transfer_data` cannot both be sent. The header alone makes the sale the account's; naming a destination makes it yours, and a request cannot be both at once. | `CONTRADICTORY_CHARGE_TYPE` |
| Either term must be at least one minor unit of the sale's base currency. `"0.001"` on a USD sale rounds to nothing, so it is refused rather than stored as a fee of zero. | `INVALID_PLATFORM_FEE` |
| `transfer_data` accepts `destination` and `amount` and nothing else. A key it does not recognise is refused rather than ignored, so a misspelling cannot look like a split you never sent. | `VALIDATION_ERROR` |
| `platform_fee` inside `transfer_data`. It is a top-level field; the error says so. | `VALIDATION_ERROR` |
***
## Currency
`platform_fee` and `transfer_data.amount` are always base-currency amounts, the currency the sale was priced in. The split is struck in that same currency, even when the customer pays in something else and the charge converts on its way to settlement. A platform fee on a converting charge settles correctly: it doesn't need the account to hold the payment currency, only its own base currency.
Only USD is held by default. Any other currency has to be enabled on an account before it can receive money in it. See [Balances](/connect/balances).
### Precision
Both terms are held to the precision of the currency they are stated in: two decimal places on `USD` or `NGN`, six on a stablecoin rail. A value carrying more than that is rounded half-up to the nearest minor unit when the checkout is created, and the rounded value is what the response echoes, what settles, and what the `PlatformFee` record keeps.
So a percentage you compute yourself is worth rounding before you send it. `platform_fee: "33.337"` on a `USD` sale is stored and paid as `"33.34"`, and the account's share is the rest of the sale after that rounded figure. A value that rounds to zero is refused rather than treated as a fee of nothing.
***
## Who pays the processing fee
Bachs charges a processing fee on every sale. On a destination charge the sale
is yours, so that fee is yours to pay, and it is taken in this order.
1. **Your cut of the sale.** Whatever you kept, whether you stated it as
`platform_fee` or left it as the remainder after a `transfer_data.amount`.
2. **Your platform's available balance.** If your cut on this sale is too small
to cover the fee, the rest comes out of your balance: money from your other
sales, not this one. Your balance has to cover the whole remainder for this
step to apply. If it cannot cover all of it, none of it is taken from there.
3. **The account's share.** Only when your cut and your balance together cannot
pay the fee does the remainder come out of what the account receives.
The third step is the one to plan around, because it is the only case where an
account is paid less than the split you described. If you named
`transfer_data.amount`, that is also the only case where the account receives
less than the amount you named.
When the customer bears the processing fee, none of this applies. The customer
paid the fee on top of the price, so it is already funded and neither your cut
nor the account's share is touched by it.
***
## When you receive it
On a direct charge, your cut becomes available on the account's settlement schedule, never sooner. Settlement is what turns a charge into money either party can actually move; crediting your fee before that point would let you spend money that hasn't cleared yet.
On a destination charge, whatever your platform keeps is never transferred at all: it is already part of your platform's own balance when the charge settles, since the charge was yours to begin with. Only the account's share moves, as a [transfer](/connect/transfers) to the account.
***
## Reading it back
The checkout and payment objects echo back whichever term you sent: `platform_fee` on a fee-first split, `destination_amount` on a share-first one. The one you didn't send is `null`.
Both fields are **`null`**, not `"0.00"`, when the sale carries no split at all. Most charges carry neither. The fields are always on the wire; test whether the value is null rather than checking whether the field exists, and don't assume a typed client that expects a string will accept a null the same way it accepts a zero one.
A fee-first split also mints a **platform fee** record: a standalone object naming the charge it came from, the amount, and both parties. This is the queryable, permanent record of your cut. A share-first split never mints one, since you never stated a cut, only a payout. There, read what the account was paid straight off the [transfer](/connect/transfers) or the payment's `destination_amount`.
Get one by id, or list every fee your platform was a party to:
```bash theme={"dark"}
curl "https://sandbox-api.bachs.io/v1/platform_fees?charge=ch_2f8a71c4e05b" \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
```json theme={"dark"}
{
"items": [
{
"id": "pf_8c1e04a7b93f2d6540ab1234",
"charge": "ch_2f8a71c4e05b",
"collected_from": "acct_7KpQ2mNv4XbR9dLc",
"earned_by": "acct_3Wq8ZfT1yHnJ5sVe",
"amount": "20000.00",
"currency": "NGN",
"amount_refunded": "0.00",
"refunded": false,
"created_at": "2026-08-12T10:31:00.000Z"
}
],
"pagination": {
"next_cursor": null,
"prev_cursor": null,
"has_more": false,
"limit": 50,
"offset": 0,
"returned": 1,
"total": 1
}
}
```
`GET /v1/platform_fees` · `GET /v1/platform_fees/{fee_id}` · scope `transfers:read`
`collected_from` is the account the cut came out of, on either shape. `earned_by` is your platform. Both the account and your platform can read a fee they were party to; filter the list with `charge` to see the one fee tied to a specific sale.
This replaces reading a direct charge's fee as a `kind=platform_fee` transfer. That transfer kind is retired: `platform_fee` is not a movement between two balances, it is money your platform keeps, and `GET /v1/transfers?kind=platform_fee` now returns `400`.
***
## What it does not do
A platform fee is not reversed on a refund. The refund debits whichever party owns the charge: the account on a direct charge, your platform on a destination charge. Bachs's own takes do not reverse either.
On a direct charge, work out the consequence before an account asks you about it: the account is debited the full amount the customer paid, but it only ever received gross minus your platform fee. On a 20% platform fee, that leaves the account 20% short on every refunded sale.
On a destination charge, the refund debits your platform instead, and your platform keeps its cut. The payout transfer already sent to the account is not reversed: your platform bears the full refund, and the account keeps the share it received. See [Refunds](/connect/refunds) for the fuller treatment.
***
## Next steps
* [Split payments](/connect/split-payments)
* [Direct charges](/connect/split-payments/direct)
* [Destination charges](/connect/split-payments/destination)
* [Balances](/connect/balances)
* [Refunds](/connect/refunds)
# Processing fees
Source: https://docs.bachs.io/connect/processing-fees
Who bears the processing fee on a charge, who collects it, and how to tell which happened after the fact.
## What the fee is
There is no single rate. The processing fee on a charge depends on the payment
method and the currency the customer paid in, and it can be set per account, so
two accounts on the same platform taking the same payment can be charged
differently. Some corridors are a percentage, some add a fixed amount on top,
and some cap the percentage.
Because of that, the fee is something you read rather than something you
compute. Every payment reports the fee that was actually applied to it:
* `fees.amount` and `fees.currency` on [Retrieve a payment](/api-reference/payments/get-payment)
* `fee` on [List payments](/api-reference/payments/list-payments)
If you need the rates that apply to your own account before you take a payment,
see [Fees](/for-you/fees) for the standard rates or ask your Bachs contact about
account-specific pricing. An NGN virtual account deposit uses its own rate of
1%, capped at NGN 300. Sizing a [platform fee](/connect/platform-fees) against
a guess is the mistake this section exists to prevent: on a destination charge,
your cut is what our fee comes out of first.
## Two separate questions
Every charge carries a processing fee, and two independent questions decide what happens to it:
* **Who bears it.** Whether the account absorbs the fee or it is added on top for the customer.
* **Who collects it.** Whether Bachs takes the fee out of the charge, or your platform pays it out of its own balance.
These do not interact. Bearing the fee decides whose money it is. Collecting the fee decides who takes it out of the charge. An account can absorb the fee while Bachs collects it, or absorb the fee while your platform collects it, or any other combination. Keep them separate when you reason about a payout, because the two settings answer different questions.
## Who bears the fee
An account has a `fee_handling` setting with two values: `account_pays_fee`, where the fee comes out of the account's own proceeds, and `customer_pays_fee`, where the fee is added on top and the customer pays it. Every checkout the account creates inherits this setting by default.
You set it on the account, either at creation or later, with `POST /v1/accounts/{account_id}`. The write field is `fee_preference`, with values `org_pays` (the account bears the fee, stored as `account_pays_fee`) and `customer_pays` (stored as `customer_pays_fee`). The read exposes it as `fee_handling`; the write takes `fee_preference`. Send `X-Account-Id` to set it on a connected account, or call it as the account itself.
To override it for a single checkout, send `customer_bears_fee` on `POST /v1/checkout-sessions`: `true` charges the customer the fee on top for that checkout, `false` has the account absorb it, whatever the account's default is. Omit it to inherit `fee_handling`.
```bash theme={"dark"}
curl -X POST https://api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer sk_live_xxx" \
-H "X-Account-Id: acct_3Wq8ZfT1yHnJ5sVe" \
-H "Content-Type: application/json" \
-d '{
"customer": { "email": "buyer@example.com" },
"pricing": { "currency": "NGN", "name": "One item", "amount": "1000.00" },
"customer_bears_fee": true,
"success_url": "https://example.com/s",
"cancel_url": "https://example.com/c"
}'
```
```bash theme={"dark"}
curl -X POST https://api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "fee_preference": "org_pays" }'
```
On a direct charge, it is the account's own `fee_handling` that applies, since the charge is the account's. On a [destination charge](/connect/split-payments/destination), it is your platform's `fee_handling` that applies instead, since your platform is the merchant of record for that charge. So to make the platform absorb the fee on your destination charges, set `fee_preference: org_pays` on your platform account.
What follows is a different setting entirely.
## Who collects the fee
Separately from who bears the fee, each account has a `fees_collector` setting with two values: `bachs`, the default, and `platform`. It is per account, not a setting on your platform itself: an account with no parent has no platform to absorb anything for it, whatever this setting would otherwise say.
You set it with `responsibilities.fees.collector` when you create that account, sent to `POST /v1/accounts`. It is returned on every read of the account, nested the same way: `responsibilities.fees.collector`.
`responsibilities.fees.collector` can only be set at creation. There is no update path for it: once an account exists, this setting is fixed for its lifetime. Changing fee posture mid-relationship would retroactively disagree with ledger history that has already settled under the old one.
With `fees_collector` set to `bachs`, the processing fee is taken out of the charge, the same way it is for any account with no platform. Whoever bears the fee, per the setting above, pays it there.
With `fees_collector` set to `platform`, your platform pays the fee on that account's charges out of its own balance instead of taking it from the charge. The account's payout is correspondingly larger: it keeps the full amount it would otherwise have had the fee deducted from, because your platform absorbed that cost on its behalf. Your platform is subsidising that account's processing costs.
Your platform paying the fee applies to direct charges only. On a [destination charge](/connect/split-payments/destination), the fee comes from the charge regardless of `fees_collector`, because the account being paid is the counterparty to that movement.
## The fallback
`fees_collector: platform` asks your platform's balance to cover the fee on every charge it applies to. That debit is guarded: if the balance cannot cover it, the debit fails and the fee is taken from the charge instead, the same as if `fees_collector` were `bachs`.
Settlement does not fail when the platform's balance can't cover the fee. The fee is recognized either way, from the platform's balance when there's enough, from the charge when there isn't, and settlement completes the same in both cases. This fallback happens silently: nothing on the charge or the checkout flags that it occurred. `fee_paid_by`, described next, is the only way to detect it after the fact.
## Knowing which happened
The payment carries `fee_paid_by`, with two values: `merchant`, meaning the fee came out of the charge, and `platform`, meaning your platform's balance covered it. It reports what actually happened when the charge settled, rather than what was configured beforehand, because the fallback means the outcome isn't known until then.
That timing is visible in the field. **`fee_paid_by` is `null` until the charge settles**, which on most rails is days after the customer paid. A charge reading `succeeded` has been paid; it has not necessarily settled, and until it does there is no answer to report. Poll it after the charge's settlement date, or read it when the payment's settlement details are populated, rather than treating `null` as a third outcome. It is available on both the payment and the payments list.
`fee_paid_by` is the only way an account can tell why two identical sales paid out differently. Two charges with the same `fees_collector` setting can still resolve to different values of `fee_paid_by` if the platform's balance covered one and not the other. On a destination charge, `fee_paid_by` never reads `platform`: the fee always comes from the charge there, for the same reason `fees_collector: platform` does not apply to that shape.
## The drain
A platform that creates accounts with `fees_collector` set to `platform` and takes no platform fee on their charges has configured a pure cost centre for those accounts. Every one of their charges debits the platform's own balance to cover the processing fee, and nothing about that arrangement puts money back in. The balance only goes down.
Once it empties, the fallback described above starts firing: charges continue to settle, but the fee shifts back to being taken from the charge, and `fee_paid_by` starts reading `merchant` where it previously read `platform`. Nothing announces the transition. A platform running this configuration has to watch its own balance to know when the subsidy stops.
## Next steps
* [Platform fees](/connect/platform-fees)
* [Split payments](/connect/split-payments)
* [Balances](/connect/balances)
* [Refunds](/connect/refunds)
# Connect quickstart
Source: https://docs.bachs.io/connect/quickstart
Create an account, onboard it, and run a split payment end to end in the sandbox.
In this guide you'll create an account, send it through onboarding, create a checkout that charges the account and takes your platform fee out of it, and read that fee back once the charge settles. By the end you'll have run a full direct charge without moving real money.
## Before you start
* A **sandbox API key** (`sk_sandbox_...`). See [Authentication](/authentication).
* The `connect` capability `active` on your account. See [Become a platform](/connect/become-a-platform).
* A product to sell. See [Products](/guides/products/overview).
* A webhook endpoint with `event_source` set to `connect` or `all`, so your platform receives an event that originates with the account. See [Connect events](/guides/webhooks/overview#connect-events).
## Steps
This account is going to be the merchant of record on a direct charge, so it needs a payment-accepting capability, and it will also be paid out, so it needs the `recipient` persona too. Name both as keys in `configuration`, and name each capability it needs under the persona it belongs to.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/accounts \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"contact_email": "ada@example.com",
"display_name": "Ada Stores",
"country": "NG",
"entity_type": "individual",
"configuration": {
"merchant": {
"capabilities": { "card_collection": { "requested": true } }
},
"recipient": {
"capabilities": {
"payouts": { "requested": true },
"transfers": { "requested": true }
}
}
}
}'
```
```json Response theme={"dark"}
{
"id": "acct_3Wq8ZfT1yHnJ5sVe",
"name": "Ada Stores",
"country": "NG",
"entity_type": "individual",
"capabilities": {
"card_collection": { "status": "active", "requested": true, "status_details": null },
"payouts": { "status": "active", "requested": true, "status_details": null },
"transfers": { "status": "active", "requested": true, "status_details": null }
},
"configuration": { "merchant": {}, "recipient": {} },
"responsibilities": { "fees": { "collector": "bachs" } },
"is_active": true,
"created_at": "2026-08-12T09:00:00.000000+00:00"
}
```
The response carries exactly the three capabilities named above. Keep the `id`. Every step below uses it.
In sandbox, a capability whose persona is applied is granted `active` at creation, with no review. `card_collection` is requested against `merchant`, named in the same call, so it comes back active immediately, unlike in production, where it lands `restricted` until a reviewer enables it. See [Testing](/connect/testing).
`card_collection` accepts cards charged in **USD**. To accept **Nigerian naira cards** as well, request `ngn_card_collection` alongside it — it is a separate capability, so this NG merchant would name both under `merchant.capabilities`. Requesting one does not enable the other. See [Capabilities](/connect/capabilities).
`card_collection` is already active, but a real account still needs its requirements satisfied to stay eligible, and this is the flow that collects them.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe/account-links \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"type": "onboarding",
"refresh_url": "https://example.com/connect/refresh",
"return_url": "https://example.com/connect/done"
}'
```
```json Response theme={"dark"}
{
"id": "alnk_5e2c7b09a134f8c91a20",
"object": "connected_account_link",
"account": "acct_3Wq8ZfT1yHnJ5sVe",
"type": "onboarding",
"created": "2026-08-12T09:00:05.000Z",
"expires_at": "2026-08-12T10:00:05.000Z",
"url": "https://connect.bachs.io/setup/c/acct_3Wq8ZfT1yHnJ5sVe/al_QwErTy12ZxCvBn34UiOp56AsDf78GhJk90Lm",
"previous_link_superseded": false
}
```
Open `url` and complete the flow. To build the interface yourself instead, see [Onboard through the API](/connect/guides/api-onboarding).
Confirm the state at any point rather than waiting on it, since in sandbox `card_collection` is already active from step 1:
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/accounts/acct_3Wq8ZfT1yHnJ5sVe/capabilities \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
```json Response theme={"dark"}
{
"items": [
{ "name": "card_collection", "status": "active", "requested": true, "status_details": null },
{ "name": "payouts", "status": "active", "requested": false, "status_details": null }
]
}
```
In production, the same create call leaves `card_collection` `restricted`, and enabling it on review fires `capability.updated`:
```json capability.updated theme={"dark"}
{
"type": "capability.updated",
"organization_id": "acct_3Wq8ZfT1yHnJ5sVe",
"data": {
"account": "acct_3Wq8ZfT1yHnJ5sVe",
"capability": "card_collection",
"status": "active",
"requested": true
}
}
```
Send `X-Account-Id` with the account's id. Its presence, on its own, is what makes this a direct charge: the account becomes the merchant of record, and the sale lands in its balance. Add `platform_fee` for your cut, in the base currency of the sale, taken from the account's proceeds.
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-H "X-Account-Id: acct_3Wq8ZfT1yHnJ5sVe" \
-d '{
"product_cart": [
{
"product_id": "prod_8f2a71c4e05b",
"quantity": 1,
"pricing": { "price_type": "fixed", "amount": "100000.00" }
}
],
"platform_fee": "20000.00",
"customer": { "email": "jane@example.com" },
"success_url": "https://example.com/thank-you",
"cancel_url": "https://example.com/checkout"
}'
```
```json Response theme={"dark"}
{
"checkout_id": "9f3a71c4-e05b-4b2a-91b4-0e6c7d2a91b4",
"status": "open",
"source_type": "CHECKOUT_SESSION",
"amount": "100000.00",
"currency": "NGN",
"checkout_url": "https://checkout.bachs.io/c/Xk7fQ2mNv4XbR9d",
"platform_fee": "20000.00",
"products": [
{
"product_id": "prod_8f2a71c4e05b",
"quantity": 1,
"unit_amount": "100000.00",
"currency": "NGN",
"price_type": "fixed",
"line_total": "100000.00"
}
]
}
```
Out of the `100000.00` NGN the customer pays, `20000.00` is your cut. The rest, minus Bachs's processing fee, is the account's. See [Platform fees](/connect/platform-fees) and [Direct charges](/connect/split-payments/direct).
Redirect the customer to `checkout_url`. Bachs hosts the payment page and collects the charge against the account.
Bachs sends [checkout.completed](/guides/webhooks/events/checkout-completed) once the customer finishes. Check `data.payment_status`: `paid` means a charge was made.
A direct charge's event originates with the account, not your platform. This is why the webhook endpoint you set up before starting needed `event_source` set to `connect` or `all`: with the default, `account`, this event never reaches you.
```json Event theme={"dark"}
{
"id": "evt_7f6e5d4c3b2a1908",
"type": "checkout.completed",
"created_at": "2026-08-12T09:15:00.000000+00:00",
"organization_id": "acct_3Wq8ZfT1yHnJ5sVe",
"account": "acct_3Wq8ZfT1yHnJ5sVe",
"data": {
"checkout_id": "9f3a71c4-e05b-4b2a-91b4-0e6c7d2a91b4",
"status": "completed",
"mode": "payment",
"payment_status": "paid",
"amount": "100000.00",
"currency": "NGN",
"charge": {
"id": "ch_2f8a71c4e05b",
"amount": "100000.00",
"currency": "NGN",
"status": "succeeded"
},
"completed_at": "2026-08-12T09:15:00.000000+00:00"
}
}
```
Your platform fee is not a transfer: it settles as its own record once the charge settles, never sooner. Read it from `GET /v1/platform_fees`:
```bash Request theme={"dark"}
curl "https://sandbox-api.bachs.io/v1/platform_fees?charge=ch_2f8a71c4e05b" \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
```json Response theme={"dark"}
{
"items": [
{
"id": "pf_9c2e04a7b93f2d654012",
"charge": "ch_2f8a71c4e05b",
"collected_from": "acct_3Wq8ZfT1yHnJ5sVe",
"earned_by": "acct_7KpQ2mNv4XbR9dLc",
"amount": "20000.00",
"currency": "NGN",
"amount_refunded": "0.00",
"refunded": false,
"created_at": "2026-08-12T10:31:00.000Z"
}
],
"pagination": {
"next_cursor": null,
"prev_cursor": null,
"has_more": false,
"limit": 50,
"offset": 0,
"returned": 1,
"total": 1
}
}
```
`collected_from` is the account whose sale funded the fee, and `earned_by` is your platform. `charge` ties it back to the checkout's charge.
## What happens next
You have run a direct charge end to end: an account that accepts money, and a platform fee that comes back to you. Going live is a key swap: the same calls against `https://api.bachs.io` with an `sk_live_` key. See [Take Connect live](/connect/go-live).
## Next steps
* [Split payments](/connect/split-payments)
* [Destination charges](/connect/split-payments/destination)
* [Payouts](/connect/payouts)
* [Choose your integration](/connect/choose-your-integration)
* [Take Connect live](/connect/go-live)
# Refunds on Connect charges
Source: https://docs.bachs.io/connect/refunds
Which balance a refund debits on a direct, destination, or transferred charge, and what happens to the platform fee.
## Who a refund debits
A refund debits whoever owns the charge. That is not always the party whose customer paid, or the party whose product sold. Ownership is decided once, at charge creation, by which shape you used:
| Shape | Who the charge belongs to | Who a refund debits |
| - | - | - |
| [Direct](/connect/split-payments/direct) | The account | The account |
| [Destination](/connect/split-payments/destination) | Your platform | Your platform |
A charge you collect on your own platform and split with standalone transfers afterward is liable the same way a destination charge is: your platform owns the charge, so your platform is debited.
Direct is the only shape where a refund reaches into an account's balance at all. On both the other shapes the charge was always your platform's, so the refund never touches an account's balance directly, whatever it later transferred out.
***
## What a refund does not reverse
The customer gets back the full amount they paid. Nothing else about the charge unwinds with it: the platform fee is not reversed, and Bachs's own processing fee is not reversed either. Both were earned when the charge settled, and a refund does not revisit that.
This is deliberate policy, not an omission: the party debited by the refund is the one who absorbs the difference between what the customer gets back and what everyone else already kept.
***
## On a direct charge
The account is debited the full amount the customer paid, but it only ever received the gross minus your platform fee. Work out the gap before an account asks you about it: on a 20% platform fee, a full refund leaves the account 20% short on that sale, because it is repaying more than it was ever credited.
***
## On a destination charge
Your platform is debited the full gross of the refund. The account keeps the share it already received: the payout transfer that already moved to it is not reversed. A refund on a destination charge and the transfer it produced are two separate movements to Bachs, and refunding one does nothing to the other.
If you want the account's share back, there is no automatic route. Send it back yourself: act as the account with `X-Account-Id` and create a transfer with `destination: "self"`. This only works while the funds are still in the account's balance; once withdrawn, they are the account's to keep, and recovering them is between you and the account, not something Bachs can do for you. See [Transfers](/connect/transfers).
The same holds if you collected the charge on your own platform and split it with standalone transfers afterward instead of naming an account on the charge itself: the charge was still your platform's outright, so a refund debits your platform the same way. Nothing ties the refund to transfers you already sent out of it, since Bachs does not record that link, so a refund here does not touch, adjust, or even see them. Recovering a share works the same way: a transfer back with `destination: "self"`, while the funds are still there.
***
## When a refund is refused
A refund the debited party cannot fund is refused, not queued or partially applied. The debit is guarded against going into overdraft with no fallback: the post fails and nothing is written, so neither the customer nor the debited party's balance moves. This returns the standard [error envelope](/errors) with `BAD_REQUEST`, 400, and the message `Insufficient balance to refund`.
A balance already withdrawn cannot absorb a refund, since there is nothing to draw on. Unlike a lost dispute, an unfundable refund is not booked as a debt against a future settlement: a refund is voluntary, so it is refused instead. Fund the balance, then retry.
***
## Related
* [Issue a refund](/guides/refunds): which payments can be refunded, the currencies we can return, and how long a refund takes
* [Split payments](/connect/split-payments)
* [Direct charges](/connect/split-payments/direct)
* [Destination charges](/connect/split-payments/destination)
* [Platform fees](/connect/platform-fees)
* [Transfers](/connect/transfers)
# Requirements
Source: https://docs.bachs.io/connect/requirements
The information an account provides before its capabilities are enabled.
**Requirements** are what an account provides before a [capability](/connect/capabilities) is enabled. Each capability requires certain fields, and the capability stays inactive until those fields are provided and accepted.
They are computed on every read from what the account's capabilities require against what has been accepted, so they change when you request a capability or when a document is rejected. They resolve by country and entity type: changing either recomputes the whole set.
***
## Where to read them
They live on the account object. `GET /v1/accounts/{account_id}` returns the `requirements` block on every read.
| What | Returns | Use it for |
| - | - | - |
| `requirements` | Four arrays of field keys, plus `errors` and `current_deadline` | A status badge |
| `requirements.entries[]` | Every outstanding field, its status, what it blocks, who can act on it, and its deadline | Building an onboarding interface |
| `?include=requirements.values` | What has been submitted so far, plus a per-person rollup | Prefilling an edit, or a review screen |
All take scope `connected_accounts:read`.
`entries[]` is the one to build a form from when an account holds more than one capability: a flat bucket cannot say that one field blocks `payouts` while another blocks nothing the account has asked for.
***
## Field states
Each entry in `requirements.entries[]` carries a `status`, naming the bucket it is listed under.
| Status | Meaning |
| - | - |
| `currently_due` | Required now. |
| `eventually_due` | Required later, once a threshold or stage is reached. |
| `past_due` | Was required by a date that has passed. |
| `pending_verification` | Provided, and being checked, either automatically or by a reviewer. |
Only outstanding fields become entries. A field that has been provided and accepted is not listed at all, so an empty `entries` means nothing is outstanding. A field awaiting a reviewer is reported as `pending_verification` rather than a status of its own: to a platform, both mean the same thing, which is that the field is with us and re-sending it achieves nothing.
`provided` says whether a value was submitted. A `currently_due` field with `provided: true` was rejected; with `provided: false` it was never filled in. A rejected field carries `error_reason`, written for display to the account holder.
The field key names what the entry is about. `company.*` and `business_profile.*` are the account itself, `persons.per_3a91c0d7.*` a specific person. Read that person at [`/persons/{person_id}`](/connect/accounts#persons).
***
## What a field blocks
Each entry carries `restricts_capabilities`, the capabilities that stay off while it is outstanding. An empty array means no capability the account has asked for needs this field.
Read it per entry rather than counting the buckets: an account holding two capabilities can have one fully satisfied while the other is blocked by a single field, which a flat list cannot express.
Nothing outstanding does not mean a capability is on. Gate on the capability's `status` being `active`. See [Capabilities](/connect/capabilities).
***
## Who can act, and by when
Each entry carries a `resolution`.
| Value | Meaning |
| - | - |
| `api` | Yours to supply. Write it through the account update or the persons subresource. |
| `review` | Already provided and sitting with us. Re-sending it achieves nothing; wait for `account.updated`. |
A field with a deadline carries it as `deadline`, and the account's `requirements.current_deadline` is the soonest one across everything outstanding, enough to drive a banner without walking every entry. Both are `null` when no deadline has been set.
***
## The account roll-up
The account object carries a smaller block for a status badge:
```json theme={"dark"}
{
"currently_due": ["company.registration_number"],
"eventually_due": [],
"past_due": [],
"pending_verification": ["persons.per_3a91c0d7.id_document"],
"current_deadline": "2026-09-01T00:00:00Z",
"errors": [
{
"field": "persons.per_3a91c0d7.id_document",
"code": "unreadable",
"reason": "The document image was too blurry to read."
}
]
}
```
These are field keys, not objects. `errors` carries a field that was provided and then rejected, as distinct from one that is missing.
***
## Submitting
`POST /v1/accounts/{account_id}` · scope `connected_accounts:write`
`fields` is keyed by the field keys the requirements name: `persons`, `company.*`, `business_profile.*`, `payout_destination`. A submission that omits a field leaves it untouched, so you can save an account holder's progress as they fill in a form. But every field you *do* send is validated together, and if any one of them is invalid the submission is refused: none of the fields in that call are saved, not even the ones that were fine. There is no partial save of a rejected submission. Retry with only the corrected fields, or the whole `fields` object again once it is fixed.
This applies to every field class in the payload, not only `payout_destination`: an invalid `company.structure` or a malformed entry in `persons[]` in the same call also refuses the whole submission.
A field that is merely **incomplete** (one you have started but not finished, so a required sub-field is still absent) is not a rejection. The call succeeds and the requirement stays in `currently_due` until you send the rest. You still see it in `errors[]` with the code `required`, alongside any field that was actually rejected.
The refusal covers the `fields` object, not the rest of the request. Contact details, capability requests, profile changes and an entity-type change sent in the same call are applied *before* the fields are validated, and a rejected submission does not undo them. A call that mixes them is not atomic: if the fields are refused, retry the fields; the rest already took effect.
Requirement values ride on the account write, so the same call can set contact details and request capabilities. See [Update an account](/connect/accounts#update-an-account).
The response is the account, with its recomputed `requirements` block.
Bank codes and mobile money operators live under `/v1/reference/`, with no account in the path, because the NG bank list is the same for every account. Resolving an account number to its holder's name is an operation rather than a lookup, so it sits at `POST /v1/misc/bank-accounts/resolve`. It allows 20 calls a minute, since each one reaches the banking network and answers about a real account. See [Onboard through the API](/connect/guides/api-onboarding).
### The `persons` shape
`persons` is an array. Each entry is one human, identified across calls by the `persons.` prefix its requirements carry once created. Submit a person by including it in the array; omit a field to leave it untouched, so a form can save as it is filled.
| Field | Type | Notes |
| - | - | - |
| `first_name` | string | |
| `last_name` | string | |
| `dob` | string | Date of birth as `YYYY-MM-DD`. |
| `email` | string | |
| `phone` | string | E.164, e.g. `+2348012345678`. |
| `address` | object | `{ line1, city, state, country }`, plus optional `postal_code`. `country` is the 2-letter ISO code. |
| `id_numbers` | array | The identifiers this person holds. Each entry is `{ type, value, issuing_country }`, where `type` names the scheme (`nin`, `passport`, `id_card`, `drivers_license`, `residence_permit`, `bvn`) and `issuing_country` is the 2-letter ISO code of who issued it. A person can hold several at once: a Nigerian merchant typically sends a `nin` and, once asked, a `bvn`. `nin` and `bvn` are Nigeria-specific; the rest are universal. |
| `relationship` | object | Who this person is to the account: `{ "representative": true }`, and any of `owner`, `director` (booleans), `percent_ownership` (number), `title` (string). The person opening the account is the `representative`. |
```json theme={"dark"}
{
"fields": {
"persons": [
{
"first_name": "Ada",
"last_name": "Obi",
"dob": "1995-06-15",
"email": "ada@example.com",
"phone": "+2348012345678",
"address": {
"line1": "12 Marina Road",
"city": "Lagos",
"state": "Lagos",
"postal_code": "101001",
"country": "NG"
},
"id_numbers": [
{ "type": "nin", "value": "12345678901", "issuing_country": "NG" }
],
"relationship": { "representative": true }
}
]
}
}
```
Add a BVN (or any further identifier) as another entry in the same list — it sits **beside** the primary ID rather than replacing it, because a NIN identifies a citizen and a BVN a bank customer, and a reviewer may need both:
```json theme={"dark"}
"id_numbers": [
{ "type": "nin", "value": "12345678901", "issuing_country": "NG" },
{ "type": "bvn", "value": "22345678901", "issuing_country": "NG" }
]
```
A person's requirements (`persons..name`, `.dob`, `.id_number`, `.id_document`, …) come from the capabilities the account holds: a recipient needs only a name, a merchant needs the full identity set. The `id_number` requirement is satisfied by any non-BVN entry in `id_numbers`. `persons.bvn` and `persons.proof_of_address` are `eventually_due` and do not block getting started; submit the `bvn` when asked by adding it to the list.
### The `business_profile` shape
A merchant (an account that accepts payments) describes its business. These fields gate the payment-method capabilities (`card_collection`, `ngn_card_collection`, `bank_transfer`, …); a recipient does not owe them.
| Field | Type | Notes |
| - | - | - |
| `product_description` | string | What the business sells. |
| `support_email` | string | A contact address for the business. |
| `product_category` | string | The business's industry. An enum, not free text: send one of the accepted category slugs (e.g. `marketplace_for_goods`, `software_as_a_service_saas`, `fashion_and_clothing`). Fetch the full list — grouped into sections, with a display label for each — from `GET /v1/reference/product-categories`. |
| `url` | string | The business website. Set by the platform, not the account. |
```json theme={"dark"}
{
"fields": {
"business_profile": {
"product_description": "Handmade leather goods",
"support_email": "help@adastores.example",
"product_category": "fashion_and_clothing"
}
}
}
```
### Company fields
A registered business (`entity_type: "company"`) also owes `company.*` fields — registration number, structure, and country-specific filings. `company.structure` is an enum whose valid values, and which documents each structure owes, depend on the country; read them from `GET /v1/reference/business-structures`. An individual account (`entity_type: "individual"`) owes none of these.
### Documents (`id_document`, company filings)
A document requirement (a person's `id_document`, a company's certificate of incorporation) is a **file**, not a value, so it is uploaded separately and then referenced. It cannot be sent inline in the `fields` object.
`POST /v1/utilities/uploads` (multipart), with the bytes and a `scope` that labels the upload:
* `identity_document` — a person's government ID.
* `account_requirement` — a company filing or supporting evidence.
```bash theme={"dark"}
curl -X POST https://api.bachs.io/v1/utilities/uploads \
-H "Authorization: Bearer sk_live_xxx" \
-F "scope=identity_document" \
-F "file=@id-front.jpg"
```
The response returns an `upload_id`. Accepted formats are the common image and PDF types; a PDF identity document is fine.
Attach the uploaded file to the slot it satisfies, referencing the `upload_id` from the previous step. A **person's** identity document attaches to that person: `POST /v1/accounts/{account_id}/persons/{person_id}/documents` with the `upload_id` and the document slot. See [Verify an account's identity](/connect/guides/identity-verification). A **company** filing attaches to the account: `POST /v1/accounts/{account_id}/documents`, naming the company document slot.
Documents cannot go through a hosted onboarding link's automatic flow either: whether you onboard by API or by link, the file itself is uploaded through `POST /v1/utilities/uploads`.
### The `payout_destination` shape
`payout_destination` is a tagged union: `type` picks the rail, and the rest of the object is whichever fields that rail needs.
| `type` | Required fields |
| - | - |
| `bank_account` | `account_number`, `account_name`, plus `bank_code` (or the legacy `routing_number`/`bank_name` aliases) |
| `mobile_money` | `phone_number`, `mobile_provider` |
| `crypto_wallet` | `asset`, `wallet_address` |
`currency` is required alongside them (a legacy submission that omits it is inferred from the account's country instead). The currency and destination type must have a configured payout provider.
Local bank destinations in NGN, GHS, KES, TZS and UGX and mobile money destinations in GHS, KES and TZS have configured routes. Payouts are available to all Bachs users; the account still needs to complete its requirements and have a usable destination. See [Supported currencies](/guides/payouts/global-payouts). For stablecoin destinations, use an asset code from the [supported payout networks](/for-you/supported-currencies#withdrawals).
The inline `payout_destination` requirement field does not accept banks that need international routing schemes, such as GBP, EUR, USD and CAD destinations. Add those through `POST /v1/payouts/destinations` with the appropriate routing details. See the [payout destination reference](/api-reference/payout-destinations/object). This is a separate submission path; adding a destination does not replace the account's other onboarding requirements or activate its capabilities.
A bank account's `bank_code` and a mobile money submission's `mobile_provider` are also checked against the account's own country: a Ghanaian account cannot name a Nigerian bank, and vice versa.
### When a submission is rejected
A rejected requirement-field write can return `400` with `error_code: "INVALID_REQUIREMENT_FIELD"` and an `errors` array. For example, a GBP bank destination is rejected through this inline field because it needs routing details supplied through the payout-destination API. Read the returned field and message before choosing another submission path.
| `code` | Meaning |
| - | - |
| `currency_required` | No `currency` was sent and the account's country has no default settlement currency to fall back to. |
| `currency_not_supported` | The currency has no configured payout provider (or none for the resolved rail). |
| `type_required` | The currency is served by more than one rail, so `type` must be stated rather than inferred. |
| `bank_code_unknown` | `bank_code` is not a bank in the account's country. |
| `mobile_provider_unknown` | `mobile_provider` is not a mobile money provider in the account's country. |
| `asset_not_supported` | `asset` on a `crypto_wallet` is not a supported crypto asset. |
| `required` | A required sub-field for the resolved `type` was not sent. This one does not fail the write: the destination is incomplete rather than rejected, and stays outstanding. |
| `invalid` | Any other shape problem. |
See [Errors](/errors) for the error envelope and [Error Reference](/api-reference/error-reference) for every `error_code`.
***
## Related
* [Onboarding](/connect/onboarding)
* [Capabilities](/connect/capabilities)
* [Onboard through the API](/connect/guides/api-onboarding)
# Split payments
Source: https://docs.bachs.io/connect/split-payments
How a platform takes a cut of an account's sale, in the two shapes Bachs supports.
## What a split payment is
A **split payment** is a single charge that divides between your platform and the account that earned it. The customer pays once, and Bachs moves the platform's cut as part of settling the charge.
There are two shapes. They differ in whose charge it is, which decides whose balance carries the risk of a refund or a lost dispute.
***
## Which shape you need
| Shape | When to use it | Who the sale belongs to | Who a refund or lost dispute debits |
| - | - | - | - |
| [Direct](/connect/split-payments/direct) | The account is the merchant of record and your cut is small relative to the sale | The account | The account |
| [Destination](/connect/split-payments/destination) | You are the merchant of record and the account is a seller you pay out | Your platform | Your platform |
Both shapes decide the split at the charge itself: the difference is whose charge it is. Pick the shape first, then follow the build track for what you are building: [Marketplaces and SaaS platforms](/connect/marketplaces/overview) or [Creator and contractor payouts](/connect/payout-networks).
***
## Direct
The sale is the account's. It is created by acting as that account with `X-Account-Id`, and your cut moves up to you.
With this shape:
* The charge lands in the account's balance.
* The platform fee moves from the account to your platform.
* A refund debits the account's balance.
* A lost dispute debits the account's balance.
* The account needs the capability for whichever payment method the charge uses, for example `card_collection` or `bank_transfer`. See [Capabilities](/connect/capabilities).
See [Direct charges](/connect/split-payments/direct).
***
## Destination
The sale is your platform's. It is created by naming the account in `transfer_data.destination`, and the seller's share moves down to them. The split has to be stated one of two ways: `platform_fee` says what your platform keeps, `transfer_data.amount` says what the account receives. Exactly one is required on this shape; see [Platform fees](/connect/platform-fees) for how the two differ.
With this shape:
* The charge lands in your platform's balance.
* Your platform keeps its stated cut or its remainder after the account's share, depending on which term you sent; the rest moves to the account.
* A refund debits your platform's balance.
* A lost dispute debits your platform's balance.
* Your platform needs the capability for whichever payment method the charge uses, for example `card_collection` or `bank_transfer`. The named account is checked only for ownership and active status, not for the capability. See [Capabilities](/connect/capabilities).
See [Destination charges](/connect/split-payments/destination).
***
## What you need before you start
Your account needs the `connect` capability to create accounts at all. See [Become a platform](/connect/become-a-platform).
Direct needs the account to hold the capability for each payment method it accepts, for example `card_collection`, `bank_transfer`, `mobile_money`, `crypto`, or `ngn_card_collection`. Destination needs your platform to hold that same capability instead, since the sale is your platform's; the account is checked only for ownership and active status. See [Capabilities](/connect/capabilities).
***
## Next steps
* [Direct charges](/connect/split-payments/direct)
* [Destination charges](/connect/split-payments/destination)
* [Platform fees](/connect/platform-fees)
* [Processing fees](/connect/processing-fees)
* [Marketplaces and SaaS platforms](/connect/marketplaces/overview)
* [Creator and contractor payouts](/connect/payout-networks)
# Destination charges
Source: https://docs.bachs.io/connect/split-payments/destination
How a destination charge lands in your platform's balance, and how the seller's share moves down to an account.
## What you'll build
A checkout that belongs to your platform, with an account named as the seller. Your platform is the merchant of record, the sale lands in your balance, and the seller's share moves down to the account as part of settling the charge. By the end you'll have created the checkout as your platform, sent the customer to it, confirmed the payment with a webhook, and read the seller's payout back as a transfer, along with your own cut when you stated the split that way.
***
## When to use it
* Customers transact with your platform, not with the account, for the goods or services the account provides.
* Each charge names exactly one account as the seller.
* Your platform is the merchant of record.
***
## The flow
The customer pays your platform directly. Your platform settles Bachs's processing fee out of that one charge, and the split with the account is settled the way you stated it: either your platform names its own cut, or it names what the account receives.
***
Do not send `X-Account-Id`. Name the account in `transfer_data.destination`; its presence, on its own, is what makes this a destination charge. State the split with exactly one of two terms: `platform_fee`, what your platform keeps, or `transfer_data.amount`, what the account receives. Sending both is rejected, and sending neither is too, since the account's share cannot be computed without one. Whichever you send, it is an amount in the base currency of the sale, not necessarily the currency the customer pays in.
Name your platform's cut. The account receives whatever is left.
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"product_cart": [
{
"product_id": "prod_8f2a71c4e05b",
"quantity": 1,
"pricing": { "price_type": "fixed", "amount": "100000.00" }
}
],
"platform_fee": "20000.00",
"transfer_data": { "destination": "acct_3Wq8ZfT1yHnJ5sVe" },
"customer": { "email": "jane@example.com" },
"success_url": "https://example.com/thank-you",
"cancel_url": "https://example.com/checkout"
}'
```
```json Response theme={"dark"}
{
"checkout_id": "chk_c48e2a917d3f",
"status": "open",
"source_type": "CHECKOUT_SESSION",
"amount": "100000.00",
"currency": "NGN",
"checkout_url": "https://checkout.bachs.io/c/Fq7mZv2XpN9kRtB",
"platform_fee": "20000.00",
"products": [
{
"product_id": "prod_8f2a71c4e05b",
"quantity": 1,
"unit_amount": "100000.00",
"currency": "NGN",
"price_type": "fixed",
"line_total": "100000.00"
}
]
}
```
Name what the account receives instead. Your platform keeps whatever is left, and absorbs any variance in the total.
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"product_cart": [
{
"product_id": "prod_8f2a71c4e05b",
"quantity": 1,
"pricing": { "price_type": "fixed", "amount": "100000.00" }
}
],
"transfer_data": { "destination": "acct_3Wq8ZfT1yHnJ5sVe", "amount": "80000.00" },
"customer": { "email": "jane@example.com" },
"success_url": "https://example.com/thank-you",
"cancel_url": "https://example.com/checkout"
}'
```
```json Response theme={"dark"}
{
"checkout_id": "chk_c48e2a917d3f",
"status": "open",
"source_type": "CHECKOUT_SESSION",
"amount": "100000.00",
"currency": "NGN",
"checkout_url": "https://checkout.bachs.io/c/Fq7mZv2XpN9kRtB",
"platform_fee": null,
"products": [
{
"product_id": "prod_8f2a71c4e05b",
"quantity": 1,
"unit_amount": "100000.00",
"currency": "NGN",
"price_type": "fixed",
"line_total": "100000.00"
}
]
}
```
`platform_fee` carries the amount because there is one; a checkout with none returns `"platform_fee": null` rather than `"0.00"`. The key is always present regardless of which term you sent. `transfer_data` is never echoed back, on either tab.
Redirect the customer to `checkout_url`. Bachs hosts the payment page and collects the charge against your platform.
Bachs sends [checkout.completed](/guides/webhooks/events/checkout-completed) once the customer finishes. Check `data.payment_status`: `paid` means a charge was made.
```json Event theme={"dark"}
{
"id": "evt_7f6e5d4c3b2a1908",
"type": "checkout.completed",
"created_at": "2026-08-12T09:15:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"checkout_id": "chk_c48e2a917d3f",
"status": "completed",
"mode": "payment",
"payment_status": "paid",
"amount": "100000.00",
"currency": "NGN",
"charge": {
"id": "ch_2f8a71c4e05b",
"amount": "100000.00",
"currency": "NGN",
"status": "succeeded"
},
"completed_at": "2026-08-12T09:15:00.000000+00:00"
}
}
```
`organization_id` is your platform: it is the party whose checkout completed, so it is also the event's origin. See [checkout.completed](/guides/webhooks/events/checkout-completed) for the full payload.
The account's share settles as a transfer from your platform. List it with `kind=payout`. What it carries, and whether your platform's cut appears anywhere else, depends on which term you used to state the split.
```bash Request theme={"dark"}
curl "https://sandbox-api.bachs.io/v1/transfers?kind=payout&connected_account_id=acct_3Wq8ZfT1yHnJ5sVe" \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
```json Response theme={"dark"}
{
"items": [
{
"id": "tr_8c1e04a7b93f2d6540ab",
"source": "acct_7KpQ2mNv4XbR9dLc",
"destination": "acct_3Wq8ZfT1yHnJ5sVe",
"amount": "100000.00",
"currency": "NGN",
"status": "paid",
"metadata": {},
"kind": "payout",
"source_charge_id": "ch_2f8a71c4e05b",
"created_at": "2026-08-12T10:31:00.000Z"
}
],
"pagination": {
"next_cursor": null,
"prev_cursor": null,
"has_more": false,
"limit": 50,
"offset": 0,
"returned": 1,
"total": 1
}
}
```
The transfer carries the full `"100000.00"` the customer paid, not the `"80000.00"` left after your fee. Your platform's cut is not on this record: it settles separately, as its own record.
Read it from `GET /v1/platform_fees`:
```bash Request theme={"dark"}
curl "https://sandbox-api.bachs.io/v1/platform_fees?charge=ch_2f8a71c4e05b" \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
```json Response theme={"dark"}
{
"items": [
{
"id": "pf_9c2e04a7b93f2d654012",
"charge": "ch_2f8a71c4e05b",
"collected_from": "acct_3Wq8ZfT1yHnJ5sVe",
"earned_by": "acct_7KpQ2mNv4XbR9dLc",
"amount": "20000.00",
"currency": "NGN",
"amount_refunded": "0.00",
"refunded": false,
"created_at": "2026-08-12T10:31:00.000Z"
}
],
"pagination": {
"next_cursor": null,
"prev_cursor": null,
"has_more": false,
"limit": 50,
"offset": 0,
"returned": 1,
"total": 1
}
}
```
`collected_from` is the account whose sale funded the fee, and `earned_by` is your platform. The transfer's `"100000.00"` minus this fee's `"20000.00"` is what actually reaches the account. See [Platform fees](/connect/platform-fees).
```bash Request theme={"dark"}
curl "https://sandbox-api.bachs.io/v1/transfers?kind=payout&connected_account_id=acct_3Wq8ZfT1yHnJ5sVe" \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
```json Response theme={"dark"}
{
"items": [
{
"id": "tr_8c1e04a7b93f2d6540ab",
"source": "acct_7KpQ2mNv4XbR9dLc",
"destination": "acct_3Wq8ZfT1yHnJ5sVe",
"amount": "80000.00",
"currency": "NGN",
"status": "paid",
"metadata": {},
"kind": "payout",
"source_charge_id": "ch_2f8a71c4e05b",
"created_at": "2026-08-12T10:31:00.000Z"
}
],
"pagination": {
"next_cursor": null,
"prev_cursor": null,
"has_more": false,
"limit": 50,
"offset": 0,
"returned": 1,
"total": 1
}
}
```
The transfer carries exactly the `"80000.00"` you fixed. Your platform keeps the remaining `"20000.00"`, but no record of it exists at `GET /v1/platform_fees`: you never named a fee, so none is minted.
`status` is `paid` once settlement has posted the movement, and `pending` before it has. `source_charge_id` ties the transfer back to the checkout's charge. See [Transfers](/connect/transfers).
***
## With this shape
* The charge lands in your platform's balance, not the account's.
* One of `platform_fee` or `transfer_data.amount` is required. `platform_fee` fixes what your platform keeps; `transfer_data.amount` fixes what the account receives.
* With `platform_fee`, the account's payout carries the sale's gross, and your platform's cut settles separately, readable at [Platform fees](/connect/platform-fees). With `transfer_data.amount`, the payout carries only the account's fixed share, and your platform mints no fee record for the rest.
* A refund debits your platform's balance for the full amount the customer paid. The payout transfer already sent to the account is not reversed: your platform bears the full refund, and the account keeps the share it received.
* A lost dispute drains your platform's available balance, then its pending balance. Any amount still owed drives the available balance negative: that negative balance is the debt, and it heals as your platform's own future settlement credits land. A dispute also debits a flat dispute fee when it opens, whether it is later won or lost. See [Disputes](/connect/disputes).
* Your platform needs the capability for every payment method it plans to accept, active before a customer can pay it that way: `card_collection`, `ngn_card_collection`, `bank_transfer`, `mobile_money`, `crypto`. The named account is checked only for ownership and active status, not for the capability. See [Capabilities](/connect/capabilities).
* The account needs `payouts` active to withdraw its share once it settles.
***
## Errors
Destination-charge errors return the standard [error envelope](/errors).
| Cause | Resolution |
| - | - |
| `transfer_data.destination` was sent with neither `platform_fee` nor `transfer_data.amount` set (`INVALID_PLATFORM_FEE`, 400). Without one of them, the split between your platform and the account is undefined. | Send `platform_fee` or `transfer_data.amount`, whichever term you want to fix. |
| `platform_fee` and `transfer_data.amount` were both sent on the same request. They are two ways to state the same split, and only one may be present. | Send only one of the two fields. |
| `platform_fee` or `transfer_data.amount` is not less than the gross amount (`INVALID_PLATFORM_FEE`, 400). | Lower whichever one you sent, so the other side of the split is left with something. |
| `X-Account-Id` and `transfer_data` were sent on the same request (`CONTRADICTORY_CHARGE_TYPE`, 400). The header alone makes the sale the account's; naming a destination makes it yours, and a request cannot be both. | Send only `transfer_data` for a destination charge. Drop the header entirely, or drop `transfer_data` and use [Direct charges](/connect/split-payments/direct) instead. |
| Your platform has not been granted the capability for a payment method a customer tries to use (`PAYMENT_METHOD_NOT_ENABLED`, 400). The check runs against your platform, since the sale is your platform's; the account is not checked. This is resolved when the method is chosen, not at checkout creation, so it can surface after the customer reaches the hosted page rather than on your `curl` call. | Request and activate the capability for that method on your platform before offering it. See [Capabilities](/connect/capabilities). |
A charge priced in a currency different from the one it settles in still splits and settles: your platform's cut and the account's share are computed in the currency the sale was priced in. See [Platform fees](/connect/platform-fees).
***
## Next steps
* [Split payments](/connect/split-payments)
* [Direct charges](/connect/split-payments/direct)
* [Platform fees](/connect/platform-fees)
* [Processing fees](/connect/processing-fees)
* [Transfers](/connect/transfers)
* [checkout.completed](/guides/webhooks/events/checkout-completed)
# Direct charges
Source: https://docs.bachs.io/connect/split-payments/direct
How a direct charge lands in an account's own balance, and how a platform fee is taken from it.
## What you'll build
A checkout that belongs to an account. The account is the merchant of record, the sale lands in its balance, and your platform fee moves up to you as part of settling the charge. By the end you'll have created the checkout as the account, sent the customer to it, confirmed the payment with a webhook, and read your cut back.
***
## When to use it
* The account transacts with its own customers, not yours.
* The customer may never need to know your platform exists.
* Each charge belongs to exactly one account.
***
## The flow
The customer pays the account directly. The account settles Bachs's processing fee and your platform fee out of that one charge; the remainder is the account's proceeds.
***
Send `X-Account-Id` with the account's id. Its presence, on its own, is what makes this a direct charge. Add `platform_fee` for your cut, in the base currency of the sale, taken from the account's proceeds.
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-H "X-Account-Id: acct_3Wq8ZfT1yHnJ5sVe" \
-d '{
"product_cart": [
{
"product_id": "prod_8f2a71c4e05b",
"quantity": 1,
"pricing": { "price_type": "fixed", "amount": "100000.00" }
}
],
"platform_fee": "20000.00",
"customer": { "email": "jane@example.com" },
"success_url": "https://example.com/thank-you",
"cancel_url": "https://example.com/checkout"
}'
```
```json Response theme={"dark"}
{
"checkout_id": "chk_9f3a71c4e05b",
"status": "open",
"source_type": "CHECKOUT_SESSION",
"amount": "100000.00",
"currency": "NGN",
"checkout_url": "https://checkout.bachs.io/c/Jd5nQx8VmT3yLcR",
"platform_fee": "20000.00",
"products": [
{
"product_id": "prod_8f2a71c4e05b",
"quantity": 1,
"unit_amount": "100000.00",
"currency": "NGN",
"price_type": "fixed",
"line_total": "100000.00"
}
]
}
```
`platform_fee` carries the amount because there is one; a checkout with none returns `"platform_fee": null` rather than `"0.00"`. The key is always present.
Redirect the customer to `checkout_url`. Bachs hosts the payment page and collects the charge against the account.
Bachs sends [checkout.completed](/guides/webhooks/events/checkout-completed) once the customer finishes. Check `data.payment_status`: `paid` means a charge was made.
A direct charge's event originates with the account. Your platform receives it only on an endpoint with `event_source` set to `connect` or `all`; the default is `account`. See [Connect events](/guides/webhooks/overview#connect-events).
```json Event theme={"dark"}
{
"id": "evt_7f6e5d4c3b2a1908",
"type": "checkout.completed",
"created_at": "2026-08-12T09:15:00.000000+00:00",
"organization_id": "acct_3Wq8ZfT1yHnJ5sVe",
"account": "acct_3Wq8ZfT1yHnJ5sVe",
"data": {
"checkout_id": "chk_9f3a71c4e05b",
"status": "completed",
"mode": "payment",
"payment_status": "paid",
"amount": "100000.00",
"currency": "NGN",
"charge": {
"id": "ch_2f8a71c4e05b",
"amount": "100000.00",
"currency": "NGN",
"status": "succeeded"
},
"completed_at": "2026-08-12T09:15:00.000000+00:00"
}
}
```
Use `account` to attribute the event to the account that generated it; it is the field added specifically for that. See [checkout.completed](/guides/webhooks/events/checkout-completed) for the full payload.
Your platform fee is not a transfer: it settles as its own record. Read it from `GET /v1/platform_fees`.
```bash Request theme={"dark"}
curl "https://sandbox-api.bachs.io/v1/platform_fees?charge=ch_2f8a71c4e05b" \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
```json Response theme={"dark"}
{
"items": [
{
"id": "pf_9c2e04a7b93f2d654012",
"charge": "ch_2f8a71c4e05b",
"collected_from": "acct_3Wq8ZfT1yHnJ5sVe",
"earned_by": "acct_7KpQ2mNv4XbR9dLc",
"amount": "20000.00",
"currency": "NGN",
"amount_refunded": "0.00",
"refunded": false,
"created_at": "2026-08-12T10:31:00.000Z"
}
],
"pagination": {
"next_cursor": null,
"prev_cursor": null,
"has_more": false,
"limit": 50,
"offset": 0,
"returned": 1,
"total": 1
}
}
```
`collected_from` is the account whose sale funded the fee, and `earned_by` is your platform. `amount` and `currency` are the fee as struck: the currency the checkout was created in, before any conversion. See [Platform fees](/connect/platform-fees).
***
## With this shape
* The charge lands in the account's balance, not yours.
* Your platform fee moves to your balance on the account's settlement schedule, never sooner. See [Platform fees](/connect/platform-fees).
* A refund debits the account's balance.
* A lost dispute drains the account's available balance, then its pending balance. Any amount still owed drives the available balance negative: that negative balance is the debt, and it heals as the account's own future settlement credits land. A dispute also debits a flat dispute fee when it opens, whether it is later won or lost. See [Disputes](/connect/disputes).
* The account needs the capability for every payment method it plans to accept, active before a customer can pay it that way: `card_collection`, `ngn_card_collection`, `bank_transfer`, `mobile_money`, `crypto`. See [Capabilities](/connect/capabilities).
***
## Errors
Direct-charge errors return the standard [error envelope](/errors).
| Cause | Resolution |
| - | - |
| `platform_fee` is not less than the gross amount (`INVALID_PLATFORM_FEE`, 400). | Lower `platform_fee` so it leaves the account something to be charged against. |
| `X-Account-Id` and `transfer_data` were sent on the same request (`CONTRADICTORY_CHARGE_TYPE`, 400). The header alone makes the sale the account's; naming a destination makes it yours, and a request cannot be both. | Send only the header for a direct charge. Drop `transfer_data` entirely, or drop the header and use [Destination charges](/connect/split-payments/destination) instead. |
| The account has not been granted the capability for a payment method a customer tries to use (`PAYMENT_METHOD_NOT_ENABLED`, 400). This is resolved when the method is chosen, not at checkout creation, so it can surface after the customer reaches the hosted page rather than on your `curl` call. | Request and activate the capability for that method on the account before offering it. See [Capabilities](/connect/capabilities). |
A charge priced in a currency different from the one it settles in still settles: your platform's cut is computed in the currency the sale was priced in. See [Platform fees](/connect/platform-fees).
***
## Next steps
* [Split payments](/connect/split-payments)
* [Destination charges](/connect/split-payments/destination)
* [Platform fees](/connect/platform-fees)
* [Processing fees](/connect/processing-fees)
* [Transfers](/connect/transfers)
* [checkout.completed](/guides/webhooks/events/checkout-completed)
# Paying an account without a charge
Source: https://docs.bachs.io/connect/split-payments/separate-transfers
How to move funds to an account as a standalone transfer, on your own schedule, with or without a charge behind it.
## What you'll build
Most platforms taking a cut of a sale should use a [direct](/connect/split-payments/direct) or [destination](/connect/split-payments/destination) charge, where Bachs ties the split to the charge for you. This page is for the cases those two cannot cover: paying an account with a standalone transfer, either with no charge behind it at all (a bonus, a correction, a balance top-up) or with a charge you collected on your own platform that you split afterward. By the end you'll have sent a transfer to an account and read it back. If a charge is behind it, you'll also have collected that charge and tied the transfer to it with `transfer_group`.
Unlike a direct or destination charge, nothing about a charge says a split is coming, and a transfer with no charge behind it needs no charge at all. Bachs does not record a link between a charge and the transfers you send against it. If you want that link, you supply it yourself.
***
## When to use it
1. **You're paying an account with no single charge behind it.** A bonus, a correction, a batch of payouts on your own schedule rather than per sale, topping up an account's balance. Direct and destination both require a charge to hang the split on, so neither can serve this case.
2. **You need to split one charge across several accounts.** Direct and destination each name a single account. Splitting one sale many ways needs this shape instead.
If there's a charge behind the transfer, you are comfortable owning the correlation between it and the transfers that followed, since Bachs does not keep that link for you. If there's no charge at all, there's nothing to correlate: you send the transfer directly.
***
## The flow
When there's a charge behind it: the customer pays your platform, the same as any charge with no account involved. Your platform settles Bachs's processing fee out of it. Once the charge settles, you send one or more transfers out of your own available balance, each carrying the `transfer_group` you chose for that charge. What you never transfer out is your cut; there is no `platform_fee` field that moves money on this shape.
When there's no charge behind it: there's no customer payment to wait on. You send the transfer straight out of your own available balance whenever you decide to, the same call, without a charge to settle first.
***
The walkthrough below is charge-based, since that's the more involved case. If you're paying an account with no charge behind it at all, skip straight to **Transfer each account its share**: it's the same transfer call, made whenever you decide to send it, with no checkout to create and no settlement to wait on first.
Do not send `X-Account-Id` and do not name a `transfer_data.destination`. The charge belongs to your platform outright.
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{
"product_cart": [
{
"product_id": "prod_8f2a71c4e05b",
"quantity": 1,
"pricing": { "price_type": "fixed", "amount": "100000.00" }
}
],
"customer": { "email": "jane@example.com" },
"success_url": "https://example.com/thank-you",
"cancel_url": "https://example.com/checkout"
}'
```
```json Response theme={"dark"}
{
"checkout_id": "chk_5b6e19d42f8a",
"status": "open",
"source_type": "CHECKOUT_SESSION",
"amount": "100000.00",
"currency": "NGN",
"checkout_url": "https://checkout.bachs.io/c/Sy2wKp6RmB4vNqT",
"platform_fee": null,
"products": [
{
"product_id": "prod_8f2a71c4e05b",
"quantity": 1,
"unit_amount": "100000.00",
"currency": "NGN",
"price_type": "fixed",
"line_total": "100000.00"
}
]
}
```
`platform_fee` has no effect on this shape and is left unset here; it returns `null`. See [Platform fees](/connect/platform-fees) if you send it anyway, since it is still bounds-checked on the request.
Redirect the customer to `checkout_url`. Once [checkout.completed](/guides/webhooks/events/checkout-completed) arrives with `data.payment_status: "paid"`, note `data.charge.id`. Transfers draw on `available_balance`, not `pending_balance`, so wait for the charge to settle before sending one. Check with `GET /v1/balances` and read `pending_settlements_by_day`. See [Balances](/connect/balances).
Choose a `transfer_group`, the charge id is a convenient choice, and send it on every transfer that splits this charge. Nothing about the request ties it to the charge automatically.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/transfers \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ch_2f8a71c4e05b-seller-share" \
-d '{
"destination": "acct_3Wq8ZfT1yHnJ5sVe",
"amount": "70000.00",
"currency": "NGN",
"transfer_group": "ch_2f8a71c4e05b",
"description": "Order #4471 seller share"
}'
```
```json Response theme={"dark"}
{
"id": "tr_8c1e04a7b93f2d6540ab",
"source": "acct_7KpQ2mNv4XbR9dLc",
"destination": "acct_3Wq8ZfT1yHnJ5sVe",
"amount": "70000.00",
"currency": "NGN",
"status": "paid",
"description": "Order #4471 seller share",
"metadata": {},
"transfer_group": "ch_2f8a71c4e05b",
"kind": "manual",
"source_charge_id": null,
"created_at": "2026-08-12T10:31:00.000Z"
}
```
`kind` is `manual` on every transfer you send here, since you created it, rather than a `payout` that settlement records for a seller's share of a destination charge. `source_charge_id` is `null` for the same reason: this transfer is not the direct product of a charge settling, so nothing populates it. `transfer_group` is the only field that connects this transfer back to the sale, and it is yours to set and yours to query on later. See [Transfers](/connect/transfers) for the object's full field reference, the two-directions model, and the standard error table.
List by `transfer_group` from your own records, or by account:
```bash theme={"dark"}
curl "https://sandbox-api.bachs.io/v1/transfers?connected_account_id=acct_3Wq8ZfT1yHnJ5sVe&limit=50" \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
Listing does not filter on `transfer_group`; store it against the order in your own database so you can go from a sale to the transfers that split it.
***
## With this shape
* The charge lands in your platform's balance. Nothing about the charge names an account, so nothing splits automatically.
* Bachs does not record a link between a charge and the transfers that follow it. `transfer_group` is the only correlation, and you own it end to end.
* A transfer moves money out of `available_balance` only; a charge has to settle before its funds can be transferred. See [Balances](/connect/balances).
* To recover funds you already sent an account, act as it with `X-Account-Id` and send a transfer with `destination: "self"`. This is a second transfer in the opposite direction, not a reversal, so it only succeeds while the funds are still in the account's balance. See [Transfers](/connect/transfers).
* Each transfer records as `kind: "manual"`, distinct from the `payout` rows settlement writes for destination charges. A platform's cut of a sale is never a transfer, on any shape: it settles as its own record, readable at [Platform fees](/connect/platform-fees).
* A refund debits your platform's balance for the full amount the customer paid. Nothing about a separate transfer already sent is reversed by a refund. See [Refunds](/connect/refunds).
* A lost dispute drains your platform's available balance, then its pending balance. Any amount still owed drives the available balance negative: that negative balance is the debt, and it heals as your platform's own future settlement credits land. A dispute also debits a flat dispute fee when it opens, whether it is later won or lost. See [Disputes](/connect/disputes).
* The account needs `transfers` active to send or receive a share, and `payouts` active to withdraw it once it has one. Your platform needs `connect` active to send a transfer to an account, but not to recover one back. See [Capabilities](/connect/capabilities).
***
## Errors
Transfer errors return the standard [error envelope](/errors). See [Transfers](/connect/transfers#errors) for the full table; the ones you'll hit first on this shape:
| Cause | Resolution |
| - | - |
| The transfer amount is more than the source's `available_balance` in that currency (`INSUFFICIENT_BALANCE`, 400). A charge that hasn't settled yet doesn't count, even though the charge succeeded. | Check `GET /v1/balances` and wait for the charge to settle before transferring against it. |
| The account's `transfers` capability is not `active` (`FORBIDDEN`, 403). Checked on the account regardless of direction. | Request and activate `transfers` on the account before sending or recovering a share. See [Capabilities](/connect/capabilities). |
| Your platform's `connect` capability is not `active` on an outbound transfer (`FORBIDDEN`, 403). Not checked when recovering funds back to your platform. | Activate `connect` on your platform. See [Become a platform](/connect/become-a-platform). |
| The account has not activated `payouts` (no error from `/v1/transfers`; the withdrawal itself is refused). A transfer can still succeed and leave funds sitting in the account's balance. | Request and activate `payouts` on the account before it tries to withdraw. |
***
## Next steps
* [Split payments](/connect/split-payments)
* [Direct charges](/connect/split-payments/direct)
* [Destination charges](/connect/split-payments/destination)
* [Platform fees](/connect/platform-fees)
* [Transfers](/connect/transfers)
* [Balances](/connect/balances)
* [Refunds](/connect/refunds)
# Testing Connect
Source: https://docs.bachs.io/connect/testing
How account creation, capabilities, and settlement behave in sandbox, and where that diverges from live.
This covers what is specific to Connect in sandbox: account creation, capability behavior, and settlement timing. First upgrade your platform account to a registered business to make Connect available in sandbox; production use waits for completed business compliance and live enablement. See [Become a platform](/connect/become-a-platform). For the base sandbox environment (base URL, isolation, keys) see [Sandbox environment](/integrate/sandbox). For how a charge's outcome is simulated, see [Test payment outcomes](/integrate/sandbox#test-payment-outcomes).
***
## Create an account
`POST /v1/accounts` works the same as live: same fields, same response shape, a `sk_sandbox_` key against `https://sandbox-api.bachs.io`. See [Accounts](/connect/accounts).
***
## Capability grants depend on the operation
Sandbox capability grants are scoped to the personas named in `configuration`. Naming `merchant` does not automatically apply `recipient`, and naming `recipient` does not apply `merchant`.
| Operation | Sandbox behavior | Live behavior |
| - | - | - |
| Create an account | Grants the operational capabilities allowed by the applied personas. Creation can grant more capabilities than you explicitly named. | Requests the capabilities you explicitly named. Omitting a persona's `capabilities` requests its allowed set; an explicit empty map requests none. |
| Update an account | Applies named personas and grants only the capabilities explicitly named in that update. | Applies named personas and requests only the explicitly named capabilities. |
On update, naming a persona without `capabilities` applies that persona but requests nothing. `conversions` is granted automatically for recipient-capable accounts, so a narrow capability request does not mean every other capability must remain inactive. See [Capabilities](/connect/capabilities).
Sandbox grants do not exercise the complete live requirements and review flow. A capability active in sandbox can still need information or approval in live mode. Check the account's `capabilities` and `requirements` in each environment, and build live requirement handling using [Requirements](/connect/requirements) and [Onboarding](/connect/onboarding).
***
## Settlement is immediate
A sandbox charge's outcome is simulated and finalizes on its own; see [Test payment outcomes](/integrate/sandbox#test-payment-outcomes) for how to control that. Once it succeeds, its amount reaches `available_balance` straight away, whatever the currency.
Live does not work this way. There, a charge lands in `pending_balance` and moves across on a schedule set by the currency it was paid in. That is the same day for some currencies and a day or two later for others. Sandbox collapses that wait so you can watch a split land and run a withdrawal against it in one sitting.
This is the one place sandbox does not reproduce live timing. Anything your integration does with the gap between `pending_balance` and `available_balance` will not be exercised here: holding a payout until funds clear, showing a customer when money becomes available, retrying a transfer that failed for lack of balance. Build those against what [Balances](/connect/balances) documents.
***
## Withdrawals in sandbox
A withdrawal in sandbox is created the same way as live, moves to `processing`, and resolves to `completed` after a short delay rather than reaching a real destination.
By default it resolves `completed`. For `bank_transfer` and `crypto`, sending specific destination values forces the outcome instead:
| Method | Outcome | Field to send |
| - | - | - |
| `bank_transfer` | `completed` | `bank_code: "999"`, `account_number: "0001112223"` |
| `bank_transfer` | `failed` | `bank_code: "998"`, `account_number: "0001112224"` |
| `crypto` | `completed` | `wallet_address: "0x1111111111111111111111111111111111111111"` |
| `crypto` | `failed` | `wallet_address: "0x2222222222222222222222222222222222222222"` |
`mobile_money` has no trigger values; it always resolves to the default outcome. See [Payouts](/connect/payouts) for the request and its fields.
***
## Related
* [Sandbox environment](/integrate/sandbox)
* [Test payment outcomes](/integrate/sandbox#test-payment-outcomes)
* [Capabilities](/connect/capabilities)
* [Requirements](/connect/requirements)
* [Balances](/connect/balances)
* [Payouts](/connect/payouts)
# Transfers
Source: https://docs.bachs.io/connect/transfers
Move funds between your platform balance and an account you own.
A **transfer** moves an amount from one available balance to another. It runs between your platform and an account you own, in either direction, and settles instantly against both balances.
Transfers are how a platform pays out a share it collected and how it recovers one. For the end-to-end flow, see [Split payments](/connect/split-payments).
Transfers move money. A transfer debits the source balance immediately and cannot be cancelled or reversed. Recovering one means creating a second transfer in the opposite direction, which only succeeds while the funds are still there.
***
## The two directions
There is no `source` field. The debited side is always whoever is authenticated, so the direction follows from how you authenticate.
**To an account.** Authenticate as your platform and name the account in `destination`.
```mermaid theme={"dark"}
flowchart LR
P["Your available balance"] -->|debit| A["Account available balance"]
```
**Back to your platform.** Send `X-Account-Id` to act as the account, and set `destination` to `self`.
```mermaid theme={"dark"}
flowchart LR
A["Account available balance"] -->|debit| P["Your available balance"]
```
A transfer between two accounts is rejected with `400`. Moving value from one to the other is two transfers through your platform.
***
## What a transfer requires
| | To an account | Back to your platform |
| - | - | - |
| API key scope | `transfers:write` | `transfers:write` |
| Your `connect` capability | `active` | Not required |
| The account's `transfers` capability | `active` | `active` |
| Source `available_balance` | At least `amount`, in `currency` | At least `amount`, in `currency` |
The account needs `transfers` active in both directions, because the capability governs its participation rather than one side of it. Your `connect` capability is only checked on the outbound direction, so a restricted platform can still recover funds it is liable for. See [Become a platform](/connect/become-a-platform).
***
## Rules
**One currency, no conversion.** `currency` has to be a currency both balances already hold. A transfer never converts, and never creates a balance in a currency the destination did not hold.
**Available balance only.** Transfers draw on `available_balance`, not `pending_balance`. A charge has to settle before its funds can be transferred. See [Balances](/connect/balances).
**Never below zero.** A transfer above the source's `available_balance` is rejected with `INSUFFICIENT_BALANCE`. No debt is recorded, so once an account withdraws its balance, that money cannot be recovered. A lost dispute is the one exception to this floor: it can drive `available_balance` negative directly, and while any currency is negative, every transfer for that party is blocked, not only in that currency. See [Disputes](/connect/disputes).
**Amounts are decimal strings** in `currency`, greater than zero, e.g. `"7000.00"`.
***
## Status
`status` is `paid` once the movement has been recorded against both balances, and `pending` while it has not. It is derived from the underlying movement rather than stored, so it cannot disagree with the balances.
***
## What kind of movement
`kind` says what a transfer is, since direction alone cannot say it.
| `kind` | Meaning |
| - | - |
| `payout` | An account's share of a charge you made, moved to it on settlement. See [Destination charges](/connect/split-payments/destination). |
| `manual` | A transfer created directly on this endpoint, tied to no charge. |
`kind` is not a request field. `POST /v1/transfers` always creates `manual`; `payout` is written only by settlement. The platform's own cut of a sale is never a transfer: it is its own object, read at [Platform fees](/connect/platform-fees). A direct charge settles no transfer at all for the platform's cut, and a destination charge settles at most one `payout` transfer.
Filter the list to one kind:
```bash theme={"dark"}
curl "https://sandbox-api.bachs.io/v1/transfers?kind=payout&connected_account_id=acct_3Wq8ZfT1yHnJ5sVe" \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
A `kind` outside `payout` or `manual` is rejected with `400`. This includes `platform_fee`: that value is retired, and the platform's cut now lives at [Platform fees](/connect/platform-fees) instead.
***
## What a payout transfer carries
A destination charge states its split one of two ways, and the `amount` on the `payout` transfer it settles depends on which:
* **`platform_fee`** (fee-first). The account is meant to keep the gross minus your cut, but the transfer itself carries the sale's full gross. Your cut is struck separately, as its own [platform fee](/connect/platform-fees) record, and is not subtracted from the transfer amount.
* **`transfer_data.amount`** (share-first). The transfer carries exactly what the account was contracted to receive, its net. No platform fee record exists for this charge.
Summing `payout` transfers for an account tells you what it was credited on, not what it earned from customers: on a fee-first split, that sum is too high by exactly your platform fee for those charges. To get the account's true net across a mix of split styles, subtract its [platform fees](/connect/platform-fees) for the same charges from the sum of its `payout` transfers.
***
## Grouping
`transfer_group` tags a transfer as part of a set. Use the id of the charge that funded the shares, and reuse it on every transfer for that charge, including a later recovery. Every transfer returns the group you set.
```bash theme={"dark"}
curl "https://sandbox-api.bachs.io/v1/transfers?connected_account_id=acct_3Wq8ZfT1yHnJ5sVe&limit=50" \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
`GET /v1/transfers` · scope `transfers:read` · [full field reference →](/api-reference/transfers/list-transfers)
Returns transfers your platform was a party to, newest first. Pass `connected_account_id` to narrow to one account, in either direction.
Store the `transfer_group` against the order in your own database when you create the split. Listing does not filter on it, so your own record is what takes you from an order to its transfers.
***
## Confirming
Each transfer emits [transfer.created](/guides/webhooks/events/transfer-created). The account is the event's origin in both directions, so a platform subscribed with `event_source` `connect` and the account subscribed with `account` both receive it, whichever side created the transfer.
Do not treat a network or `5xx` error as proof the transfer was not created. Verify with [Get Transfer](/api-reference/transfers/get-a-transfer) before retrying, or retry with the same `Idempotency-Key`.
***
## Errors
Transfers return the standard [error envelope](/errors). Common cases:
* `INSUFFICIENT_BALANCE` (400), the source `available_balance` is below `amount` in that currency. Check the balance and the settlement date, then retry. See [Balances](/connect/balances).
* `ORGANIZATION_IN_DEBT` (400), the transfer's source has a negative balance in some currency, even one other than the transfer's own. Every transfer out is blocked until that currency's balance clears. See [Disputes](/connect/disputes).
* `FORBIDDEN` (403), the account's `transfers` capability is not `active`, your `connect` capability is not `active` on an outbound transfer, or the key lacks `transfers:write`.
* `NOT_FOUND` (404), `destination` is not an account you own. An account belonging to another platform returns `404` rather than `403`, so the response never confirms the id exists.
* `BAD_REQUEST` (400), the two sides are not a platform and one of its own accounts, or the `kind` filter on a list request is not `payout` or `manual`.
* `VALIDATION_ERROR` (400), `amount` is not a positive decimal string, or a required field is missing.
***
## Related
* [Split payments](/connect/split-payments)
* [Platform fees](/connect/platform-fees)
* [Paying an account without a charge](/connect/split-payments/separate-transfers)
* [Balances](/connect/balances)
* [Capabilities](/connect/capabilities)
* [transfer.created](/guides/webhooks/events/transfer-created)
# SnapKit, the live demo
Source: https://docs.bachs.io/demo
A fully working storefront on the Bachs sandbox: one-time payments, subscriptions, free trials, pay-what-you-want, and the overlay checkout, all clickable.
[SnapKit](https://snapkit.bachs.io) is a fictional screenshot API built by Bachs to show the whole payment stack working end to end. Every button on its pricing page opens a real Bachs checkout through the [overlay SDK](/guides/checkout/overlay-checkout), against the sandbox, with test cards. Nothing is mocked: sessions are minted by the real API, webhooks fire, subscriptions renew.
**Live demo**: [snapkit.bachs.io](https://snapkit.bachs.io). No account needed; pay with the test card shown on the checkout.
## What you can test
Each pricing button demonstrates a different billing model, and each maps to a guide you can build from.
| On the demo | What it demonstrates | Build it yourself |
| - | - | - |
| Starter pack, 1 to 20 | A one-time product bought with `quantity` | [Accept a payment](/guides/checkout/checkout-sessions) |
| Growth pack | The simplest flow: one product, one payment | [Accept a payment](/guides/checkout/checkout-sessions) |
| Pro Unlimited, monthly or annual | A subscription with a 14-day free trial: card saved now, first charge at trial end | [Free trials](/guides/subscriptions/trials) |
| Tip jar | A pay-what-you-want product: the customer picks the amount at checkout | [Products](/guides/products/overview) |
Two switches change how the buttons behave:
* **Monthly / Annual** swaps which Pro product the button sells, since a billing interval is fixed per product.
* **Overlay / Hosted page** opens the same checkout session in the on-page overlay or as a full-page redirect, so you can compare both integrations on identical sessions.
## How a purchase flows
Your first buy click asks for an email and optional name. Use your real inbox and the checkout greets you, the customer record is yours, and receipts land in your mail. Continue as guest and everything runs under a shared demo customer instead.
The sandbox checkout shows a test-card panel with success and failure cards, so you can simulate any outcome. Use the toggle to open it in the overlay or as the hosted page, whichever you want to feel.
The payment settles, webhooks fire, and buying Pro starts a real subscription (`trialing` through the 14-day trial) that renews on schedule.
## How it is built
The demo is a small Next.js app, and its architecture is the one we recommend to every merchant:
1. A single server route holds a **sandbox secret key** and mints checkout sessions for a fixed set of demo products. The browser only ever receives a `checkout_url`; amounts, products, and quantity limits are enforced server-side, behind a rate limit.
2. The page adds `bachs.js` with a script tag, exactly as in the [overlay guide's quick start](/guides/checkout/overlay-checkout#quick-start), and opens sessions with `Bachs.Checkout.open({ checkoutUrl })`.
3. Fulfilment belongs to webhooks, not the browser. The demo's toasts react to client events for UX only, exactly as the [overlay guide](/guides/checkout/overlay-checkout#fulfil-the-order-with-a-webhook) prescribes.
## Next steps
* [Add an overlay checkout](/guides/checkout/overlay-checkout): the SDK the demo is built on.
* [Accept a payment](/guides/checkout/checkout-sessions): the session-create flow behind every button.
* [Sandbox](/integrate/sandbox): the environment the whole demo runs in.
# API Keys
Source: https://docs.bachs.io/developer-portal/api-keys
Secret keys that authenticate your backend requests to Bachs. Scoped, rotatable, and revocable on demand.
API keys are how your server authenticates with Bachs. Every request to the API must carry a key in the `Authorization` header. Keys are environment-scoped. A key created in sandbox will not work against the live API, and vice versa.
The **API Keys** tab in the Developer Portal is where you create, inspect, and manage every key for your organization.
***
## Creating a Key
Click **+ Create Secret Key** to open the key creation flow.
You provide two things:
**Name**: A label you choose. Use something that identifies the service or environment using the key, like `production-backend` or `data-export-worker`. The name is for your reference only.
**Scopes**: The permissions this key carries. You can select all scopes for a fully privileged key, or restrict it to exactly what the key needs.
The plain key is shown exactly once, immediately after creation. Copy it and store it in a secrets manager or environment variable before closing the dialog. There is no way to retrieve it again. If you lose it, rotate the key to generate a new one.
***
## Scopes
Scopes limit what a key can do. A key without the required scope returns a `403 Forbidden` response for that operation, regardless of other permissions.
| Category | Scope | What it allows |
| - | - | - |
| **Payments** | View payments | Read payment records |
| **Payments** | Create & charge | Create checkouts and initiate charges |
| **Payouts** | View payouts | Read payout and withdrawal records |
| **Payouts** | Send payouts | Create withdrawals |
| **Refunds** | View refund | Read refund records |
| **Refunds** | Issue refund | Create refunds against charges |
| **Disputes** | Manage disputes | Read and respond to disputes |
Leaving **Select all** checked creates an unrestricted key that can perform any operation your organization has access to.
Scope down keys wherever possible. A key used by a webhook handler that only needs to read payments should not also have permission to send payouts.
***
## Key Detail
Click any key in the list to open its detail panel.
The panel shows:
* **Name and creation date**: When the key was created.
* **Key prefix**: A short identifier (e.g. `8dab0752`) that lets you match a key to a log entry without exposing the full secret.
* **Scopes**: All permissions currently assigned to this key, shown as tags.
* **API requests**: A chart of successful and failed requests from this key over the past week.
### Editing Scopes
Click **Edit scopes** to change the permissions assigned to an existing key. The key itself is not regenerated, only the allowed operations change. Changes take effect immediately.
### Rotating a Key
Click **Rotate API Key** to invalidate the current secret and generate a new one. The new plain key is shown once. The key's ID, name, and scopes are preserved.
Rotate a key whenever you suspect it has been leaked, when offboarding a service, or as part of a regular key hygiene policy.
Rotating a key immediately invalidates the old secret. Any request using the old key will fail with `401 Unauthorized`. Update your deployment before or immediately after rotating.
### Revoking a Key
Click **Revoke key** to permanently delete the key. This cannot be undone. All requests authenticated with this key will immediately return `401 Unauthorized`.
Only revoke a key you are certain is no longer in use. If you are unsure, rotate instead. That gives you time to update your deployment before the old credential stops working.
***
## Using a Key
Pass your key as a Bearer token in the `Authorization` header:
```http theme={"dark"}
Authorization: Bearer sk_live_...
```
```python Python theme={"dark"}
import httpx
response = httpx.post(
"https://api.bachs.io/v1/checkout-sessions",
headers={"Authorization": "Bearer sk_live_..."},
json={...},
)
```
```javascript Node.js theme={"dark"}
const response = await fetch("https://api.bachs.io/v1/checkout-sessions", {
method: "POST",
headers: {
Authorization: "Bearer sk_live_...",
"Content-Type": "application/json",
},
body: JSON.stringify({...}),
});
```
```go Go theme={"dark"}
req, _ := http.NewRequest("POST", "https://api.bachs.io/v1/checkout-sessions", body)
req.Header.Set("Authorization", "Bearer sk_live_...")
```
***
## Rate Limits
Key creation is rate-limited to **one new key per minute** per user. This is a safeguard, not a quota. If you need to create multiple keys in quick succession during setup, space the requests out by at least 60 seconds.
***
Trace requests back to the key that made them.
Sandbox keys are separate from live. Generate them from within your sandbox account.
# Events
Source: https://docs.bachs.io/developer-portal/events
Monitor webhook delivery health per endpoint. Inspect every attempt, see the exact payload delivered, and retry anything that didn't land.
The **Events** tab gives you a real-time view of how Bachs is delivering events to your webhook endpoints. You can see success rates over time, drill into individual delivery attempts, inspect the full JSON payload that was sent, and manually retry any event. No code required.
***
## Selecting an Endpoint
The Events tab is scoped to one endpoint at a time. Use the dropdown at the top to switch between your registered webhook destinations. The performance metrics and delivery log below update to reflect only that endpoint's activity.
***
## Performance Overview
The top panel summarizes delivery health for the selected endpoint over the chosen time range.
| Metric | What it measures |
| - | - |
| **Total deliveries** | All delivery attempts made to this endpoint in the period |
| **Successful** | Attempts that received a `2xx` response from your server |
| **Failed** | Attempts where your server returned a non-`2xx` response or timed out |
| **Success rate** | Successful as a percentage of total |
The chart plots successful and failed attempts over time. A rising failed line means your endpoint is returning errors or becoming unreachable. Use it alongside your server logs to triage delivery degradation quickly.
**Time range**: Use the dropdown at the top right of the chart to change the reporting window. The default is the last 7 days.
***
## Endpoint Details
The right panel on the Events tab shows the configuration for the selected endpoint at a glance:
* **Name**: The label you gave this destination.
* **URL**: The HTTPS address deliveries are sent to. Click the copy icon to grab it without revealing the full path in your browser history.
* **Events**: The event types this endpoint is subscribed to. The count badge shows how many subscriptions are active.
* **Created At**: When the endpoint was registered.
* **Signing secret**: The HMAC secret used to sign deliveries to this endpoint. Masked by default. Click the eye icon to reveal it or the copy icon to grab it directly. You only see it if your role has permission to manage webhooks.
To rotate the signing secret, click **Rotate secret**. This takes effect immediately. Update your server before rotating if you cannot tolerate a brief verification gap.
***
## Delivery Attempts
Below the performance chart, every delivery attempt to the selected endpoint is listed in reverse chronological order.
| Column | What it shows |
| - | - |
| **Date** | When the delivery was attempted |
| **Status** | The HTTP status your server returned, e.g. `204 OK`, `500`, or `timeout` |
| **Event** | The event type that was delivered, e.g. `collection.succeeded` |
| **Event ID** | The unique identifier for this event, prefixed `evt_` |
| **Time** | The time of day the attempt was made |
Click **Refresh** to pull the latest attempts without reloading the page.
***
## Inspecting an Event
Click any row in the delivery attempts table to open the event detail panel.
The panel shows:
**Delivery status**: The HTTP response code your server returned for this attempt, shown with color coding. Green means success, red means failure.
**Event details**
| Field | Description |
| - | - |
| `Event type` | The event category, e.g. `checkout.completed` |
| `Provider event ID` | The upstream event identifier from the payment provider, if applicable |
| `Callback URL` | The exact URL this delivery was sent to |
**Payload**: The complete JSON body that was `POST`ed to your endpoint. This is exactly what your server received. Click the copy icon to grab the full payload for use in local testing or debugging.
***
## Retrying an Event
Click **Retry** on any event detail to re-deliver that event to the endpoint. You need permission to manage webhooks to see this button.
Bachs enqueues a new delivery attempt immediately. The original event payload is sent again, not regenerated. Use this to recover from transient failures, server downtime, or misconfigured handlers without waiting for a new event to fire.
Retry delivers the original payload. If your handler is idempotent (which it should be), retrying is always safe. Check the `id` field in the envelope to deduplicate if needed.
A retry you start is tried once. Nothing is delivered to a disabled endpoint, so [turn it back on](/guides/webhooks/overview#turn-it-back-on) before you retry. For how we retry on our own, see [Retries](/guides/webhooks/overview#retries).
You can also replay events programmatically via the API:
Re-deliver any past event to your endpoint via the API.
***
Manage your endpoint destinations and signing secrets.
Trace the API requests that triggered these events.
# Test Webhooks Locally
Source: https://docs.bachs.io/developer-portal/local-testing
Receive live webhook events on your own machine and verify a real signature, before you have anything deployed.
Webhooks are awkward to develop against. Your handler has to be reachable from the internet before you can see a single event, which usually means deploying unfinished code or running a tunnel.
Forwarding removes that. The [Bachs CLI](/cli/overview) holds an outbound connection to Bachs and hands each event to a port on your machine, so you can write and debug your handler against real signed events with nothing deployed and no public URL.
This guide assumes the CLI is installed and paired with your account. If it is not, [install it and run `bachs login`](/cli/overview#install) first. It takes about a minute.
***
## Start forwarding
Point the CLI at the port your handler listens on.
```bash Forward every event theme={"dark"}
bachs listen --forward-to localhost:3000/webhooks
```
```text Output theme={"dark"}
Ready! Forwarding sandbox events to http://localhost:3000/webhooks
Your webhook signing secret is whsec_a1b2c3... (^C to quit)
Session whls_8f2e... · 26 event type(s)
```
Copy the signing secret into your local environment. It is created for this session, so your production endpoint's secret stays in production while your verification code runs against real signed requests.
To forward only what you are working on, name the events:
```bash Forward two event types theme={"dark"}
bachs listen --forward-to localhost:3000/webhooks --events collection.succeeded,refund.paid
```
***
## Send yourself an event
Leave `listen` running and open a second terminal. You do not need a real payment to exercise your handler:
```bash Emit a sample event theme={"dark"}
bachs trigger collection.succeeded
```
The event goes through the ordinary delivery path, so it is signed and recorded exactly like a real one. It is sandbox only, for the good reason that a fake `collection.succeeded` in production could have you fulfil an order nobody paid for.
You can also redeliver something that already happened:
```bash Replay a past event theme={"dark"}
bachs events replay evt_3ab4e0d5d27445cf8a52ab3d8cb8f0b1
```
Every delivery prints as it arrives, with the status your handler returned and how long it took:
```text Terminal theme={"dark"}
✓ collection.succeeded evt_3ab4e0d5 200 [42ms]
✗ refund.paid evt_7c1f22a9 500 [131ms]
```
A `✓` means your handler returned a `2xx`. Anything else shows the status or the connection error inline, which is usually enough to find the problem without adding logging.
Seeing nothing at all? Your endpoint filter may not include the type you are sending. `bachs events list --undelivered` shows events that matched no destination.
***
## Verify the signature
This is the part worth getting right locally, because a handler that verifies incorrectly usually still looks fine until it starts rejecting live traffic.
Each forwarded request carries `X-Bachs-Signature-V2` in the form `t={timestamp},v1={signature}`. Read the raw body before parsing it, because re-serializing JSON changes the bytes and breaks verification.
```python Verify a forwarded request theme={"dark"}
import hashlib
import hmac
import time
def verify(header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
timestamp = int(parts["t"])
if abs(time.time() - timestamp) > tolerance:
return False
signatures = [
value
for key, value in (p.split("=", 1) for p in header.split(","))
if key == "v1"
]
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)
```
Accept the request if **any** `v1` value matches. The header carries one signature per currently valid secret, so a handler that checks only the first will start rejecting deliveries during a [secret rotation](/guides/webhooks/overview#rotating-a-signing-secret).
Because the forwarded request is signed with this session's own secret, the code above is the same code you will run in production. Only the secret differs.
***
## How it works
Your machine opens the connection outbound to Bachs, so nothing needs to reach you. There is no public URL, no inbound port, and no firewall change, and it works behind NAT and on corporate networks because it is an ordinary outgoing HTTPS connection.
A session is a webhook destination like any other, so event filtering, delivery records, and the [Events](/developer-portal/events) view all behave the same way. The only difference is the last step: instead of sending to a URL, Bachs hands the event to the connection your machine is holding, and the CLI sends it to your local port.
Press `Ctrl-C` and the session closes. Your registered destinations are untouched: starting a session changes nothing in your account, and stopping one leaves nothing behind. If your machine sleeps or loses its network, the CLI reconnects on its own and tells you while it is trying.
Deliveries to a forwarding session are not retried. If your handler is down, your machine is asleep, or your code returns a `500`, the event is shown as failed and dropped. You are watching the terminal, so a backlog arriving hours later would not help you. Registered URL destinations retry as normal.
***
## Replaying without the CLI
Replay is a plain API call and needs no session. It works against your registered destinations too, which makes it useful in production and not only while developing:
```bash Replay over HTTP theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/webhooks/replay \
-H "Authorization: Bearer sk_sandbox_..." \
-H "Content-Type: application/json" \
-d '{"event_id": "evt_3ab4e0d5d27445cf8a52ab3d8cb8f0b1"}'
```
```json Response theme={"dark"}
{
"event_id": "evt_3ab4e0d5d27445cf8a52ab3d8cb8f0b1",
"attempt_id": "wha_6f1e40f6bdf84c1980e1e1f6407f3f8a",
"attempt_no": 3,
"event_type": "collection.succeeded"
}
```
***
## Going live
Forwarding works against production too. `bachs whoami` tells you which environment you are paired with, and the CLI marks a live session so you can tell the two apart while forwarding.
Your customers' real events reach your machine on a live session, so use the sandbox unless you have a reason not to.
Once your handler is deployed, register its URL as a destination and it receives events the same way, with retries. See [Setting up webhooks](/guides/webhooks/overview).
***
## Next steps
* [Bachs CLI](/cli/overview) for everything else the CLI does, including calling any API operation from the terminal.
* [Setting up webhooks](/guides/webhooks/overview) for registering a destination your server can receive on.
* [Replay webhook events](/guides/webhooks/replay-events) for the full replay API, including lookup by charge or reference.
* [Events](/developer-portal/events) to inspect deliveries and responses in the dashboard.
# Logs
Source: https://docs.bachs.io/developer-portal/logs
A full record of every API request your keys have made. Method, path, status code, duration, and the exact request and response bodies.
The **Logs** tab records every API call your keys have made to Bachs. It's the fastest way to diagnose a failed integration, confirm a request actually left your server, or inspect exactly what was sent and returned without instrumenting your own code.
Logs are scoped to your current environment. Sandbox requests only appear in sandbox; live requests only appear in live.
***
## The Log List
Each row in the log is one API request. The list is sorted with the most recent at the top.
| Column | What it shows |
| - | - |
| **Date** | The calendar date the request was made |
| **Status** | The HTTP status code Bachs returned. Green for `2xx`, red for `4xx`/`5xx` |
| **Method + Path** | The HTTP method and endpoint path, e.g. `POST /v1/checkout-sessions` |
| **Time** | The time of day the request was made |
**Search and filter**: Use the search bar to narrow the list by method, path, or status code. Useful when triaging a specific failing endpoint or tracing a particular sequence of requests.
***
## Log Detail
Click any row to open the full detail panel for that request.
The panel shows:
**Header**: The HTTP method and path at the top, color-coded by method. The exact timestamp and response status with duration appear directly below.
**Log details**
| Field | Description |
| - | - |
| `Time` | Full timestamp of the request in your local timezone |
| `Duration` | How long Bachs took to respond, in milliseconds |
| `API key ID` | The short identifier of the key that made this request. Links to the matching key in the API Keys tab |
| `Method` | `GET`, `POST`, `PATCH`, `DELETE`, etc. |
| `Path` | The full endpoint path, e.g. `/v1/checkout-sessions` |
**Query params**: Any query string parameters attached to the request, shown as a formatted block.
**Request body**: The exact JSON body your server sent. Formatted and syntax-highlighted. Click the copy icon to paste it directly into a curl command or your API client for reproduction.
**Response body**: The exact JSON Bachs returned. This is the authoritative record of what your server received. Useful when the response your code logged differs from what you expect.
***
## Common Uses
**Debugging a `4xx`**: Open the failed request and check the request body against the API reference. The response body typically contains an error message and a machine-readable code. No guessing what was sent.
**Confirming a request was made**: If a payment or webhook isn't behaving as expected, the log confirms whether the API call was received by Bachs at all. If there is no log entry, the request never reached the API.
**Tracing a key**: The `API key ID` field links directly to the key in the API Keys tab. When a key has been rotated or revoked, you can use the log to verify the last request it made before the credential changed.
**Measuring latency**: Duration is logged per request. If your integration has a timeout problem, the log tells you whether the latency is on Bachs's side or your network.
***
See which key made each request and manage its permissions.
Inspect the webhook deliveries that followed these API requests.
# Developer Portal
Source: https://docs.bachs.io/developer-portal/overview
Debug, manage, and grow your Bachs integration using the Developer Portal.
The Developer Portal is where you control how your application talks to Bachs. Create and manage API keys, configure webhook destinations, inspect every delivery attempt, and drill into raw API request logs without leaving your dashboard.
It is scoped to your current environment. Keys, endpoints, and event history are all isolated between sandbox and live.
***
## How to Access
From any page in your dashboard, click **Developer Portal** at the bottom left of the sidebar.
***
## What's Inside
The portal has four tabs. Each one owns a distinct part of your integration workflow.
| Tab | What it does |
| - | - |
| **API Keys** | Create and manage secret keys that authenticate your backend requests to Bachs |
| **Webhook** | Register and configure the HTTPS endpoints Bachs delivers events to |
| **Events** | Monitor delivery attempts per endpoint, inspect payloads, and retry failed deliveries |
| **Logs** | See every API request your keys have made, with full request and response bodies |
***
Create scoped keys, rotate them, and revoke what you no longer need.
Register destinations and subscribe to the events that matter to you.
Monitor delivery health and retry any failed or missed event.
Inspect raw API calls: method, path, status, and full request/response bodies.
# Errors
Source: https://docs.bachs.io/errors
How the Bachs API reports errors: the error object, HTTP status codes, and how to handle them.
Bachs uses conventional HTTP status codes to indicate the result of a request. A `2xx` code means success. A `4xx` code means the request failed given the information provided, such as a missing field or a declined payment. A `5xx` code means something went wrong on our side; these are rare.
```json Validation (400) theme={"dark"}
{
"detail": "Missing required field(s): name, price",
"error_code": "VALIDATION_ERROR",
"doc_url": "https://docs.bachs.io/api-reference/error-reference#general",
"errors": [
{ "field": "name", "message": "Field required", "type": "missing" },
{ "field": "price", "message": "Field required", "type": "missing" }
]
}
```
```json Not found (404) theme={"dark"}
{
"detail": "Product not found",
"error_code": "NOT_FOUND",
"doc_url": "https://docs.bachs.io/api-reference/error-reference#general"
}
```
```json Unauthorized (401) theme={"dark"}
{
"detail": "Invalid API key",
"error_code": "UNAUTHORIZED",
"doc_url": "https://docs.bachs.io/api-reference/error-reference#general"
}
```
```json Forbidden (403) theme={"dark"}
{
"detail": "This key does not have the required scope: products:write",
"error_code": "FORBIDDEN",
"doc_url": "https://docs.bachs.io/api-reference/error-reference#general"
}
```
## The error object
When a request fails, the API returns a flat error object instead of the resource. Branch on `error_code`, which is stable, rather than on `detail`, which is a human-readable message that may change.
```json theme={"dark"}
{
"detail": "Product not found",
"error_code": "NOT_FOUND",
"doc_url": "https://docs.bachs.io/api-reference/error-reference#general"
}
```
Stable, machine-readable code. Always present. Look it up in the [Error Reference](/api-reference/error-reference).
Human-readable description of what went wrong.
Optional link to an entry in the [Error Reference](/api-reference/error-reference). Some errors omit it.
Optional field-level details on some validation errors. When present, use each entry's `field` and `message` to help the user correct the input. The example below also includes `type`; some requirement-field errors provide `code` instead. Handle additional entry fields without requiring either one.
Present on limit errors only. Structured context such as `requested_amount` and `max_allowed_amount`.
### Validation errors
A `VALIDATION_ERROR` returns `400`. It can provide only `detail`, or it can also include `errors[]` with field-level messages. Handle both shapes:
```json theme={"dark"}
{
"detail": "Missing required field(s): name, price",
"error_code": "VALIDATION_ERROR",
"doc_url": "https://docs.bachs.io/api-reference/error-reference#general",
"errors": [
{ "field": "name", "message": "Field required", "type": "missing" },
{ "field": "price", "message": "Field required", "type": "missing" }
]
}
```
```json Fixed price validation (400) theme={"dark"}
{
"detail": "price: amount is required for a fixed price.",
"error_code": "VALIDATION_ERROR"
}
```
## HTTP status codes
| Status | Meaning |
| - | - |
| `200` | OK. The request succeeded. |
| `201` | Created. A new resource was created. |
| `204` | No Content. The request succeeded with no body (deletes). |
| `400` | Bad Request. The request was unacceptable, often a missing or invalid parameter. Request validation errors (`VALIDATION_ERROR`) use this status, with field-level `errors[]` when available. |
| `401` | Unauthorized. No valid API key was provided. |
| `403` | Forbidden. The key lacks permission for this action. |
| `404` | Not Found. The resource does not exist in your scope. |
| `409` | Conflict. The request conflicts with the current state, such as a duplicate. |
| `422` | Unprocessable. The request was well-formed, but a business rule rejected it, for example an unsupported checkout currency. |
| `428` | Precondition Required. A required precondition is missing. |
| `429` | Too Many Requests. You hit the rate limit. Back off and retry. |
| `500`, `502`, `503` | Server Errors. Something went wrong on our side. These are rare. |
For every `error_code` and what to do about it, see the [Error Reference](/api-reference/error-reference).
## Request ID
Every response, success or error, includes an `x-request-id` header.
```http theme={"dark"}
x-request-id: 2f31edcd-0bba-4a89-98b9-533921e42f26
```
Log it, and include it when you contact support. It is the fastest way for us to locate your exact request.
## Handling errors
Use `error_code`, not `detail` or the HTTP status alone, to drive your logic. It is stable across releases.
For `VALIDATION_ERROR`, show `detail`. If `errors[]` is present, also show each field-level message beside the relevant input.
Back off before retrying a read after `429` or `5xx`. For a write, a timeout or `5xx` does not prove that nothing happened. Keep the same request and [`Idempotency-Key`](/guides/idempotency) where the endpoint supports it, then follow that endpoint's retrieval or reconciliation guidance. Do not start the same uncertain operation with a new key.
Rate limits and back-off headers (`Retry-After`, `X-RateLimit-Reset`) are documented on [API Standards](/api-reference/api-standards#rate-limits).
# Age Requirements
Source: https://docs.bachs.io/for-you/age-requirements
Who can open a Bachs account, and how registration works for anyone below the age of majority in their country.
You must be at least 18, or the legal age of majority where you live, to open and control a Bachs account on your own.
If you are below that age, you are not shut out. You can register with the consent of a parent or legal guardian, who takes responsibility for the account alongside you.
***
## Why there is an age rule at all
A Bachs account is not a login. It is a commercial relationship: it moves real money, it settles to a real bank account, and it carries obligations under the [Terms of Service](https://app.bachs.io/legal/terms) that someone has to be legally capable of accepting.
In most places a contract signed by a minor is voidable, which is a problem for everyone involved. It leaves the merchant without enforceable protections, and it leaves us unable to meet the identity and anti-money-laundering duties our own licences depend on. Requiring an adult on the account is what makes the arrangement hold.
That is also why the guardian route is not a formality. A guardian is not vouching for you; they are accepting the account's obligations.
***
## The threshold is local, not universal
Bachs operates across several markets, and the age of majority is not the same in all of them. Rather than name one number that would be wrong somewhere, the rule follows your jurisdiction: **18, or the legal age of majority in your country, whichever applies to you.**
If you are unsure which applies, assume 18 and submit the consent form. We would rather review a form you did not strictly need than discover the gap during verification.
***
## Registering with guardian consent
Sign up the way any merchant does. Nothing about the flow changes, and you do not need to declare your age up front.
Your parent or legal guardian fills in the [Bachs Parent/Guardian Consent Form](https://forms.gle/dTvxJ2Bd3Edcwifi9). They complete it themselves, in their own name. It asks for:
* **Your details** as the applicant: full name, the email you used to register, and date of birth.
* **Their details** as the guardian: full name, email, date of birth, National Identification Number (NIN), and their relationship to you.
* **A signed consent statement** confirming they are your parent or legal guardian, that they consent to your registration and use of Bachs, and that they accept the [Terms of Service](https://app.bachs.io/legal/terms) on your behalf.
The email must match the one on the account. It is how we connect the form to your registration, and a mismatch is the most common reason a submission stalls.
We verify the guardian's identity against the details they gave, in the same way we verify any person on an account. If the automated check cannot confirm them, we will ask for a copy of their ID.
This is a check on the guardian, not on you. It is the point of the exercise: an identified adult who has accepted the terms.
Once consent is verified, your account behaves like any other. You can accept charges and receive settlements normally.
Until consent is verified, your account can exist but cannot accept charges or settle funds. Submitting the form early avoids discovering this at the point where you have a customer waiting to pay.
***
## What consent actually means
Worth being clear about, because it is easy to read the form as paperwork.
The guardian is named on the account and accepts the Terms of Service for it. They are responsible for how it is used, including money that moves through it. In practice this means the guardian should be someone genuinely willing to stand behind the business, not simply an adult willing to sign something.
You continue to operate the account day to day. Consent does not hand it over; it puts an accountable adult behind it.
***
## After you reach the age of majority
Consent does not need renewing on your birthday, and nothing breaks. But once you reach the age of majority in your country, tell us so we can remove the guardian from the account and leave it entirely in your name.
***
## If something is unclear
Age, majority, and guardianship interact differently across jurisdictions, and the edge cases are real: guardians who are not parents, applicants who move country mid-verification, businesses already registered in an adult's name.
If your situation does not match the steps above, contact [support@bachs.io](mailto:support@bachs.io) before submitting. A short question now is faster than a rejected verification later.
***
## Related
* [Supported businesses](/for-you/supported-businesses) for the business types Bachs accepts.
* [Terms of Service](https://app.bachs.io/legal/terms) for the agreement a guardian is accepting.
# Fees
Source: https://docs.bachs.io/for-you/fees
A complete breakdown of every fee on Bachs processing, withdrawals, conversions, and more.
Bachs charges only for what you use. There are no setup fees, no monthly costs, and no hidden charges. Every fee is shown upfront in your dashboard before you confirm any action.
***
## Processing Fees
Processing fees are deducted from each payment at the time of collection in the currency they pay in. The rate depends on the payment method your customer uses.
### NGN Payments
| Method | Fee | Notes |
| - | - | - |
| Checkout bank transfer | 1.5% capped at NGN 2,000 | Bank transfers started from a checkout |
| Virtual account deposit | 1% capped at NGN 300 | Deposits into a fixed NGN virtual account |
| Local Cards Beta | 2% | Nigerian-issued Visa and Mastercard |
### Global Payments
| Method | Fee | Notes |
| - | - | - |
| Cards | 5% + \$0.40 | Visa, Mastercard, and major digital wallets issued in the US |
| International Cards | +1.5% on top of the base rate | Charged when the card was issued outside the US |
Your USD balance can fund supported stablecoin payouts or payouts to your own bank accounts and mobile money wallets in supported currencies. See [Supported currencies](/guides/payouts/global-payouts).
### International Cards
A card issued outside the US costs more to process, and you pay that extra amount. It is 1.5% of the payment, charged on top of the base card rate.
You pay this fee even when your customer pays the processing fee. Choosing who pays the processing fee applies to that fee only.
The amount is taken out of the payment, together with the processing fee, and you see it in the fee breakdown on that payment.
### Mobile Money
| Method | Fee | Notes |
| - | - | - |
| M-Pesa, MTN MoMo, Airtel Money, and others | 3.5% | Supported African markets |
Mobile money is supported across major African markets and all your payments get converted to your preferred settlement currency without any extra hidden charges.
### Crypto
| Method | Fee | Notes |
| - | - | - |
| USDT / USDC | 1.5% | Supported EVM-compatible networks |
Crypto payments settle to your USD balance. From there you can withdraw to a supported stablecoin wallet or fund a fiat withdrawal to your own account in a supported currency.
***
## Withdrawal Fees
Move funds from your Bachs balance to your own bank account, mobile money wallet or supported stablecoin wallet. You can initiate a payout manually or configure [Automatic payouts](/guides/payouts/payout-schedules) for eligible settled customer collections.
The fee depends on the **currency you withdraw from** and the payout method.
Before confirming a payout, review the conversion rate, the amount your destination receives and the total debit including the fee. Account-specific fees can differ from the default rates below. See [Supported currencies](/guides/payouts/global-payouts).
### Bank Transfer
| Withdraw from | Fee |
| - | - |
| NGN | NGN 50 flat |
| USD | 1%, minimum \$1.00, capped at \$15.00 |
| USDT | 1%, minimum 1 USDT |
| Any other currency | 0.3% |
USD balances can be settled directly to your NGN account. The fee for this is 1% capped at \$15.00.
### Mobile Money
| Withdraw from | Fee |
| - | - |
| USD | 1%, minimum \$1.00 |
| USDT | 1%, minimum 1 USDT |
| Any other currency | 0.3% |
### Crypto
| Withdraw from | Fee |
| - | - |
| USDT / USDC | 1%, minimum 1 |
| Any other asset | 1%, minimum 1 |
### How the Fee Is Applied
The fee is charged in the currency you withdraw **from**, and it is added **on top** of the amount you withdraw. The amount you ask for is the amount that arrives, and your balance is debited that amount plus the fee.
Either a flat amount or a percentage, priced from the amount that leaves your balance.
If the calculated fee falls below the minimum for that currency, the minimum is charged instead. If it rises above the cap, the cap is charged instead.
The full amount you asked for is converted at the current rate and sent to your destination. Your balance gives up that amount plus the fee.
**Example: NGN 500,000 to a Nigerian bank account**
A flat NGN 50 fee applies. NGN 500,000 arrives and NGN 500,050 leaves your balance.
**Example: \$80 USD withdrawn as USDT**
1% of \$80 is \$0.80, which is below the \$1.00 minimum, so \$1.00 is charged. \$80.00 is converted and sent, and \$81.00 leaves your balance.
**Example: \$10,000 USD withdrawn to a bank account**
1% of \$10,000 would be \$100, but if you choose to settle this to your Nigerian bank account, a cap of \$15.00 is applied. \$10,000 is converted and sent, and \$10,015 leaves your balance.
A withdrawal your balance cannot cover, once the fee is added, is rejected before it is submitted.
If you create a payout through the API, this is why `amount` and `total_debited` differ. Reconcile against `total_debited`. See [Create a payout](/guides/payouts/payout-using-api).
***
## When Your Funds Become Available
In some cases, money you collect is not withdrawable the instant a payment succeeds. It first clears a short settlement window, which depends on the **currency you collected in**.
| Collection currency | Available |
| - | - |
| NGN | Immediately |
| USD (including card payments and crypto) | 2 days after the payment |
| GHS, KES, ZAR, UGX, TZS, ZMW, XAF, XOF, RWF | 1 day after the payment |
| Any other currency | 1 day after the payment |
Your pending settlements are added to your balance every morning at **09:00 UTC**, on their scheduled day. The waiting period counts calendar days from the day the payment landed, so a payment collected at any hour on Monday on a 1-day settlement timeline becomes available at the Tuesday 09:00 UTC run.
Card payments that would become available on a Saturday or Sunday roll forward to the following Monday, since banks do not settle over the weekend. Other payment methods keep their raw date.
Once funds are available, they sit in your Bachs balance until you withdraw. There is no automatic withdrawal. You decide when the money moves.
***
## Currency Conversion
Convert between balances only when you need to. Bachs absorbs FX risk so you get a clean, predictable rate.
| Conversion | Fee |
| - | - |
| USD → NGN | Competitive FX rate, no hidden markup |
You are never forced to convert. You can hold a USD balance and withdraw as USDT or NGN on demand at the current rate.
***
## Refunds & Disputes
| Event | Fee |
| - | - |
| Refund | No Bachs fee |
| Early dispute warning | \$29.00 per payment |
| Dispute / Chargeback | \$15.00 per dispute |
Bachs does not charge a fee to issue a refund. Where the underlying payment provider charges a reversal cost, that amount is passed through unchanged and shown on the refund record.
An early dispute warning is sent by the card network before a customer files a chargeback. It gives you a chance to sort the problem out with the customer first. The fee is charged once per payment, even if that payment gets more than one warning.
Dispute fees apply when a customer initiates a chargeback through their card network. If a warned payment goes on to become a full dispute, you pay both fees. Keeping dispute rates low protects your account standing.
***
## Billing & Revenue Tools Coming Soon
These are optional add-ons. You only pay if you use them.
| Feature | Fee |
| - | - |
| Subscriptions & usage billing | +0.5% on billing volume |
| Invoicing | Included |
| Analytics & reporting | Included |
Subscription fees are applied on top of the base processing fee for the underlying payment. Invoicing, analytics, and reporting are available at no extra charge.
### Revenue Recovery Coming Soon
Recover failed or abandoned revenue automatically.
| Feature | Fee |
| - | - |
| Abandoned checkout recovery | Free to enable |
| Subscription dunning | Free to enable |
| Successfully recovered revenue | 5% |
There is no upfront cost to enable recovery. The 5% fee only applies to revenue that Bachs successfully recovers if nothing is recovered, you pay nothing.
***
## Fraud & Security
Fraud protection is built into every charge on Bachs. There is no percentage fee added for fraud screening. Pricing is transparent and event-based you will never see a line item you did not expect.
***
## Fee Visibility
Every charge in your dashboard shows a full breakdown: the amount collected, the processing fee deducted, and the net amount credited to your balance. Withdrawal records include the amount sent, the fee, and the total debited from your balance.
Your balance activity lists every movement of your available balance, each one named and linked to what caused it. Anything charged on its own rather than out of a payment appears there, such as a [dispute fee](/guides/disputes/overview).
***
How customers pay you and how you receive funds.
Full list of collection and withdrawal currencies.
# Pay-ins and Withdrawals
Source: https://docs.bachs.io/for-you/payins-payouts
How customers pay you, and how you receive your funds.
## How Customers Pay You
Customers can pay using any of the following methods at checkout.
Visa, Mastercard, and all major networks. Priced in USD or NGN.
Direct bank transfer in NGN.
MTN, M-Pesa, Airtel, Vodafone, and more. Available in GHS, KES, MWK, RWF, TZS, UGX, XAF, XOF, and ZMW.
USDT, USDC, ETH, SOL, and BNB across multiple networks.
All payment methods are available through a single checkout. Your customers see only the methods relevant to their currency and location.
***
## How You Get Paid
Funds collected through Bachs are held in your Bachs balance. You initiate withdrawals to your configured payout destination whenever you choose.
**Your first withdrawal takes a little longer.** The first payout from an account is held for a one-time review before it is sent, so it can take a few minutes longer to complete than the ones after it. This happens once per account: once a payout has completed, later withdrawals, including to a new destination, go through without the extra wait. It applies to any account you set up to hold and move its own money, whether you created it through the API or in the dashboard.
### Fiat Withdrawals
You can withdraw NGN to your registered Nigerian bank account. You can also withdraw to your own bank accounts in GHS, KES, TZS, UGX, GBP, USD, CAD and EUR, and your mobile money wallets in GHS, KES and TZS.
International routes use your USD balance. Payouts are available to all Bachs users. The payout method depends on the destination currency, and account verification and destination approval still apply. See [Supported currencies](/for-you/supported-currencies#withdrawals) for the full currency and method table, and [Supported currencies](/guides/payouts/global-payouts) for the bank or wallet details you need.
### Crypto Withdrawals
| Token | Network |
| - | - |
| USDT | Tron, Ethereum, BNB Chain, Solana |
| USDC | Ethereum, BNB Chain, Solana, Base |
Crypto withdrawals are sent to your configured wallet address. You set up your payout destination once in your dashboard under **Payouts**, and all future withdrawals go there.
***
## For Marketplaces Built on Bachs Beta
If you are building a marketplace or platform, Bachs supports connected accounts. Your sub-merchants collect payments under your platform, with their own balances, payout destinations, and requirements managed through the Bachs API.
Create connected accounts for each of your sub-merchants via the Organizations API. Each account is isolated with its own balance and settings.
Use the Requirements API to initiate identity verification for each connected account. Bachs handles the hosted biometric verification flow and notifies you of the result.
Payments made through your platform are routed to the appropriate connected account balance.
Each connected account manages its own withdrawals to its own payout destination. You retain platform-level visibility through the Organizations API.
A connected account completes its requirements before it can process live payments. See [Onboarding](/connect/onboarding) for the full flow.
***
Full list of collection and withdrawal currencies.
How processing and withdrawal fees are calculated.
# Supported Businesses
Source: https://docs.bachs.io/for-you/supported-businesses
Business types accepted on Bachs and those that are not permitted on the platform.
Bachs is built for businesses collecting payments online, whether from local or international customers. Before integrating, confirm your business falls within the supported categories below.
This policy exists to protect merchants, customers, and the integrity of Bachs's payment infrastructure. Accounts that violate this policy may be suspended or restricted without notice.
***
## Supported Businesses
Web applications and SaaS products, developer tools and APIs, AI-powered tools, analytics platforms, and automation tools.
eBooks, PDFs, templates, design assets, code libraries, scripts, courses, and digital downloads.
Physical goods sold online, branded merchandise, print-on-demand products, and online retail stores.
Coaching and consulting, premium research and analytics, agency retainers, and freelance service packages.
Platforms that onboard sub-merchants and collect payments on their behalf using Bachs's connected accounts, with proper agreements in place.
***
## Prohibited Businesses
The following categories are not permitted on Bachs. This list is not exhaustive. We reserve the right to restrict any account that presents significant compliance, regulatory, fraud, or chargeback risk.
Illegal goods or services of any kind, drugs and controlled substances, counterfeit goods, products that violate copyright or trademark rights, age-restricted goods sold without proper verification.
Gambling and games of chance, mystery boxes and random pack openings, unlicensed financial services, unregistered securities offerings, investment funds and high-yield programs, unregulated trading or signals services.
Fake reviews or manufactured social proof, review manipulation platforms, selling or brokering customer data, cloaking services designed to bypass platform bans, services built to circumvent third-party platform restrictions.
Malware, spyware, or exploit distribution, unauthorized access tools, unverified medical or pharmaceutical claims, services targeted at minors without appropriate safeguards.
***
## Compliance Requirements
Bachs works with global payment infrastructure partners, so all businesses on the platform must comply with:
* Card network rules (Visa and Mastercard policies) for any card payment flows
* Applicable crypto compliance standards for any crypto-enabled payment flows
You remain responsible for your own tax and regulatory obligations for your business.
# Supported Currencies
Source: https://docs.bachs.io/for-you/supported-currencies
Currencies accepted for payment collection and available for withdrawals.
Collection currencies and withdrawal currencies are separate. What you can collect from customers is not the same as what you can withdraw.
A third set matters if you run a platform: the currencies an account can **hold** as a balance. It is narrower than either list here. See [Balance currencies](#balance-currencies).
***
## Payment Collection
### Card Payments
Cards are charged in USD or in NGN. Visa, Mastercard, and all major card networks are supported, along with Apple Pay and Google Pay where available.
A card charge in USD settles to your Bachs balance in USD, whatever country the cardholder is in.
### Fiat
| Currency | Code | Payment Methods |
| - | - | - |
| US Dollar | `USD` | Card |
| Nigerian Naira | `NGN` | Card, Bank Transfer |
| Ghanaian Cedi | `GHS` | Mobile Money |
| Kenyan Shilling | `KES` | Mobile Money |
| Malawian Kwacha | `MWK` | Mobile Money |
| Rwandan Franc | `RWF` | Mobile Money |
| Tanzanian Shilling | `TZS` | Mobile Money |
| Ugandan Shilling | `UGX` | Mobile Money |
| Central African CFA Franc | `XAF` | Mobile Money |
| West African CFA Franc | `XOF` | Mobile Money |
| Zambian Kwacha | `ZMW` | Mobile Money |
Bank transfer collects in NGN only. Every other local currency here is collected through mobile money.
### Crypto
Crypto is collected on the networks listed below. The token and network together decide the code you send.
| Token | Network | Code |
| - | - | - |
| Tether | Tron (TRC-20) | `USDT_TRC20` |
| Tether | BNB Chain (BEP-20) | `USDT_BEP20` |
| Tether | Ethereum (ERC-20) | `USDT_ERC20` |
| Tether | Solana | `USDT_SOL` |
| USD Coin | BNB Chain (BEP-20) | `USDC_BEP20` |
| Ethereum | Ethereum | `ETH_ETH` |
| Solana | Solana | `SOL_SOL` |
| BNB | BNB Chain (BEP-20) | `BNB_BEP20` |
To fetch the live list of supported collection currencies, call `GET /v1/currencies/supported`. The response is grouped by `fiat` and `crypto`.
***
## Balance Currencies
A balance currency is one you can **hold** money in. It is a narrower set than what you can collect, because collecting in a currency does not mean we keep a balance in it: a payment in a currency you do not hold is converted into one you do.
| Currency | Code |
| - | - |
| US Dollar | `USD` |
| Nigerian Naira | `NGN` |
USD is always held and cannot be turned off. Any other currency here has to be enabled on the account before it can be used.
Holding a currency decides what an account **settles** in, not what it can charge in. A checkout can be priced in any supported currency, held or not, and converts on the way to the balance. This holds for one-time and recurring (subscription) checkouts alike: a subscription priced in a currency you do not hold still bills each renewal in that currency and settles the proceeds as your settlement currency (USD unless you hold the priced one). A subscription keeps its price currency for its whole life, since renewals run months later without the buyer present.
This matters most if you run a platform. A new account holds only USD, so a connected account that sells subscriptions in its own market has to be given that currency first. See [Currencies the account holds](/connect/accounts#currencies-the-account-holds) for the call that does it, and [Charge in any currency](/guides/checkout/any-currency-checkout) for how pricing and settlement differ.
Collecting in a currency that is not on this list still works. The payment converts into a currency you hold, at the rate quoted on the charge. See [How Conversion Works](#how-conversion-works).
***
## Withdrawals
Global payouts are available to all Bachs users. Account verification, destination approval and sufficient available funds are still required.
### Fiat
| Currency | Code | Withdrawal method | Availability |
| - | - | - | - |
| Nigerian Naira | `NGN` | Local bank transfer | Accounts enabled for payouts |
| Ghanaian Cedi | `GHS` | Local bank transfer, mobile money | Available for withdrawals |
| Kenyan Shilling | `KES` | Local bank transfer, mobile money | Available for withdrawals |
| Tanzanian Shilling | `TZS` | Local bank transfer, mobile money | Available for withdrawals |
| Ugandan Shilling | `UGX` | Local bank transfer | Available for withdrawals |
| British Pound | `GBP` | Faster Payments | Available for withdrawals |
| US Dollar | `USD` | ACH, Wire, Real-Time Payments (RTP) | Available for withdrawals |
| Canadian Dollar | `CAD` | EFT, Interac to email or bank account | Available for withdrawals |
| Euro | `EUR` | SEPA | Available for withdrawals |
EUR destinations include France, Germany, Spain, Italy and the Netherlands. See [Supported currencies](/guides/payouts/global-payouts) for the country list, your bank or wallet details and funding requirements.
International bank and mobile money withdrawals draw from your USD balance. A payout in another currency needs a conversion quote. These destination currencies do not add currencies you can hold as a balance; see [Balance currencies](#balance-currencies).
SWIFT withdrawals are not currently offered. A BIC required for a SEPA destination identifies the bank; it does not select a SWIFT payout.
### Crypto
| Token | Network | Code |
| - | - | - |
| Tether | Tron (TRC-20) | `USDT_TRC20` |
| Tether | BNB Chain (BEP-20) | `USDT_BEP20` |
| Tether | Ethereum (ERC-20) | `USDT_ERC20` |
| Tether | Solana | `USDT_SOL` |
| USD Coin | Ethereum (ERC-20) | `USDC_ERC20` |
| USD Coin | BNB Chain (BEP-20) | `USDC_BEP20` |
| USD Coin | Solana | `USDC_SOL` |
| USD Coin | Base | `USDC_BASE` |
`GET /v1/currencies/payout-supported` lists configured withdrawal currencies, grouped by `fiat` and `crypto`. A currency appearing in the response does not mean every withdrawal method supports it. Use a supported destination type and check `is_usable` before withdrawing.
***
## How Conversion Works
For a cross-currency payout, create a quote before sending the payout. The quote shows `from_currency`, `to_currency`, `from_amount`, `to_amount`, `exchange_rate` and `expires_at`.
`from_amount` is the source amount to convert; `to_amount` is the amount payable in the destination currency. The withdrawal fee is added to the source amount, so your balance must cover both. The quote request's `amount` is in the source currency.
Send the quote's `quote_id` with the registered destination ID and omit `amount` from the payout request. For a same-currency payout, send `amount` without a quote. That `amount` is what your destination receives. Reconcile the payout using `total_debited`, which includes the fee. See [Overview](/guides/payouts/overview) and [Fees](/for-you/fees#withdrawal-fees).
***
## Related
Initiate a withdrawal to a configured payout destination.
Receive real-time updates on withdrawal status.
# Go live
Source: https://docs.bachs.io/go-live
Verify your account once to accept payments and withdraw funds. Build in the sandbox now; going live is a key swap.
To accept payments and withdraw funds on Bachs, your account goes through verification once. It confirms that every business on the platform is real, compliant, and aligned with our [acceptable-use policy](https://bachs.io/aup), across three things: your product, your identity, and your withdrawal account.
Most reviews finish within 48 hours. Timelines can shift around weekends and holidays, but every submission is worked through to a decision, nothing is left hanging.
You don't have to wait to build. Bachs' [sandbox](/integrate/sandbox) mirrors production closely; once you're verified, going live means swapping your API keys.
## What we ask for
Whether you're an individual or a registered business, verification covers three things.
**Your product.** A short description of what you do: your website or a social link, what your product is, the category it fits, and a support email. This is how we confirm your business fits our policy.
**Your identity.** A quick, hosted identity check through [Smile ID](https://smileidentity.com): a government-issued ID and a selfie to confirm you're real and that you match it. Bachs never stores your documents; Smile returns the result to us. The exact ID types depend on your country.
**A withdrawal account.** The bank account where your money lands. The name on it must match your verified identity, or your company's registered name if you're a business.
Registered businesses also provide their company registration details and confirm their directors and owners.
## How reviews and tasks work
After you submit, our team reviews your details. If anything else is needed, we open a **task** on your dashboard. Tasks are the single, official channel for every request, a missing field, an unreadable document, or a clarification. If it isn't a task, it isn't us.
We open a task the moment something specific is needed from you.
It appears under **Tasks** in your Settings, and we email you a link straight to it.
Open the task, provide the information or upload the document, and submit.
We mark it complete, or send it back with a clear reason if more is needed.
Every task moves through the same states, so what you see always matches where it really is: **Action required → In review → Completed**.
Unresolved tasks can limit certain functions on your account, so it's worth clearing them promptly. Your capabilities, accepting payments and withdrawing, reflect your outstanding tasks and the review outcome.
## Staying in good standing
To keep the platform trustworthy, we ask a few things of every merchant: keep customer complaints low, respond within 48 hours when we loop you into a support thread, and provide good service. Sustained unresponsiveness can lead to warnings, refunds to affected customers, or offboarding. We also monitor accounts continuously to prevent fraud, which is why a task can appear after you're live, not only during onboarding.
Ready? [Start verification](https://app.bachs.io/signup) from your dashboard.
# Ad-hoc pricing
Source: https://docs.bachs.io/guides/checkout/adhoc-pricing
Charge a catalog product at a one-off price for a single checkout, without changing your catalog.
Ad-hoc pricing lets you override a product's price for one checkout. You keep selling an existing catalog product, but the price for that session is whatever you set on the cart item. Bachs never creates a new product, and your catalog price is untouched.
Reach for it when the price is decided at order time rather than in your catalog: a negotiated deal, a customer-specific rate, a computed or usage-derived amount, or a one-off discount. The catalog stays clean; the override lives on the checkout.
This lets you price in real time and still keep a clean catalog.
## Before you start
* A **sandbox API key** (`sk_sandbox_...`). See [Authentication](/authentication).
* A **catalog product** to override. See [Create a product](/guides/products/overview).
Everything below uses the sandbox base URL, so you can run the whole flow without moving real money.
## How it works
Add a `pricing` object to a `product_cart` item. Bachs charges the override instead of the product's catalog price for that checkout only.
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"product_cart": [
{
"product_id": "prod_abc123",
"quantity": 1,
"pricing": { "price_type": "fixed", "amount": "19.00" }
}
],
"customer": { "email": "jane@example.com" }
}'
```
```json Response theme={"dark"}
{
"checkout_id": "chk_2N3o4P5q6R7s8T9u",
"status": "open",
"source_type": "CHECKOUT_SESSION",
"amount": "19.00",
"currency": "USD",
"checkout_url": "https://checkout.bachs.io/c/V8xQ2mZpLj9RfTa",
"products": [
{
"product_id": "prod_abc123",
"quantity": 1,
"unit_amount": "19.00",
"currency": "USD",
"price_type": "fixed",
"line_total": "19.00"
}
]
}
```
The override is in the product's primary currency and applies to that checkout only. From here the flow is the ordinary checkout: redirect the customer to `checkout_url` and confirm with the `collection.succeeded` webhook. See [Accept a payment with Checkout](/guides/checkout/checkout-sessions).
## Price types
Ad-hoc pricing supports the same price types as catalog prices, with one exception.
| `price_type` | What it does |
| - | - |
| `fixed` | A set amount. Put it in `pricing.amount`. |
| `custom` | Pay-what-you-want, bounded by `minimum_amount` / `maximum_amount` with an optional `preset_amount`. |
| `free` | No charge. The line is \$0; the checkout completes without payment. On a recurring product it creates a free subscription (no card, renews with no charge). |
A `custom` override lets the buyer choose the amount on the hosted checkout page, within your bounds:
```bash Custom (pay-what-you-want) theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"product_cart": [
{
"product_id": "prod_abc123",
"quantity": 1,
"pricing": {
"price_type": "custom",
"preset_amount": "10.00",
"minimum_amount": "5.00",
"maximum_amount": "100.00"
}
}
],
"customer": { "email": "jane@example.com" }
}'
```
The response shows the line as `price_type: "custom"` with the bounds, priced at the preset until the buyer changes it.
On a `custom` [ui\_mode](/guides/checkout/checkout-sessions) checkout (server-to-server, your own UI) there is no hosted page to collect the amount. Send the buyer's chosen amount as the cart item's `amount` when you create the checkout.
## One-time vs recurring
The override behaves differently depending on whether the product is one-time or recurring, but in both cases it stays out of your catalog price list.
* **One-time product.** The override is snapshotted on the checkout. No price row is created; nothing changes in your catalog.
* **Recurring product.** The override becomes the subscription's price for its whole life. When the subscription activates, Bachs stores the settled amount as an archived, one-off price the subscription ties to.
An ad-hoc price is a one-off. It is never returned by the product price list and cannot be reused across checkouts. To sell at a price repeatedly, add it to the catalog instead.
## Testing
Use a sandbox key (`sk_sandbox_...`) to run the whole flow against test-mode products without moving real funds. See [Sandbox](/integrate/sandbox).
## Next steps
* [Accept a payment with Checkout](/guides/checkout/checkout-sessions): the full checkout flow ad-hoc pricing plugs into, including [charging a raw amount](/guides/checkout/checkout-sessions#charge-a-raw-amount) with no product at all.
* [Subscriptions](/guides/subscriptions/overview): how a recurring ad-hoc price renews.
* [The checkout session object](/api-reference/checkout-sessions/object): every field the API accepts and returns.
# Charge in any currency
Source: https://docs.bachs.io/guides/checkout/any-currency-checkout
Price a product in your customer's currency, collect on a local payment method, and settle to the balance you hold.
In this guide you'll price a product in a currency your account does not hold, send a customer to a checkout that shows that currency and offers a payment method for it, and receive the money in your own balance currency. By the end you'll know which currency each part of a payment is in, and why they can differ.
Two questions decide everything on this page, and they have different answers:
* **What can you price in?** Any of the eleven supported currencies, whether or not you hold a balance in it.
* **What can you hold?** `USD` and `NGN`. Every payment ends up in one of these.
Pricing a sale in `GHS` does not require a Ghanaian Cedi balance. The customer pays `GHS`, and Bachs converts to your balance currency when the payment settles.
## Before you start
* A **sandbox API key** (`sk_sandbox_...`). See [Authentication](/authentication).
* A **webhook endpoint** to receive the result. See [Set up webhooks](/guides/webhooks/overview).
Everything below uses the sandbox base URL, so you can run the whole flow without moving real money.
## Currencies you can price in
| Code | Currency | Code | Currency |
| - | - | - | - |
| USD | United States Dollar | TZS | Tanzanian Shilling |
| NGN | Nigerian Naira | UGX | Ugandan Shilling |
| GHS | Ghanaian Cedi | XAF | Central African CFA |
| KES | Kenyan Shilling | XOF | West African CFA |
| MWK | Malawian Kwacha | ZMW | Zambian Kwacha |
| RWF | Rwandan Franc | | |
Any of these can be a product's primary currency, a `pricing.currency` on a raw amount, or a `currency_options` entry. Crypto asset codes such as `USDT_TRC20` are not currencies you price in; they are how a customer can choose to pay. See [Local pricing](/guides/products/local-pricing) for setting several prices on one product.
## Steps
Set `price.currency` to the currency you want to sell in. Nothing about your account needs to change first.
```bash Create a GHS product theme={"dark"}
curl https://sandbox-api.bachs.io/v1/products \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Pro plan",
"price": {
"price_type": "fixed",
"amount": "150.00",
"currency": "GHS"
}
}'
```
```json Response (price) theme={"dark"}
{
"price_type": "fixed",
"amount": "150.00",
"currency": "GHS",
"currency_options": []
}
```
Nothing extra is needed. The checkout takes its currency from the cart.
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"product_cart": [
{ "product_id": "prod_abc123", "quantity": 1 }
],
"customer": { "email": "kwame@example.com", "name": "Kwame Mensah" }
}'
```
```json Response theme={"dark"}
{
"checkout_id": "chk_2N3o4P5q6R7s8T9u",
"checkout_url": "https://checkout.bachs.io/c/V8xQ2mZpLj9RfTa",
"mode": "payment",
"amount": "150.00",
"currency": "GHS",
"billing_currency": "GHS",
"status": "open",
"expires_at": "2026-04-27T13:00:00Z"
}
```
The hosted page shows `GH₵ 150.00` and offers the payment methods that can charge Cedi. It does not offer a currency chooser, because the sale is priced in a currency the customer already pays in.
A checkout priced in `USD` behaves differently. See [How the customer's currency is chosen](#how-the-customers-currency-is-chosen).
The `collection.succeeded` webhook reports the currency the customer paid in.
```json collection.succeeded theme={"dark"}
{
"id": "evt_3ab4e0d5d2",
"type": "collection.succeeded",
"created_at": "2026-04-27T12:04:00Z",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"charge_id": "ch_1a2b3c4d5e6f",
"checkout_id": "chk_2N3o4P5q6R7s8T9u",
"status": "SUCCEEDED",
"amount": "150.00",
"currency": "GHS"
}
}
```
The amount that reaches your balance is in `USD` or `NGN`, not `GHS`. See [What lands in your balance](#what-lands-in-your-balance).
## How the customer's currency is chosen
The currency you price in decides whether the customer is offered a choice at all. There are two behaviours, and which one applies depends on the price currency.
| Price currency | What the customer sees | Currency chooser |
| - | - | - |
| `USD` | The price converted into their local currency | Yes |
| Any other supported currency | The price exactly as you set it | No |
`USD` is the only currency that converts at the page. Price in `USD` and a customer in Ghana is shown a Cedi amount, converted at the rate at that moment, and can switch currency. Price in `GHS` and every customer is charged `GHS 150.00`, wherever they are.
Adaptive pricing, the setting that turns on conversion at the page, only affects a `USD`-priced checkout. A checkout priced in any other currency is charged in that currency whether adaptive pricing is on or off.
This is why pricing in a local currency is the way to guarantee the amount. A `USD` price converts, so what the customer is charged moves with the exchange rate. A `GHS` price does not.
## Pin the currency with `billing_currency`
Set `billing_currency` on the checkout to choose which price the customer is charged, instead of letting their location decide. It selects among the prices the checkout already carries; it does not create a new one.
```bash theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"product_cart": [{ "product_id": "prod_abc123", "quantity": 1 }],
"customer": { "email": "kwame@example.com" },
"billing_currency": "GHS"
}'
```
Two conditions must both hold, and each has its own error:
1. **The checkout must be priced in that currency**, as its primary currency or as a `currency_options` entry. Otherwise the request fails with `BILLING_CURRENCY_NOT_AVAILABLE`, and the message lists the currencies that are priced.
2. **A payment method on the checkout must be able to charge it.** Otherwise the request fails with `BILLING_CURRENCY_HAS_NO_PAYMENT_METHOD`, and the message lists the currencies that can be charged.
The second condition is the one that catches people out on subscriptions. A monthly product can carry a `GHS` price, but subscriptions are card only and no card corridor charges Cedi, so `billing_currency: "GHS"` on a subscription checkout is refused. Requesting a currency nothing can collect fails at creation rather than producing a checkout the customer cannot complete.
## What lands in your balance
Your balance is held in `USD` or `NGN`. A payment collected in any other currency is converted when it settles, and the converted amount is credited to your balance in your settlement currency.
| Payment currency | What happens |
| - | - |
| A currency you hold | Credited as collected, with no conversion |
| Any other currency | Converted at settlement, then credited |
Conversion happens when the payment settles, not when the customer pays, so the amount credited is not known at the moment of sale. Track the settled figure from your [balances](/api-reference/accounts/get-balances) rather than deriving it from the charge amount.
A `NGN` payment on an account that holds `NGN` is credited without conversion. The same sale priced in `GHS` is converted. The price currency and the settlement currency are separate decisions, and only the second is restricted to what you hold.
## NGN checkout settlement
NGN-initiated checkout payments settle to your NGN balance by default. You can still choose USD settlement for NGN-initiated checkout payments in your [withdrawal settings](https://app.bachs.io/settings?tab=withdrawals).
In the dashboard, use the **Balance currencies** section to control whether your organization holds NGN balances.
An NGN-initiated checkout is one whose locked checkout price is NGN. This is different from a checkout where the customer pays an NGN equivalent for a USD price.
| Checkout setup | Customer pays | Default settlement |
| - | - | - |
| `pricing.currency` is `NGN` | NGN | NGN balance |
| Product primary price is `NGN` | NGN | NGN balance |
| Product primary price is `USD`, with an NGN currency option selected at checkout | NGN | NGN balance |
| `pricing.currency` is `USD`, and the customer pays the NGN equivalent | NGN | USD settlement behavior |
| Product primary price is `USD`, with no NGN currency option selected | USD or local equivalent | USD settlement behavior |
The deciding factor is the checkout's locked price currency. A USD checkout with an NGN local price becomes NGN-initiated when the checkout resolves to that NGN price. A USD checkout that only lets the customer pay an NGN equivalent is still USD-initiated.
For processing fees and conversion charges, see [Fees](/for-you/fees).
## Recurring products
A subscription is priced the same way as a one-time checkout: in any supported currency, whether or not you hold it. Each renewal charges the saved card in that currency and the proceeds settle into your settlement currency (USD unless you hold the priced currency), the same conversion a one-time charge makes. The one thing that stays fixed is the price currency: a subscription bills in the currency it was created with for its whole life, since renewals run months later without the buyer present.
The subscribe page only offers the methods that can bill the price currency off-session, so a `USD` plan shows the USD card and nothing that cannot renew it.
Today a recurring checkout priced in a currency you do not hold is still refused at creation with `BASE_CURRENCY_NOT_HELD_BY_ORG`, while the settle-to-USD path above is rolled out for renewals. Until then, hold the price currency (or price the plan in one you hold) to create the subscription. One-time checkouts are unaffected and already price in any currency.
See [Subscriptions](/guides/subscriptions/overview) for how renewals work.
## Errors
| Code | Status | Cause and resolution |
| - | - | - |
| `BILLING_CURRENCY_NOT_AVAILABLE` | 400 | Nothing in the checkout is priced in the requested currency. Add a price in that currency, or request one that is listed in the message. |
| `BILLING_CURRENCY_HAS_NO_PAYMENT_METHOD` | 400 | The checkout is priced in that currency, but no payment method it offers can charge it. Request one of the currencies listed in the message. |
| `BASE_CURRENCY_NOT_HELD_BY_ORG` | 422 | A recurring checkout was priced in a currency you do not hold. A current limitation until renewals settle to USD like one-time charges: for now, price the recurring product in a currency you hold. |
| `BASE_CURRENCY_NOT_COLLECTIBLE` | 422 | Nothing can collect the currency the checkout is priced in. Crypto asset codes are refused here: an asset code names a rail, not a price. |
| `BASE_CURRENCY_NOT_ENABLED` | 422 | The currency is collectible, but not enabled for your organization. Enable it in your checkout settings. |
| `BASE_CURRENCY_NOT_CONVERTIBLE` | 422 | The currency can be collected, but there is no rate to settle it into your settlement currency. The checkout is refused rather than created with money that could not be paid out. |
| `CART_CURRENCY_MISMATCH` | 400 | Every product in one checkout must share the same primary currency. Split the cart, or give the products a common currency. |
## Next steps
* [Local pricing](/guides/products/local-pricing): set several prices on one product.
* [Accept a payment with Checkout](/guides/checkout/checkout-sessions): the full checkout flow.
* [Payment method support](/guides/payments/payment-method-support): which corridors your account can accept.
* [Get balances](/api-reference/accounts/get-balances): what has settled and in which currency.
# Accept a payment with Checkout
Source: https://docs.bachs.io/guides/checkout/checkout-sessions
Create a hosted checkout, send your customer to it, and confirm the payment with a webhook.
In this guide you'll create a checkout session, redirect a customer to the hosted checkout page, and confirm the payment through a webhook. By the end you'll have a working one-time purchase flow that you can drop into your app.
A checkout session ties a price to a customer and returns a hosted `checkout_url`. Bachs resolves pricing, handles currency conversion, selects payment methods, and processes the payment. You send the customer to the URL and listen for the result.
`customer` is optional. Pass one and it's attached at creation; omit it and the hosted page collects the buyer's email and name before they can pay. See [Attach a customer](#attach-a-customer).
You price a session in one of two ways, and supply exactly one:
* `product_cart`: one or more catalog products (each item may override its price with `pricing`).
* `pricing`: a raw amount and currency, with no product. See [Charge a raw amount](#charge-a-raw-amount) below.
## Before you start
* A **sandbox API key** (`sk_sandbox_...`). See [Authentication](/authentication).
* At least one **product** to sell. See [Create a product](/guides/products/overview).
* A **webhook endpoint** to receive the result. See [Set up webhooks](/guides/webhooks/overview).
Everything below uses the sandbox base URL, so you can run the whole flow without moving real money.
## Steps
Call `POST /v1/checkout-sessions` with the products the customer is buying and their details. You get back a `checkout_url`.
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"product_cart": [
{ "product_id": "prod_abc123", "quantity": 1 }
],
"customer": { "email": "jane@example.com", "name": "Jane Doe" },
"success_url": "https://shop.example.com/thanks",
"cancel_url": "https://shop.example.com/cart"
}'
```
```json Response theme={"dark"}
{
"checkout_id": "chk_2N3o4P5q6R7s8T9u",
"checkout_url": "https://checkout.bachs.io/c/V8xQ2mZpLj9RfTa",
"status": "open",
"expires_at": "2026-04-27T13:00:00Z",
"created_at": "2026-04-27T12:00:00Z"
}
```
The total charged is the sum of `unit_amount × quantity` across the cart. All products in the cart must share the same primary currency.
Send the customer to the `checkout_url` from the response. Bachs renders the hosted page, collects payment, and handles currency conversion.
```bash Redirect theme={"dark"}
HTTP/1.1 302 Found
Location: https://checkout.bachs.io/c/V8xQ2mZpLj9RfTa
```
When the customer finishes, Bachs redirects them to your `success_url` with `?checkout_id=` appended. If they cancel or abandon the checkout, they are sent to your `cancel_url`.
Don't rely on the redirect alone. When the checkout is paid, Bachs sends [`checkout.completed`](/guides/webhooks/events/checkout-completed) and [`collection.succeeded`](/guides/webhooks/events/collection-succeeded) to your webhook endpoint. Either can arrive first: fulfil the order on whichever arrives first, once. A free checkout sends only `checkout.completed`. For a worked example that also checks the amount paid, see [Sell digital products](/build/use-cases/digital-products).
```json collection.succeeded theme={"dark"}
{
"id": "evt_3ab4e0d5d2",
"type": "collection.succeeded",
"created_at": "2026-04-27T12:04:00Z",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"charge_id": "ch_1a2b3c4d5e6f",
"checkout_id": "chk_2N3o4P5q6R7s8T9u",
"status": "SUCCEEDED",
"amount": "29.00",
"currency": "USD"
}
}
```
Redirects can be lost if the customer closes the tab. Treat the webhook as the source of truth for fulfillment, and verify its signature before trusting it. See [Set up webhooks](/guides/webhooks/overview).
Provide exactly one pricing source: `product_cart` or `pricing`. Never both, and never neither.
## Charge a raw amount
When your pricing is computed at order time and you have no product to reference, pass a `pricing` object instead of a cart. Bachs creates the checkout from the amount alone, with no products and no catalog. It's the right choice when your pricing is dynamic, computed at order time, or when you want to accept a payment without defining products in Bachs first.
```bash Raw amount theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"pricing": { "currency": "USD", "amount": "42.00" },
"customer": { "email": "jane@example.com" }
}'
```
The rest of the flow is identical: you get a `checkout_url`, redirect the customer, and confirm with the `collection.succeeded` webhook.
### Pricing intent
Set `pricing.currency` to `USD` and Bachs converts to the customer's local currency at the prevailing FX rate, letting them pay in any currency you support. Set it to any other supported fiat currency instead and the customer pays in that currency only.
Add `currency_options` to lock in exact amounts for specific currencies instead of relying on the live FX rate. Bachs uses the override when the customer selects that currency, and falls back to FX for any currency not listed. Keys must be fiat currency codes you support, and cannot include `currency` itself.
```json theme={"dark"}
{
"pricing": {
"currency": "USD",
"amount": "50.00",
"currency_options": {
"NGN": "75000.00",
"GHS": "620.00"
}
}
}
```
See the [create checkout session reference](/api-reference/payments/create-checkout-session) for the full `pricing` field list, including per-currency minimums.
## Override a product price
To sell a catalog product at a different price for one checkout, set `pricing` on the cart item. Bachs charges the override instead of the catalog price, without creating a new product. It works on any product, including fixed-price ones, and supports the same price types as catalog prices (`fixed`, `custom`, and `free`). The override is in the product's primary currency and applies to that checkout only.
```bash Fixed override theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"product_cart": [
{
"product_id": "prod_abc123",
"quantity": 1,
"pricing": { "price_type": "fixed", "amount": "19.00" }
}
],
"customer": { "email": "jane@example.com" }
}'
```
```bash Custom (pay-what-you-want) override theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"product_cart": [
{
"product_id": "prod_abc123",
"quantity": 1,
"pricing": {
"price_type": "custom",
"preset_amount": "10.00",
"minimum_amount": "5.00",
"maximum_amount": "100.00"
}
}
],
"customer": { "email": "jane@example.com" }
}'
```
A `custom` override lets the buyer pick the amount on the hosted page, within your bounds. On a `custom` ui\_mode (server-to-server) checkout there is no hosted page, so supply the buyer's chosen amount as the cart item's `amount`.
For a **one-time** product the override is snapshotted on the checkout, so it never enters your price list. For a **recurring** product it becomes the subscription's price for its whole life; Bachs mints an archived, one-off price the subscription ties to when it activates, kept out of your catalog price list.
## Attach a customer
For a new customer, provide `email`; `name` is optional and must not be blank when supplied. For an existing customer, provide `customer_id`.
Pass a new customer by email (Bachs creates or matches one), an existing customer by ID, or omit `customer` entirely and let the buyer identify themselves on the hosted page.
```json theme={"dark"}
{
"customer": {
"email": "jane@example.com",
"name": "Jane Doe",
"phone_number": "+2348012345678"
}
}
```
```json theme={"dark"}
{ "customer": { "customer_id": "cust_1a2b3c4d5e6f" } }
```
```json theme={"dark"}
{
"product_cart": [
{ "product_id": "prod_abc123", "quantity": 1 }
],
"success_url": "https://shop.example.com/thanks",
"cancel_url": "https://shop.example.com/cart"
}
```
`customer` is required for **recurring products**. A cart containing a product with a `billing_cycle` becomes a subscription checkout, which needs a durable identity to bill on renewal, and there is no opportunity to collect one later. Omitting `customer` returns `400`.
### Guest checkout
Omit `customer` on a standard hosted checkout and Bachs collects the buyer's email and name on the checkout page itself. By default they do not join your customer directory: you receive their email and name on `customer_details`, and `customer` stays `null`. Send `customer_creation: "always"` if you want them on file as a customer.
Until the buyer supplies their identity, `GET /v1/checkout-sessions/{checkout_id}` and the list endpoint return `"customer": null` and `"customer_details": null`, and the hosted page's own pricing and payment calls return `400`, since there is no one to bill yet. A malformed email returns `400` with `error_code: "VALIDATION_ERROR"`.
### Whether a guest becomes a customer: `customer_creation`
`customer_creation` decides whether collecting an identity on the hosted page also creates a customer record. Send it when you create the checkout.
| Value | What happens |
| - | - |
| `if_required` (default) | The buyer does not join your customer directory. `customer` stays `null` on every response and webhook, and no `customer.*` event fires. Their identity reaches you on `customer_details`, and their purchases group together in your dashboard so you can still look them up. |
| `always` | The buyer becomes a customer in your directory. You receive it on `customer`, and a [`customer.created`](/guides/webhooks/events/customer-created) or [`customer.updated`](/guides/webhooks/events/customer-updated) webhook fires. |
```json theme={"dark"}
{ "customer_creation": "always" }
```
The default keeps your directory to people you have an ongoing relationship with, which is usually what you want for one-time sales. Reach for `always` when you want every buyer on file, for example to look them up through the API or to bill them again by hand.
Under `always`, a buyer whose email already matches a customer you hold attaches to **that** record rather than creating a second one, so their payment history stays in one place. Matching is on the email alone, and an email typed on the checkout page is not verified, so anyone who knows one of your customers' addresses can have their purchase recorded against that customer. Under `if_required` this cannot happen: a buyer who shares an email with one of your customers stays entirely separate from them.
`customer_creation` is ignored for a subscription or a `setup`-mode checkout. Both always create a customer, whatever you pass, because recurring billing needs a durable record to keep the renewal card on. If you set `customer_creation: "if_required"` everywhere and still see new customers appearing, this is why.
See [`customer` vs `customer_details`](/api-reference/checkout-sessions/object#customer-vs-customer-details) for exactly which of the two you get in each case.
## Recurring products
You don't create subscriptions directly. If a product in the cart has a `billing_cycle`, Bachs turns the checkout into a subscription checkout automatically: when the customer pays, it saves their card, bills the first cycle (or defers it for a [trial](/guides/subscriptions/trials)), and creates the subscription.
On completion you receive `collection.succeeded` **and** `customer.subscription.created` + `invoice.paid`. Subscriptions are USD card only today. See [Subscriptions](/guides/subscriptions/overview) for how they renew and how to manage them.
## Common options
| Option | What it does |
| - | - |
| `pricing` | Per cart item. Overrides that product's price for this checkout (`fixed` or `custom`), in its primary currency. Leaves the catalog price untouched. |
| `success_url` | Where the customer lands after paying. `?checkout_id=` is appended. Must be a public `http` or `https` address: `localhost` and private network addresses are refused, in the sandbox too. |
| `cancel_url` | Where the customer is sent if they cancel or abandon. |
| `billing_currency` | Locks the session to a currency and selects the matching price row. The checkout must already be priced in it, and a payment method must be able to charge it. See [Charge in any currency](/guides/checkout/any-currency-checkout#pin-the-currency-with-billing_currency). |
| `payment_method_types` | Restricts which payment methods appear. See [Restrict payment methods](#restrict-payment-methods). |
| `reference` | Your own order or charge ID. Unique per organization for good, including checkouts that expired. Max 128 chars. |
| `customer_creation` | `if_required` (default) or `always`. Whether a buyer who identifies themselves on the hosted page becomes a customer record. See [Whether a guest becomes a customer](#whether-a-guest-becomes-a-customer-customer-creation). |
| `metadata` | Up to 20 key/value pairs, returned in webhooks. Max 10 KB. For a subscription checkout, this metadata is copied onto the subscription when the checkout succeeds. |
| `expires_in_minutes` | Session lifetime, 1 to 1440. Defaults to 60. |
## Restrict payment methods
By default a checkout offers every payment method your account is enabled for. Pass `payment_method_types` to show less than that.
Each entry is an exact payment-method **corridor**, not a payment type. Card, bank transfer, and mobile money are each split into one corridor per currency: `USD_CARD` and `NGN_CARD` are separate corridors, as is each of the nine mobile money corridors. You restrict to a currency by choosing which corridors to list, not by filtering a shared `card` entry. A corridor you leave out is not offered at all.
```bash theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"product_cart": [
{ "product_id": "prod_abc123", "quantity": 1 }
],
"customer": { "email": "jane@example.com", "name": "Jane Doe" },
"payment_method_types": ["USD_CARD", "NGN_BANK_TRANSFER"]
}'
```
That checkout offers USD cards and NGN bank transfer, and nothing else. NGN cards are not offered, because `NGN_CARD` is not listed. Mobile money and crypto do not appear at all, because no corridor for them is named.
A restriction only ever narrows. It cannot offer a corridor your account is not enabled for, and listing only corridors you cannot accept leaves the checkout with no way to pay — the request fails with `CHECKOUT_RESTRICTION_LEAVES_NO_PAYMENT_METHOD`.
### The corridors
| Corridor | Method | Currency |
| - | - | - |
| `USD_CARD` | Card | USD |
| `NGN_CARD` | Card | NGN |
| `NGN_BANK_TRANSFER` | Bank transfer | NGN |
| `MOMO_GHS` | Mobile money | GHS |
| `MOMO_KES` | Mobile money | KES |
| `MOMO_TZS` | Mobile money | TZS |
| `MOMO_UGX` | Mobile money | UGX |
| `MOMO_XAF` | Mobile money | XAF |
| `MOMO_XOF` | Mobile money | XOF |
| `MOMO_RWF` | Mobile money | RWF |
| `MOMO_MWK` | Mobile money | MWK |
| `MOMO_ZMW` | Mobile money | ZMW |
| `CRYPTO` | Crypto | all supported assets |
Do not guess which corridors your account can accept. [Payment method support](/guides/payments/payment-method-support) lists every corridor and whether it is enabled for you.
`CRYPTO` is one corridor covering every asset and network we support. The customer picks the one they want to pay with; whichever they choose, you are credited in USD.
Restricting only ever removes options. It cannot add a corridor or a currency your account is not already enabled for, and it cannot bring back something another rule has already ruled out. If you restrict a checkout to a corridor your account is not enabled for, that corridor stays absent and the request still succeeds.
If the restriction leaves nothing payable, the request fails with `400` rather than creating a checkout the customer cannot complete.
Naming a currency the corridor cannot process is rejected with `422`, as is an empty `currencies` array. To turn a corridor off, leave its key out rather than passing an empty list.
Payment links you create in the dashboard support the same restriction, and it applies to every checkout the link creates.
## Testing
Use a sandbox key (`sk_sandbox_...`) to run the whole flow against test-mode products without moving real funds. See [Sandbox](/integrate/sandbox).
## Next steps
* [Live demo](https://snapkit.bachs.io): a storefront running this exact flow on the sandbox, overlay and hosted page both.
* [Charge in any currency](/guides/checkout/any-currency-checkout): price in a currency you do not hold, and see what settles.
* [The checkout session object](/api-reference/checkout-sessions/object): every field the API returns and accepts.
* [Manage customers](/guides/customers): the customer object, and when a checkout creates one for you.
* [Set up webhooks](/guides/webhooks/overview): receive and verify the result.
* [Subscriptions](/guides/subscriptions/overview): sell recurring products.
* [Refunds](/guides/refunds): refund a completed payment.
# Add an overlay checkout
Source: https://docs.bachs.io/guides/checkout/overlay-checkout
Open the Bachs hosted checkout in a modal on your own site with bachs.js, so customers pay without leaving your page.
In this guide you'll add an overlay checkout to your site: a button that opens the Bachs hosted checkout in a modal on top of your page, takes the payment, and tells you the result in real time. By the end you'll have a drop-in payment flow where the customer never leaves your site.
The overlay loads the Bachs-hosted checkout inside an iframe, so your page never touches card data. Bachs owns the card form, tokenization, and the payment; you decide what to sell and what happens after.
**Try it before you build.** [snapkit.bachs.io](https://snapkit.bachs.io) is a demo storefront built on this SDK. Open one-time, subscription, free-trial, and pay-what-you-want checkouts in the overlay or as the hosted page, all against the sandbox with a test card.
## Quick start
```html theme={"dark"}
```
That is the whole client. The steps below walk through it properly, server side included.
## How it works
Your backend calls the Bachs API with your secret key and gets back a `checkout_url`. The amount and currency are fixed here, on your server.
Your frontend loads `bachs.js` and calls `Bachs.Checkout.open()` with that session. The customer pays in the modal.
Bachs sends the `checkout.completed` and `collection.succeeded` webhooks to your server. Those, not the browser, are your signal to grant access or ship the order.
## Before you start
* A **sandbox API key** (`sk_sandbox_...`). Keep it on your server. See [Authentication](/authentication).
* At least one **product** to sell. See [Create a product](/guides/products/overview).
* A **webhook endpoint** to confirm payments. See [Set up webhooks](/guides/webhooks/overview).
## Steps
Call `POST /v1/checkout-sessions` from your **backend** with your **secret key**, exactly as in [Accept a payment](/guides/checkout/checkout-sessions). You get back a `checkout_url`. Return it to your frontend.
Create sessions server-side only. Your secret key must never reach the browser, and creating the session on the server is what keeps the amount tamper-proof.
```bash Request (server-side) theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"product_cart": [
{ "product_id": "prod_abc123", "quantity": 1 }
],
"customer": { "email": "jane@example.com", "name": "Jane Doe" },
"success_url": "https://shop.example.com/thanks",
"cancel_url": "https://shop.example.com/cart"
}'
```
```json Response theme={"dark"}
{
"checkout_id": "chk_2N3o4P5q6R7s8T9u",
"checkout_url": "https://checkout.bachs.io/c/V8xQ2mZpLj9RfTa",
"status": "open",
"expires_at": "2026-04-27T13:00:00Z",
"created_at": "2026-04-27T12:00:00Z"
}
```
Add the SDK with a script tag, or install the npm package if you use a bundler (Vite, Next.js, webpack). Both load the same overlay.
```html Script tag theme={"dark"}
```
```bash npm theme={"dark"}
npm install @bachs/js
```
With the script tag, the SDK is available as the global `Bachs`. With the npm package, import the loader:
```js npm import theme={"dark"}
import { loadBachs } from "@bachs/js";
const Bachs = await loadBachs();
```
Initialize once when your app loads, not on every click. Pass a global `onEvent` handler for checkout lifecycle events.
```js theme={"dark"}
Bachs.Initialize({
onEvent: (event) => {
// Handle checkout lifecycle events (see the table below).
},
});
```
There is no test/live switch on the SDK. A checkout session already carries its environment: a session created with a sandbox key is a sandbox checkout, and its `checkout_url` says which checkout serves it. Pass that URL through and the client works the same in every environment. Only if you open bare tokens with `open({ token })` do you point the SDK somewhere with `baseUrl` (the sandbox checkout origin while you build).
On a button click, fetch a fresh `checkout_url` from your server, then open the overlay with it. `open()` returns a promise that resolves once the modal is mounted.
```html Full example theme={"dark"}
```
```jsx React theme={"dark"}
"use client";
import { useEffect } from "react";
import { loadBachs } from "@bachs/js";
export function PayButton() {
useEffect(() => {
loadBachs().then((Bachs) =>
Bachs.Initialize({ onEvent: handleEvent }),
);
}, []);
async function pay() {
const Bachs = await loadBachs();
const { checkout_url } = await fetch("/api/create-checkout", {
method: "POST",
}).then((r) => r.json());
Bachs.Checkout.open({ checkoutUrl: checkout_url });
}
return ;
}
```
You can pass the whole `checkout_url` as `checkoutUrl`, or only its token segment as `token`. A `checkoutUrl` must be on your Bachs checkout origin; the SDK rejects any other host.
## Handle checkout events
Pass an `onEvent` callback to `Initialize` (global) or to `open` (this checkout only). Each event is `{ type, data }`. These events drive your **UI**, not fulfilment.
| Event | When it fires | Use it for |
| - | - | - |
| `checkout.opened` | The overlay opened. | Analytics. |
| `checkout.loaded` | The iframe finished loading. | Analytics. |
| `checkout.ready` | The checkout is mounted and ready for input. | Hide your own loading state. |
| `checkout.completed` | The payment succeeded. | Show a success screen. Fulfil on the webhook, not here. |
| `checkout.failed` | The payment failed. | Show a retry prompt. |
| `checkout.expired` | The session expired before payment. | Prompt the customer to start over. |
| `checkout.closed` | The overlay was closed. `data.reason` says why. | Reset your button state. |
| `checkout.error` | An SDK-level error, such as a bad token. | Log `data.message`. |
```js theme={"dark"}
Bachs.Initialize({
onEvent: (event) => {
switch (event.type) {
case "checkout.ready":
hideLoadingState();
break;
case "checkout.completed":
showSuccess(event.data.reference);
break;
case "checkout.failed":
showRetry();
break;
case "checkout.error":
console.error("Checkout error:", event.data.message);
break;
}
},
});
```
## Fulfil the order with a webhook
The overlay tells you when a payment succeeds, but the browser is not the source of truth. When the checkout is paid, Bachs sends the [`checkout.completed`](/guides/webhooks/events/checkout-completed) and [`collection.succeeded`](/guides/webhooks/events/collection-succeeded) webhooks to your endpoint. Grant access or ship the order on whichever arrives first, once.
Never fulfil an order from the overlay's browser event, also named `checkout.completed`. A customer can close the overlay after paying but before your code runs, so the browser event can be missed. Treat the webhooks your server receives as the source of truth, and verify their signatures before trusting them. See [Set up webhooks](/guides/webhooks/overview).
```json collection.succeeded theme={"dark"}
{
"id": "evt_3ab4e0d5d2",
"type": "collection.succeeded",
"created_at": "2026-04-27T12:04:00Z",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"charge_id": "ch_1a2b3c4d5e6f",
"checkout_id": "chk_2N3o4P5q6R7s8T9u",
"status": "SUCCEEDED",
"amount": "29.00",
"currency": "USD"
}
}
```
## Test it
Use a sandbox key (`sk_sandbox_...`) and `mode: "test"` to run the whole flow against test-mode products without moving real funds. Open the overlay, pay with a test card, and confirm your webhook receives `collection.succeeded`. See [Sandbox](/integrate/sandbox).
## Go live
When you're ready, swap one thing: create sessions with a **live** secret key (`sk_live_...`) against `https://api.bachs.io`. The live session's `checkout_url` points at the live checkout, so the client code does not change at all. See [Go live](/go-live).
## Methods
### `Bachs.Initialize(options)`
Configure the SDK once, when your app loads. Returns `Bachs` for chaining.
| Option | Type | Required | Description |
| - | - | - | - |
| `onEvent` | `function` | No | Global handler for every checkout lifecycle event. |
| `baseUrl` | `string` | No | Checkout origin used to build URLs for bare tokens and to validate `checkoutUrl`. Defaults to the live checkout; set the sandbox origin while you build with tokens. Not needed when you pass full `checkoutUrl`s. |
### `Bachs.Checkout.open(args)`
Opens the overlay. Returns a promise that resolves once the modal is mounted and rejects on a bad token or a `checkoutUrl` from the wrong origin.
| Argument | Type | Required | Description |
| - | - | - | - |
| `checkoutUrl` | `string` | One of the two | The session's `checkout_url` from your server. Must be on the configured checkout origin. |
| `token` | `string` | One of the two | Only the token segment of the URL; the SDK builds the URL on `baseUrl`. |
| `onEvent` | `function` | No | Extra handler for this checkout only, on top of the global one. |
| `options.showCloseButton` | `boolean` | No | Show the close button. Defaults to `true`. |
| `options.autoCloseOnComplete` | `boolean` | No | Close the overlay about 1.2 seconds after a successful payment. Defaults to `true`. |
### `Bachs.Checkout.close()` and `Bachs.Checkout.isOpen()`
Close the overlay programmatically, and check whether it is currently open.
## How the overlay stays secure
* **No card data on your page.** The card form lives in the Bachs iframe on `checkout.bachs.io`. Your site and the SDK never see the card number, CVV, or PIN.
* **Server-set amounts.** The token references a session whose amount and currency were fixed on your server. The browser cannot change what the customer pays.
* **Origin-locked messaging.** The SDK only accepts messages from the exact Bachs checkout origin it loaded, and only from its own iframe. Anything else is ignored.
## Overlay or hosted page?
Both run the same checkout session; they differ in where the customer pays.
| | Overlay | Hosted page |
| - | - | - |
| Where the customer pays | In a modal on your page | On the Bachs-hosted page after a redirect |
| Page context | Kept; the customer never leaves | Left and returned to via `success_url` |
| Integration | Script tag or `@bachs/js`, a few lines of JS | A plain redirect to `checkout_url`, zero JS |
| Browser events | Full lifecycle events (`checkout.completed`, ...) | None; you get the redirect and webhooks |
| Best for | Keeping customers in your product | The simplest possible integration |
The [live demo](https://snapkit.bachs.io) has a toggle that opens every scenario both ways, on identical sessions.
## Troubleshooting
Check the browser console for a `checkout.error` event. The usual causes: the SDK was not loaded before calling `open()`, the token is invalid, or the `checkoutUrl` is on a different origin than the SDK expects (set `baseUrl` to match where the session was created).
Confirm `onEvent` is set in `Initialize()` or passed to `open()`, and that no other `message` listener on your page stops propagation. Events only flow while the overlay is open.
The session is probably expired or the token malformed; create a fresh session and open it promptly. Sessions default to a 60 minute lifetime.
## Next steps
* [Live demo](https://snapkit.bachs.io): click through every scenario on this page against the sandbox.
* [Accept a payment](/guides/checkout/checkout-sessions): the full checkout session flow the overlay opens.
* [Set up webhooks](/guides/webhooks/overview): receive and verify `collection.succeeded`.
* [The checkout session object](/api-reference/checkout-sessions/object): every field the API returns and accepts.
# Create a portal session
Source: https://docs.bachs.io/guides/customer-portal/create-portal-session
Mint an authenticated customer portal session and get the URL to redirect your customer to.
In this guide you'll create a [customer portal](/guides/customer-portal/overview) session for a customer and redirect them to it. By the end, a customer who clicks "Manage subscription" in your app lands in the portal already signed in, with no second login.
Create a session at the moment the customer asks for it. Sessions are short-lived, so a URL minted ahead of time and stored is usually dead by the time it's used. Always mint a fresh one per request.
## Prerequisites
* An API key with the `customers:write` scope. See [Permissions](/api-reference/permissions).
* The customer's `customer_id`, prefixed `cust_`. See [Manage customers](/guides/customers).
## Steps
Call `POST /v1/customers/{customer_id}/portal-sessions`. There is no request body: what the customer can do in the portal comes from your portal settings, not from this call.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/customers/cust_1a2b3c4d5e6f/portal-sessions \
-H "Authorization: Bearer $BACHS_API_KEY"
```
```json Response theme={"dark"}
{
"id": "psn_9f2c4a7b1d3e",
"url": "https://portal.bachs.io/s/6Yc0nQpR2vX1sK7fLbA9tE"
}
```
| Field | Type | Description |
| - | - | - |
| `id` | string | The session identifier, prefixed `psn_`. Use it to correlate a session with your own logs. It is not a credential and cannot be exchanged for access. |
| `url` | string | The URL that opens the portal as this customer. It carries the session credential, so it works on any device. |
Send the customer to `url` and nothing else.
```js theme={"dark"}
const res = await fetch(
`https://sandbox-api.bachs.io/v1/customers/${customerId}/portal-sessions`,
{ method: "POST", headers: { Authorization: `Bearer ${process.env.BACHS_API_KEY}` } }
);
const { url } = await res.json();
return Response.redirect(url, 303);
```
The customer's session is now theirs. Anything they change emits the same webhook as the equivalent API call: `customer.subscription.updated` on a plan change or scheduled cancellation, `customer.subscription.deleted` on a cancellation, `invoice.paid` when a recovery payment succeeds.
There is no redirect back to you unless you've set a return URL in your portal settings, and the customer may close the tab at any point. Treat [webhooks](/guides/webhooks/overview) as the only reliable signal that something changed.
## Errors
| Status | `error_code` | Cause | Resolution |
| - | - | - | - |
| `401` | `UNAUTHORIZED` | Missing or invalid API key. | Send a valid key in the `Authorization` header. See [Authentication](/authentication). |
| `403` | `FORBIDDEN` | The key lacks the `customers:write` scope. | Issue a key with the scope, or add it to the existing key. |
| `404` | `NOT_FOUND` | No customer with that ID on your account. | Check the `customer_id`, including that it isn't a customer from your other environment. Sandbox and live customers are separate. |
| `429` | `TOO_MANY_REQUESTS` | You've exceeded your rate limit. | Back off and retry. No session was created. |
| `500` | `INTERNAL_SERVER_ERROR` | Unexpected failure. | Retry once. If it persists, contact support with the response body. |
| `503` | `SERVICE_UNAVAILABLE` | The portal can't issue sessions right now. | Retry once. If it keeps failing, the portal isn't configured for this environment, so contact support rather than retrying in a loop. |
## Next steps
* [Customer portal](/guides/customer-portal/overview) covers what the portal does and how to configure what customers can change.
* [Manage customers](/guides/customers) shows how to create and look up the `customer_id` this endpoint needs.
* [Webhooks](/guides/webhooks/overview) explains how to receive the events a portal action emits.
# Customer portal
Source: https://docs.bachs.io/guides/customer-portal/overview
A hosted page where your customers manage their own subscriptions, invoices, cards, and contact details, without you building any of it.
The **customer portal** is a hosted page your customers open to manage their own billing. They can view and cancel subscriptions, switch plans, read their invoice history, add or replace a card, and update their contact details. You don't build any of it, and your systems never touch card data. The portal runs on Bachs infrastructure, and the customer authenticates against Bachs rather than against you.
You decide what the portal is allowed to do. Every capability below is a setting on your account, so the portal can be a read-only receipt archive for one merchant and a full self-service console for another.
## Two ways in
Both produce the same thing: an authenticated session for one specific customer, and a URL that opens it.
* **From your app.** Call [Create a portal session](/guides/customer-portal/create-portal-session) for a customer you've already signed in, then redirect them. They never see a Bachs login.
* **From the dashboard.** Open a customer and click **Copy portal link**. That mints a session exactly as the API does, and hands you the link.
There is also a **login page**, a fixed URL where a customer signs in themselves with an emailed code. Copy it from **Settings > Customer portal** and use it when you want a "Manage my billing" link that works without your app being involved. See [The login page](#the-login-page).
## How a session works
A session is scoped to a single customer at a single point in time. Creating one is an authenticated action on your side: you're vouching that this person is that customer.
Redirect them to it, or hand it over however you like. The URL carries its own credential, so it works on any device. A customer can start on your desktop app and finish on their phone.
Everything they can do is governed by your portal settings. Changes take effect immediately and emit the same webhooks as changes made through the API.
Sessions are short-lived by design. After one lapses, the customer sees an expiry page. If you've set a return URL, that page offers a link back to your site; otherwise it explains that the session ended. Create a new session whenever you need one. There's no limit, and no requirement to reuse an old one.
A portal session is a credential in a URL. Treat it like a password reset link: send it only to the customer you created it for, don't log it, and don't put it anywhere a third party can read it. Anyone holding the URL is that customer until it expires.
## What your customers can do
The portal has four sections: **Overview** for subscriptions, **Invoices**, **Billing** for saved cards, and **Profile** for contact details. Every capability below is individually controlled by your portal settings. Defaults are noted.
### Subscriptions
Customers see each subscription with its plan name, price, billing cadence, renewal or trial-end date, and the card that pays for it.
| Capability | Default | Notes |
| - | - | - |
| View subscriptions | Always on | Includes status, next billing date, and the card on file. |
| Cancel a subscription | On | You choose whether cancellation takes effect at **period end** (the customer keeps access until the cycle they've paid for runs out) or **immediately**. |
| Collect a cancellation reason | Off | When on, the customer picks from a fixed reason list before cancelling, and can add a comment. You can narrow the list to the reasons you care about. |
| Undo a scheduled cancellation | Follows cancellation | If a cancellation is scheduled for period end, the customer can reverse it before the date. |
| Switch plans | Off | Requires an allowlist of the products a customer may move to. An empty allowlist means no switching. The customer sees a price preview (what's charged today, what credit is granted, the new recurring amount) before confirming. See [Proration](/guides/subscriptions/proration). |
Subscriptions sit on **Overview**, the page a customer lands on, each with the actions your settings allow.
Cancelling opens a confirmation that states when access actually ends, and collects a reason when you've asked for one.
### Invoices
| Capability | Default | Notes |
| - | - | - |
| Invoice history | On | Every issued invoice, whether paid, open, or uncollectible. Drafts and voided invoices are never shown. |
| Invoice detail | Follows history | Line-by-line breakdown with quantities and billing periods, plus subtotal, credit applied, amount paid, and amount still due. |
| Payment history per invoice | Follows history | Every attempt made to collect that invoice, in order, with the outcome and the card used. A retried invoice reads as a history, not a single line. |
Opening one shows what was billed and every attempt made to collect it.
### Payment methods
Saved cards are always visible to the customer they belong to: brand, last four digits, expiry, and which one is the default. Expired cards are flagged.
| Capability | Default | Notes |
| - | - | - |
| View saved cards | Always on | Removed cards are never listed. |
| Add, remove, set default | Off | Turning this on lets the customer add a card, choose their default, and remove one they no longer use. A card that is the only way an active subscription can bill cannot be removed. |
Adding a card opens a secure hosted page. Nothing is charged. The card is saved for future billing, and the customer chooses whether it becomes their default. Their first card always becomes the default, so there is always something to bill.
Saving a card without a charge is supported for **USD** today. Where a currency has no zero-amount card-save step, the portal hides the "add card" action rather than quietly turning it into a payment the customer didn't agree to.
### Recovering a failed payment
When a subscription is `past_due` or `unpaid`, the portal shows the amount outstanding and a button that opens a hosted page to pay it with a new card. This is the same recovery flow the automated dunning email links to. See [Payment recovery](/guides/subscriptions/failed-payments).
### Contact details
| Capability | Default |
| - | - |
| Update name | Off |
| Update phone number | Off |
Email is deliberately not editable. It's the identity the customer signs in with, and changing it in a self-service page is an account-takeover path.
Where you haven't opened a field for editing, the portal shows it locked and points the customer at your support channel rather than hiding it.
## Configuring the portal
Portal settings live in your dashboard. There is no "enable portal" switch: the portal exists for every live account, and the settings decide what it does.
| Setting | What it controls |
| - | - |
| Cancellation | Whether customers may cancel, and whether it takes effect at period end or immediately. |
| Cancellation reasons | Whether a reason is required, and which reasons you offer. |
| Invoice history | Whether the invoices section exists. |
| Payment method management | Whether customers may add, remove, or change their default card. |
| Plan changes | Whether customers may switch plans, and the allowlist of products they may switch to. |
| Editable details | Name and phone number, independently. |
| Support URL | A "contact support" link shown in the portal. |
| Return URL | Where the portal sends a customer who is finished, and where the expiry page's back-link points. |
| Login page | Whether the self-serve login URL is live, and the URL itself. |
Settings apply the moment you save them, including to sessions that are already open.
### The login page
Your portal has a login URL, shown with a copy button under **Settings > Customer portal**. A customer opens it, enters their email, receives a one-time code, and lands in the same portal an API-created session would have opened. It's the link to put in a footer, a receipt email, or a "Manage my billing" menu item.
Every account has one. It's generated for you, and you don't need to do anything to switch it on. Turning the login page **off** closes that route without affecting API or dashboard sessions.
The login URL is available in the dashboard only. It isn't returned by the API, so copy it from your settings when you need it.
## Opening the portal from the dashboard
You don't need to write any code to use the portal. Go to **Customers**, open the customer, and click **Copy portal link**. That creates a session for them and copies the URL to your clipboard, ready to paste into a reply.
It's the same object the API returns, so the link behaves identically: short-lived, single-customer, and usable on any device. This is the fastest route for support, when a customer emails asking to cancel and you'd rather they did it themselves.
Because handing someone an authenticated session is effectively acting as that customer, the dashboard records who created it. The action requires the **manage customers** permission.
## What happens in your system
Portal actions are not a side channel. A cancellation, plan change, or card update made in the portal is the same operation as the equivalent API call, and it emits the same webhooks:
* `customer.subscription.updated` when a plan changes, or a cancellation is scheduled or reversed
* `customer.subscription.deleted` when a subscription is canceled
* `invoice.paid` when a recovery payment succeeds
Keep granting and revoking access off webhooks. A customer who cancels in the portal never touches your app, so a webhook is the only signal you get. See [Webhooks](/guides/webhooks/overview).
## FAQ
No. If you already know who the customer is, create a session through the API and redirect them, and they're signed in on arrival. If you'd rather not involve your app at all, use the login page and let them sign in with an emailed code.
No. A session is bound to one customer. Every read and write is scoped to them.
The customer sees a page explaining that the session ended. If you've configured a return URL, that page links back to your site so they can start again from somewhere they trust. Sessions are short-lived and are not renewed in place, so create a new one.
While it is live, yes, and on any device. After that it's dead. Don't store portal URLs, and don't reuse one across requests: create a fresh session each time a customer asks to manage their billing.
Your systems never see card details. Adding or replacing a card opens a secure hosted page, and Bachs stores a reusable reference to the card, not the number.
## Next steps
* [Create a portal session](/guides/customer-portal/create-portal-session) mints a session for a customer and returns the URL to redirect them to.
* [Payment recovery](/guides/subscriptions/failed-payments) covers how a failed renewal is retried, and the hosted card-update flow the portal shares.
* [Manage subscriptions](/guides/subscriptions/manage) is the API equivalent of what a customer does in the portal.
# Manage customers
Source: https://docs.bachs.io/guides/customers
Create, find, and update the customers you bill through the Bachs API.
In this guide you'll create a customer, look them up, and update their details through the API. A customer groups a buyer's payments, subscriptions, and saved payment methods under one record.
You don't have to create customers up front. Bachs creates one automatically the first time someone completes a payment (matched by email). But creating them yourself lets you attach your own IDs, pre-fill checkout, and reconcile against your system.
## Before you start
* A **sandbox API key** (`sk_sandbox_...`) with the `customers:read` / `customers:write` scopes. See [Permissions](/api-reference/permissions).
## Steps
Call `POST /v1/customers` with at least an email. Attach your own data with `metadata`.
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/customers \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "jane@example.com",
"name": "Jane Doe",
"phone_number": "+2348012345678",
"metadata": { "user_id": "usr_1234" },
"billing_address": {
"line1": "40 Yaba Road",
"city": "Lagos",
"state": "Lagos",
"postal_code": "101245",
"country": "NG"
}
}'
```
```json Response theme={"dark"}
{
"customer_id": "cust_1a2b3c4d5e6f",
"email": "jane@example.com",
"name": "Jane Doe",
"phone_number": "+2348012345678",
"metadata": { "user_id": "usr_1234" },
"billing_address": {
"line1": "40 Yaba Road",
"line2": null,
"city": "Lagos",
"state": "Lagos",
"postal_code": "101245",
"country": "NG"
},
"created_at": "2026-04-27T12:00:00Z",
"updated_at": "2026-04-27T12:00:00Z"
}
```
`billing_address` is optional. Omit it and the customer has none, shown as `null`. `line1` and `country` are required whenever you do supply one, and `country` must be a real ISO-3166-1 alpha-2 code (for example `NG`, `FR`).
Keep the `customer_id` (prefixed `cust_`). You'll use it to attach the customer to a checkout or subscription.
Retrieve one by ID, or list and search by email or name.
```bash Retrieve by ID theme={"dark"}
curl https://sandbox-api.bachs.io/v1/customers/cust_1a2b3c4d5e6f \
-H "Authorization: Bearer $BACHS_API_KEY"
```
```bash Search theme={"dark"}
curl "https://sandbox-api.bachs.io/v1/customers?search=jane@example.com" \
-H "Authorization: Bearer $BACHS_API_KEY"
```
The list response returns `{ items, pagination }`. Page through with `limit` and the cursor. See [Pagination](/guides/pagination).
Send only the fields you want to change with `PATCH`. Everything else stays as it was.
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/customers/cust_1a2b3c4d5e6f \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Jane A. Doe", "metadata": { "plan": "pro" } }'
```
```json Response theme={"dark"}
{
"customer_id": "cust_1a2b3c4d5e6f",
"email": "jane@example.com",
"name": "Jane A. Doe",
"metadata": { "plan": "pro" },
"updated_at": "2026-04-27T12:05:00Z"
}
```
`billing_address` is the one exception to "only the fields you send change." Omit it and it is untouched, send `null` and it is cleared, but send an object and it **replaces** the whole address. Any component you do not include becomes `null`; it does not merge with what is stored. See [the customer object](/api-reference/customers/object) for the full semantics and a worked example. `name` and `phone_number` don't have this behavior; they update normally.
## Attach a customer to a checkout
Once you have a `customer_id`, pass it when creating a checkout so the payment ties to that record. See [Accept a payment](/guides/checkout/checkout-sessions#attach-a-customer).
```json theme={"dark"}
{ "customer": { "customer_id": "cust_1a2b3c4d5e6f" } }
```
If you pass a new email at checkout instead, Bachs creates or matches a customer for you. The same email is never duplicated; later payments append to the existing record.
## When a checkout creates a customer
You don't have to pass a `customer` at all. Omit it on a standard hosted checkout and Bachs collects the buyer's email and name on the checkout page. By default they do not join your directory: you get the identity on `customer_details` instead. Send `customer_creation: "always"` to add them, matched by email so a buyer you already hold attaches to their existing record rather than a second one.
`customer` stays required for a subscription checkout, where recurring billing needs a durable record. See [Guest checkout](/guides/checkout/checkout-sessions#guest-checkout) for the full flow.
### Putting hosted-checkout buyers in your directory
The default, `customer_creation: "if_required"`, keeps a one-time buyer out of your directory. Their email and name still reach you, on `customer_details` on the checkout object and on [`checkout.completed`](/guides/webhooks/events/checkout-completed), and their purchases group together in your dashboard so you can look them up when they write in. What you do not get is a customer you can reach through this API: `customer` is `null` on every response and webhook for that checkout, and no [`customer.created`](/guides/webhooks/events/customer-created) or [`customer.updated`](/guides/webhooks/events/customer-updated) event fires.
Pass `customer_creation: "always"` when you create the checkout and the buyer is added to your directory like any other customer, matched by email to one you already hold. See [`customer_creation`](/guides/checkout/checkout-sessions#whether-a-guest-becomes-a-customer-customer-creation).
An email a buyer types on the checkout page is not verified. Under `always`, someone who knows one of your customers' email addresses can have their purchase recorded against that customer. They cannot see anything about that customer, and they cannot change their name, phone number or any other stored detail. Use the default, `if_required`, if you would rather a hosted-page buyer could never reach your existing customers at all.
`customer_creation` does not apply to a subscription or a `setup`-mode checkout. Both always create a customer, whatever you pass, because recurring billing needs a durable record to keep the renewal card on. Setting `if_required` everywhere will not stop subscription customers from appearing in your directory.
## From the dashboard
You can also create and manage customers without code. Open **Customers** to view a record's payments, subscriptions, and refunds in one place, or add a customer manually. Records created in the dashboard and via the API are the same customers.
## Next steps
* [The customer object](/api-reference/customers/object): every field the API returns.
* [Accept a payment](/guides/checkout/checkout-sessions): attach the customer to a checkout.
* [Sell a subscription](/guides/subscriptions/overview): bill the customer on a recurring plan.
* [Customer portal](/guides/customer-portal/overview) lets the customer manage their own subscriptions, invoices, and cards.
# Home & Analytics
Source: https://docs.bachs.io/guides/dashboard-analytics
Your real-time view of revenue, payment activity, and customer growth, filterable by date range and fully customizable.
The Home page is your primary dashboard. It surfaces your account balance, a revenue chart, and a customizable overview grid that tracks the metrics you care about most. All data reflects your live account in real time.
***
## Revenue Chart
The chart at the top of the Home page plots your revenue over time. By default it shows the last 6 months, with a tooltip on hover showing the exact figure for any given date.
Use the **Last 6 months** dropdown to change the time window. The chart updates immediately.
##
## Bachs Balance
On the right side of the Home page, your current balance is displayed alongside a breakdown of what's available versus pending.
| Field | What it shows |
| - | - |
| **Bachs Balance** | Total balance across available and pending funds |
| **Available** | Funds cleared and ready to withdraw |
| **Pending** | Funds received but not yet settled |
| **Last withdrawal** | Amount from your most recent withdrawal |
Click **Withdraw funds** to initiate a withdrawal from your available balance.
***
## Overview
Below the revenue chart, the **Overview** section is a grid of metric cards giving you a broader picture of account activity over a selected date range.
### Date Range
Use the **Custom range** picker to set a start and end date. The entire overview grid updates to reflect that window. Unlike the revenue chart, the overview supports arbitrary date ranges, not only preset periods.
### Metric Cards
The default overview includes the following cards:
**Revenue** Monthly revenue bars for the selected period. Each bar represents one month's collected amount.
**Gross Volume** Total payment volume processed: all charges regardless of outcome, including failed and refunded charges. This differs from revenue, which reflects only settled amounts.
**Payment Breakdown** A summary of charges split by outcome for the selected period:
| Outcome | Color | What it counts |
| - | - | - |
| `Succeeded` | Green | Payments that completed and settled |
| `Pending` | Orange | Payments initiated but not yet confirmed |
| `Refunded` | Purple | Charges that were fully or partially refunded |
| `Failed` | Red | Payment attempts that were declined or errored |
**Net Volume** Gross volume minus refunds and fees. This is the closest approximation of actual earnings for the period.
**New Customers** Count of unique customer records created within the selected range, i.e. first-time payers.
**Latest Payment** The most recent charge on your account, shown with amount and status. Useful for confirming a payment came through without navigating to Transactions.
**Payments** A chart or count of total payment events for the period, across all statuses.
***
## Customizing the Overview
Click **Customize** at the top right of the Overview section to open the customization panel.
The panel is split into two columns:
* **Visible metrics**: cards currently shown on your overview, in display order. Drag to reorder. Click **×** to remove a card.
* **Available metrics**: cards not currently shown. Click **+** to add them.
### Available metrics to add
| Metric | Description |
| - | - |
| **Failed Payments** | Payment attempts that did not succeed |
| **Top Customers by Spending** | Your customers ranked by total amount paid |
Once you've arranged your visible metrics, click **Apply** to save. The number in the Apply button reflects how many cards are currently active.
To revert to the original layout, click **Reset to default** at the bottom left of the panel.
Your overview layout is saved per account. Changes persist across sessions and are not affected by date range or filter selections.
***
## Related
The full log of every charge attempt on your account.
View customer records, payment history, and manage subscriptions.
# Disputes
Source: https://docs.bachs.io/guides/disputes/overview
When a customer files a chargeback or payment dispute through their card network or payment provider, we create a dispute record in your account and email the account owner. You have a limited window to submit evidence countering the claim before the deadline passes.
## What Is a Dispute?
A dispute (also called a chargeback) occurs when a customer contacts their bank or card network to reverse a payment they made to you. The network then notifies us, and we create a dispute record against the original charge.
Disputes are initiated entirely by the customer and their bank. You cannot prevent one from being opened. What you can control is whether you respond with evidence, and the quality of that evidence determines whether the dispute is resolved in your favour or against you.
***
## Financial Impact
Understanding what happens to your money at each stage is critical.
### When a dispute is created
The disputed amount is taken out of your available balance and set aside until the dispute is resolved, so you cannot pay it out in the meantime. A flat \$15 dispute fee is deducted at the same time, whatever the outcome.
### When you win (`won`)
The dispute was resolved in your favour. The amount set aside returns to your available balance. The dispute fee charged when the dispute was created is not refunded.
### When you lose (`lost`)
The dispute was resolved against you. The amount set aside goes back to the customer. The dispute fee is not charged again, since it was deducted when the dispute was created.
### When a dispute is closed (`closed`)
The dispute ended without a won or lost ruling, typically because the customer withdrew the claim or the network closed it for procedural reasons. Bachs reviews the case and releases any amount set aside. It is not released automatically, so contact [support@bachs.io](mailto:support@bachs.io) if you are waiting on it.
### When a dispute is prevented (`prevented`)
The customer was refunded before any ruling. The refund moves the money, nothing else is set aside, and the dispute does not count toward your dispute rate.
### Dispute fees
Each dispute costs a flat \$15, deducted as soon as the dispute is created, whether it is later won or lost. It is not charged again at resolution and is not refunded on a win. See [Fees](/for-you/fees).
***
## Warning Disputes
Some card networks send an **early dispute warning** before a customer files a formal chargeback. A warning gives you a window to resolve the issue with the customer directly, often by issuing a refund, before it becomes a dispute.
An early dispute warning has its own fee, charged once per payment. If a warned payment goes on to become a dispute, both fees apply. See [Fees](/for-you/fees). Treat a warning with the same urgency as a dispute.
***
## Dispute Reason Codes
The `reason` field on a dispute reflects the reason code the customer gave when filing. Common reasons include:
| Reason | What it typically means |
| - | - |
| `fraudulent` | The customer claims they did not authorise the payment. This is the most common and hardest to win without strong evidence. |
| `product_not_received` | The customer claims the product or service was not delivered. |
| `product_unacceptable` | The customer received the product but claims it was significantly different from what was described. |
| `duplicate` | The customer believes they were charged more than once for the same purchase. |
| `subscription_cancelled` | The customer claims they cancelled their subscription but were still charged. |
| `credit_not_processed` | The customer claims a refund was promised but not issued. |
| `unrecognized` | The customer does not recognise the charge on their statement. |
The reason code determines what evidence is most relevant to your response. See [What Makes a Strong Response](#what-makes-a-strong-response) below.
***
## Response Deadlines
Every dispute has a `response_deadline_at` timestamp. This is the hard deadline by which you must call [Submit Dispute](/api-reference/disputes/submit-dispute). Saving or submitting evidence after the deadline returns `410` with `error_code: DEADLINE_PASSED`, and your evidence is not accepted.
Deadlines are set by the payment network and are typically 7–21 days from when the dispute is opened, though this varies. Build alerting around the `response_deadline_at` field. Do not rely on checking disputes manually.
The deadline can also move earlier after a dispute is created. If this happens, we will update the `response_deadline_at` field on the dispute record.
Missing the `response_deadline_at` window means the network will not accept
any further evidence for that dispute. Make sure you have monitoring and
alerts in place so you never miss this deadline.
***
## What Makes a Strong Response
The evidence you submit should directly address the reason for the dispute. A submission with no evidence, or evidence that does not speak to the stated reason, is unlikely to succeed.
**For `fraudulent` disputes:**
* Proof of delivery or service fulfilment
* IP address and device data from checkout (include in `notes`)
* Signed agreements or terms acceptance records
* Prior non-disputed charges from the same customer
**For `product_not_received` disputes:**
* Delivery confirmation, tracking numbers, or access logs
* The date the product or service was provided (`service_date`)
* Communication with the customer confirming receipt
**For `subscription_cancelled` disputes:**
* Your cancellation policy as it was presented to the customer at purchase (`cancellation_policy_disclosure`)
* Proof that the cancellation was not received or processed before the billing date
**For `credit_not_processed` disputes:**
* Your refund policy (`refund_policy_disclosure`)
* Explanation of why a refund was not issued (`refund_refusal_explanation`)
* Any communication with the customer about the refund request
**For all disputes:**
* The customer's name and email (`customer_name`, `customer_email_address`)
* Their billing address (`billing_address`)
* A clear product or service description (`product_description`)
When we have them on file, `customer_email_address`, `customer_name`, and `billing_address` arrive already filled in from the customer record attached to the disputed charge. See [Update Dispute Evidence](/api-reference/disputes/update-dispute-evidence). They're ordinary editable evidence: review them and overwrite or clear any of them like any other field before you submit.
Upload supporting documents (receipts, screenshots, email threads, signed agreements) via [Upload Dispute Document](/api-reference/disputes/upload-dispute-document) and attach them using `customer_communication_attachment_id`, `cancellation_policy_attachment_id`, `refund_policy_attachment_id`, or `uncategorized_attachment_id`.
***
## How It Works
We receive the dispute and create a record with status `needs_response`.
We email the account owner's contact address, and emit a [`dispute.created`](/guides/webhooks/events/dispute-created)
webhook event.
Call [Get Dispute](/api-reference/disputes/get-dispute) to review reason, amount,
and deadline.
Use [Upload Dispute Document](/api-reference/disputes/upload-dispute-document) and
collect `document_id` references.
Send text and document references with [Update Dispute
Evidence](/api-reference/disputes/update-dispute-evidence). You can update multiple
times before submit.
Call [Submit Dispute](/api-reference/disputes/submit-dispute) to lock and submit
your evidence.
Dispute moves to `under_review`, then resolves to `won`, `lost`, or
`closed`. Check the outcome with [Get Dispute](/api-reference/disputes/get-dispute).
***
## Dispute Statuses
| Status | Description |
| - | - |
| `needs_response` | A dispute has been opened and is awaiting your evidence. `is_response_editable` is `true`. Includes warning disputes. |
| `under_review` | You have submitted evidence and the dispute is being reviewed. `is_response_editable` is `false`. |
| `won` | The dispute was resolved in your favour. The amount set aside returns to your available balance. |
| `lost` | The dispute was resolved against you. The amount set aside has gone back to the customer. |
| `closed` | The dispute was closed without a ruling, for example because the customer withdrew it. |
| `prevented` | The customer was refunded before any ruling. It does not count toward your dispute rate. |
***
## Prerequisites
* An API key with `disputes:read` to read disputes and `disputes:write` to upload documents, save evidence and submit. See [Permissions](/api-reference/permissions).
* To act on a connected account's disputes, send its ID in the `X-Account-Id` header. See [Acting as an account](/connect/acting-as-an-account).
* An endpoint subscribed to [dispute.created](/guides/webhooks/events/dispute-created) and [dispute.updated](/guides/webhooks/events/dispute-updated) if you want to learn about a dispute as it happens rather than by polling [List Disputes](/api-reference/disputes/list-disputes).
***
## Learning about a dispute programmatically
Besides polling with [Get Dispute](/api-reference/disputes/get-dispute), a dispute sends [`dispute.created`](/guides/webhooks/events/dispute-created) when it opens and [`dispute.updated`](/guides/webhooks/events/dispute-updated) when the network changes its status, so you can react without polling. Saving or submitting evidence does not send `dispute.updated`.
***
**Important constraints**
* Evidence can only be saved or updated while `is_response_editable` is `true`. Once you call [Submit Dispute](/api-reference/disputes/submit-dispute), `is_response_editable` becomes `false` and the evidence is locked.
* Saving or submitting evidence after `response_deadline_at` returns `410 DEADLINE_PASSED`. If the dispute is already locked, you get `409 DISPUTE_NOT_EDITABLE` instead. Monitor the deadline and set alerts.
* The deadline can move earlier after a dispute is opened. Re-check `response_deadline_at` on the dispute record rather than caching it.
* Document uploads accept PDF, JPEG, PNG, and GIF files up to 10 MB each.
***
## In This Section
* [List Disputes](/api-reference/disputes/list-disputes)
* [Get Dispute](/api-reference/disputes/get-dispute)
* [Upload Dispute Document](/api-reference/disputes/upload-dispute-document)
* [Update Dispute Evidence](/api-reference/disputes/update-dispute-evidence)
* [Submit Dispute](/api-reference/disputes/submit-dispute)
# Idempotency
Source: https://docs.bachs.io/guides/idempotency
Use Idempotency-Key on public POST and PATCH requests, and recover uncertain writes without creating a new operation.
Network failures happen. A timeout or server error does not prove that a POST failed before creating a charge, withdrawal, or other resource. An `Idempotency-Key` identifies retries of the same operation so the server can avoid duplicate effects while that key is retained.
***
## How it works
Send an `Idempotency-Key` on a public `POST` or `PATCH` request to a `/v1/` endpoint. Use one key for one business operation and persist it before submitting the request. A retry of that operation uses the same key, API key, acting account, method, path and body.
1. Bachs executes the first request.
2. A successful JSON response is cached for 24 hours from when it is stored.
3. A matching retry returns the cached body and status without running the handler again. Reading a cached response does not extend its lifetime.
A timeout or 5xx leaves a write's outcome uncertain. Only successful responses are cached, so the absence of a cached response does not prove that a write had no effect. Check the operation's state before repeating it or creating a replacement with a new key. See [Handling errors](/errors#handling-errors).
***
## Using the header
```bash theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Idempotency-Key: checkout-order-12345" \
-H "Content-Type: application/json" \
-d '{
"product_cart": [{ "product_id": "prod_abc123", "quantity": 1 }],
"success_url": "https://shop.example.com/success"
}'
```
This is an illustrative sandbox request. Keep its key for retries of this operation; generate a different key for a separate checkout.
***
## Scope and caching
| Property | Value |
| - | - |
| Applies to | Public `POST` and `PATCH` requests under `/v1/` with the header; excludes internal `/v1/i/` routes |
| Other methods | `GET` and `DELETE` are not covered by this middleware |
| Cache duration | 24 hours from storing the successful response; cache hits do not refresh it |
| Cache scope | Per API key and acting account (`X-Account-Id`, when present) |
| Cached on | Successful JSON responses (2xx) |
| Replay | Cached response body and HTTP status; response headers can differ |
A 4xx or 5xx response is not cached by this middleware. Handle validation errors before trying again. For an uncertain write, reconcile its actual state before repeating it; keeping the same key is part of recovery, not proof that every failed exchange is safe to repeat.
If the original request is still running, a retry can return `409 IDEMPOTENCY_IN_PROGRESS`. Wait briefly and retry the same operation with the same key. When a key's response has expired, it no longer protects against a new execution; reconcile before resubmitting an old operation.
***
## Fingerprint mismatch 409
Each idempotency key is bound to the exact request it was first used with (method + path + body). If you reuse a key with a **different request body**, you will receive `409` with `IDEMPOTENCY_CONFLICT`:
```json theme={"dark"}
{
"detail": "Idempotency-Key was already used with a different request",
"error_code": "IDEMPOTENCY_CONFLICT"
}
```
This protects against accidentally reusing a key for a different operation. If you receive this error, generate a new unique key for your new request.
***
## Choosing good keys
Tied to a specific business operation:
* `order_ORD-12345`
* `withdrawal_2026-05-14_batch-3_item-7`
* `checkout_usr_abc_session_xyz`
Keys that could collide or repeat:
* Random UUIDs regenerated on each retry (defeats the purpose)
* Timestamps alone (collide under load)
* Generic strings like `retry_1`
***
## Recover an uncertain write
* Keep the original operation ID, `Idempotency-Key`, request body and acting account in your records.
* For a timeout or 5xx, retrieve or reconcile the operation. For a withdrawal, use [Get Payout](/api-reference/payouts/get-payout) or [List Payouts](/api-reference/payouts/list-payouts).
* If you confirm a retry is needed, reuse the original key and unchanged request. Follow `Retry-After` on rate-limit errors and wait briefly on `IDEMPOTENCY_IN_PROGRESS`.
* If you cannot establish the outcome, investigate or contact support with the request ID. Do not create a replacement operation with a fresh key solely because the first response was lost.
# Pagination
Source: https://docs.bachs.io/guides/pagination
List endpoints return results in pages. Use the cursor to walk from one page to the next.
List endpoints return a page of results at a time. Each response has an `items` array and a `pagination` object that tells you whether more results exist and how to fetch them.
## Response shape
Every list endpoint returns the same shape.
```json theme={"dark"}
{
"items": [],
"pagination": {
"next_cursor": "cur_20",
"prev_cursor": null,
"has_more": true,
"limit": 20,
"offset": 0,
"returned": 20,
"total": 47
}
}
```
Pass as `cursor` on your next request to fetch the following page. `null` on the last page.
Pass as `cursor` to fetch the previous page. `null` on the first page.
`true` when more results exist beyond this page.
The page size actually applied, after clamping to the maximum.
The record offset this page starts from.
The number of items in this page. May be less than `limit` on the last page.
The total number of records matching the query.
## Request parameters
| Parameter | Type | Default | Description |
| - | - | - | - |
| `limit` | integer | 20 | Page size. Values above 100 are clamped to 100. |
| `cursor` | string | none | A `next_cursor` or `prev_cursor` from a previous response. Takes precedence over `offset`. |
| `offset` | integer | 0 | Record offset to start from. Ignored when `cursor` is set. |
`limit` is clamped, not rejected. A `limit` above 100 returns 100 results rather than an error.
## Walking through pages
Start with no cursor, then pass `next_cursor` on each request until `has_more` is `false`.
```python Python theme={"dark"}
import requests
API_KEY = "sk_sandbox_..."
BASE_URL = "https://api.bachs.io"
cursor = None
while True:
params = {"limit": 20}
if cursor:
params["cursor"] = cursor
page = requests.get(
f"{BASE_URL}/v1/customers",
headers={"Authorization": f"Bearer {API_KEY}"},
params=params,
).json()
for customer in page["items"]:
print(customer["customer_id"])
if not page["pagination"]["has_more"]:
break
cursor = page["pagination"]["next_cursor"]
```
```javascript Node.js theme={"dark"}
const API_KEY = "sk_sandbox_...";
const BASE_URL = "https://api.bachs.io";
let cursor = null;
const all = [];
while (true) {
const params = new URLSearchParams({ limit: "20" });
if (cursor) params.set("cursor", cursor);
const page = await fetch(`${BASE_URL}/v1/customers?${params}`, {
headers: { Authorization: `Bearer ${API_KEY}` },
}).then((r) => r.json());
all.push(...page.items);
if (!page.pagination.has_more) break;
cursor = page.pagination.next_cursor;
}
```
## Cursors
Cursors are opaque tokens returned by the API. Do not build them or read meaning from their value. Always use the `next_cursor` and `prev_cursor` from a response.
Passing a malformed cursor returns a `400` with `error_code` `BAD_REQUEST`.
```json theme={"dark"}
{
"detail": "Invalid cursor format. Expected 'cur_'.",
"error_code": "BAD_REQUEST",
"doc_url": "https://docs.bachs.io/api-reference/error-reference#general"
}
```
## Tips
* Check `has_more` to decide whether to continue, rather than comparing `returned` to `limit`.
* Store `next_cursor` between requests instead of computing offsets yourself.
# Charge a saved card
Source: https://docs.bachs.io/guides/payments/charge-a-saved-card
Save a customer's card at checkout, then charge it off-session later with no customer present.
In this guide you'll save a customer's card during a checkout, then charge that card later from your server with nobody on a payment page. By the end you'll have a working flow for the payments a customer agrees to once and you collect many times: a renewal you bill yourself, a usage invoice at the end of the month, a top-up when a balance runs low.
A payment taken with the customer away is called an **off-session** charge, as opposed to an on-session one where they are on a payment page and can answer their bank. The distinction matters: an off-session charge cannot ask the customer to authenticate, so a card whose issuer demands it will be refused rather than prompt anyone.
There are two halves, and they happen at different times:
* **Save the card.** A customer completes a checkout, and Bachs keeps their card against their customer record.
* **Charge it.** Days or months later, you call `POST /v1/charges` with that customer and an amount.
Saving and charging cards this way is in beta. The behavior below might change, including field names and the shape of the response. Pin your integration to what you test, and check back before you rely on it in production.
If you want Bachs to run the billing cycle for you, use [Subscriptions](/guides/subscriptions/overview) instead. This guide is for when you decide what to charge and when.
## Your side of keeping a card
Storing a customer's card and charging it later carries obligations that are yours, not ours.
* **Tell them, and let them agree.** Card network rules require the cardholder's consent to store their card for later use. Our checkout page states this on the card form, but your own terms, and how you present the choice, are yours to get right.
* **Say what you will charge and when.** A customer who agreed to one amount has not agreed to any amount. Be specific about what you will bill and how often.
* **Let them stop it.** Give them a way to remove a saved card and to cancel whatever it is paying for.
You are responsible for your own compliance with the laws, regulations and card network rules that apply to you.
## Before you start
* A **sandbox API key** (`sk_sandbox_...`) with the `payments:write` permission. See [Authentication](/authentication) and [Permissions](/api-reference/permissions).
* A **webhook endpoint** to receive the result. See [Set up webhooks](/guides/webhooks/overview).
Everything below uses the sandbox base URL, so you can run the whole flow without moving real money.
## Steps
A saved card belongs to a customer, and charging it later names that customer by id. Create them first, and keep the `cust_` id: you will use it twice.
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/customers \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "jane@example.com",
"name": "Jane Doe"
}'
```
```json Response theme={"dark"}
{
"customer_id": "cust_1a2b3c4d5e6f",
"email": "jane@example.com",
"name": "Jane Doe",
"created_at": "2026-04-27T11:58:00Z"
}
```
Already have the customer? Reuse their id and skip this step. See [the customer object](/api-reference/customers/object).
You have two ways to save a card, and they differ only in whether the customer pays at the same time.
Send `save_payment_method` with a customer and no price. The checkout collects a card, charges nothing, and saves it. Use this when a customer signs up before they owe you anything.
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer": { "customer_id": "cust_1a2b3c4d5e6f" },
"save_payment_method": true,
"success_url": "https://shop.example.com/card-saved"
}'
```
```json Response theme={"dark"}
{
"checkout_id": "chk_u6FNakbY9shGPBC4",
"checkout_url": "https://checkout.bachs.io/c/udK0GuvyweLeCnI",
"save_payment_method": true,
"status": "open",
"amount": "0.00",
"expires_at": "2026-04-27T13:00:00Z",
"created_at": "2026-04-27T12:00:00Z"
}
```
Add `save_payment_method: true` to a normal checkout. The customer pays what they owe today, and their card is kept for later.
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"pricing": { "currency": "USD", "amount": "29.00" },
"customer": { "customer_id": "cust_1a2b3c4d5e6f" },
"save_payment_method": true,
"success_url": "https://shop.example.com/thanks"
}'
```
```json Response theme={"dark"}
{
"checkout_id": "chk_2N3o4P5q6R7s8T9u",
"checkout_url": "https://checkout.bachs.io/c/V8xQ2mZpLj9RfTa",
"save_payment_method": true,
"status": "open",
"amount": "29.00",
"currency": "USD",
"expires_at": "2026-04-27T13:00:00Z",
"created_at": "2026-04-27T12:00:00Z"
}
```
Only card payments leave something you can charge again, so a checkout that offers no card is refused rather than accepted and quietly broken. See [Why a checkout cannot save a card](#why-a-checkout-cannot-save-a-card).
Send the customer to the `checkout_url` either way. See [Accept a payment with Checkout](/guides/checkout/checkout-sessions) for the full checkout flow.
The card is saved when the customer finishes the checkout, not when you create it. Bachs sends `payment_method.saved` when the card is ready to charge.
```json payment_method.saved theme={"dark"}
{
"id": "evt_3ab4e0d5d2",
"type": "payment_method.saved",
"created_at": "2026-04-27T12:04:00Z",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"payment_method_id": "pm_4f2c9a1b8e3d5a7c6b04",
"customer": {
"customer_id": "cust_1a2b3c4d5e6f",
"email": "jane@example.com",
"name": "Jane Doe"
},
"type": "card",
"card_brand": "visa",
"card_last4": "4242",
"card_exp_month": 12,
"card_exp_year": 2034,
"currency": "USD",
"is_default": true,
"created_at": "2026-04-27T12:04:00Z"
}
}
```
Store the `pm_` id against your own record of the customer. You can charge without it, but then you are charging whichever card is their default, and you cannot show them which card you are about to bill.
`is_default` tells you whether this is the card a charge picks when you name none. The first card a customer saves becomes their default.
Do not call `POST /v1/charges` straight after creating the checkout. The customer has not entered a card yet, and the charge is refused with `NO_SAVED_PAYMENT_METHOD`.
Call `POST /v1/charges` with the customer and an amount. There is no checkout and no page for the customer to visit.
```bash Request theme={"dark"}
curl https://sandbox-api.bachs.io/v1/charges \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: invoice-2026-04-cust_1a2b3c4d5e6f" \
-d '{
"customer": "cust_1a2b3c4d5e6f",
"payment_method": "pm_4f2c9a1b8e3d5a7c6b04",
"amount": "29.00",
"currency": "USD",
"description": "April usage",
"reference": "INV-2026-04-881"
}'
```
```json Response theme={"dark"}
{
"payment_id": "ch_389305e973a841cc",
"status": "processing",
"amount": "30.85",
"amount_paid": "0.00",
"amount_remaining": "30.85",
"currency": "USD",
"fees": { "amount": "1.85", "currency": "USD" },
"payment_method": "CARD",
"checkout_id": null,
"narration": "April usage",
"reference": "INV-2026-04-881",
"customer": { "name": "Jane Doe", "email": "jane@example.com" },
"created_at": "2026-04-27T12:05:00Z"
}
```
`amount` is what the card is charged. It is larger than the `29.00` you asked for because this account passes the processing fee to the customer. On an account that absorbs the fee, the card is charged `29.00` and you settle less. See [Fees](/for-you/fees).
Leave `payment_method` out and Bachs charges the customer's default saved card.
Always send an `Idempotency-Key`. Without one, a retried request after a timeout charges the customer twice. Key it to the thing you are billing for, not to the attempt. See [Idempotency](/guides/idempotency).
The charge comes back `processing`, which means the card has been submitted and nobody has told us yet whether it worked. The answer arrives as a webhook.
```json collection.succeeded theme={"dark"}
{
"id": "evt_9f21c7e4a8",
"type": "collection.succeeded",
"created_at": "2026-04-27T12:05:04Z",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"charge_id": "ch_389305e973a841cc",
"status": "SUCCEEDED",
"amount": "30.85",
"currency": "USD"
}
}
```
If the card is refused you get `collection.failed` instead, and the charge ends at `failed` with `amount_paid` still `"0.00"`. No money moved and nothing is owed.
## Charging in a currency the card does not use
You do not have to bill in the currency the card was saved in. Ask for the amount you are owed and Bachs converts it.
Say a customer saved a card that bills in `USD`, and you invoice in `NGN`:
```json theme={"dark"}
{
"customer": "cust_1a2b3c4d5e6f",
"amount": "45000.00",
"currency": "NGN"
}
```
Bachs converts `45000.00 NGN` into `USD` at the prevailing rate and charges the card that amount. `currency` is the currency you are owed and settle in; the card is billed in its own. This is the same conversion a normal checkout does when a customer pays you in their currency.
## A declined card is an answer, not an error
`POST /v1/charges` always answers with a charge. A refused card is an outcome you read from `status`, never an exception you catch.
A refused card returns `201` with a charge like this:
```json Refused card theme={"dark"}
{
"payment_id": "ch_1791fa89510846e2",
"status": "failed",
"amount": "30.85",
"amount_paid": "0.00",
"amount_remaining": "30.85",
"currency": "USD",
"payment_method": "CARD"
}
```
So branch on `status`, and do not rely on the request raising.
Do not treat a `201` as payment received. A charge is `processing` at that moment and can still fail. Only `succeeded` means you have the money, and it reaches you as `collection.succeeded`. Code that fulfils an order on the `201` will ship goods it was never paid for.
Most charges come back `processing` and settle a few seconds later. Some come back already `failed`, when the card is refused while your request is still open. Both are normal, and both are the same response shape, so read `status` rather than assuming which one you got.
## Retrying a failed charge
A failed charge is final. To try again, create a new charge with a new `Idempotency-Key`.
Before you retry, consider why it failed. A card refused for insufficient funds may work in three days; a card refused because it expired will never work, and the customer has to save a new one. Send them to a new checkout that saves a card to replace it.
Do not retry a failed charge in a tight loop. Repeated attempts against a refused card can get your account flagged by the card networks.
## Test it in the sandbox
The sandbox takes test cards, so you can run the whole flow, save a card and charge it, without moving real money.
| Card number | What it does |
| - | - |
| `4242 4242 4242 4242` | Saves, and later charges succeed. |
| `4000 0000 0000 0341` | Saves, and later charges are declined. |
Any future expiry date and any CVC work.
The second one is worth spending time on. A card that saves and then fails is the case most integrations get wrong, because it only goes wrong long after the customer has gone.
Watch for your browser autofilling a card you used earlier. Check the field holds the card you meant before you submit, or you will test the wrong one.
## Why a checkout cannot save a card
Only a card can be charged again with the customer away. So when you ask a checkout to save a card, Bachs checks that it can offer one. If it cannot, the checkout is refused when you create it, with `CHECKOUT_CANNOT_SAVE_PAYMENT_METHOD`:
```json theme={"dark"}
{
"detail": "Saving a payment method requires a card. This checkout does not offer one, so no card could be saved.",
"error_code": "CHECKOUT_CANNOT_SAVE_PAYMENT_METHOD"
}
```
This is refused early on purpose. If we accepted the checkout, your customer could pay by bank transfer, and you would only find out there was no card when your first charge failed.
The sandbox offers every payment method, so a flow that works there can fail the first time you use a live key. Your live account only offers what has been approved for it.
Work through these checks in order.
**1. Check your live account can take card payments.** Call [`GET /v1/accounts/checkout/settings`](/api-reference/organizations/get-checkout-settings) with your live key. `USD_CARD` or `NGN_CARD` in `enabled_payment_methods` needs `"enabled": true`. If it is enabled there and you still get this error, cards have not been approved on your account yet. Send your account ID to [support@bachs.io](mailto:support@bachs.io) and we will check.
**2. Check the currency.** Cards collect in `USD` and `NGN` only. When `adaptive_pricing` is `false`, a checkout can only be paid in the currency it is priced in. So a checkout priced in any other currency has no card to offer. Price it in `USD` or `NGN`, or turn adaptive pricing on.
**3. To save without charging, you need a `USD` card.** An `NGN` card can be saved only while the customer pays. If your account takes `NGN` cards and not `USD` cards, use **Save while they pay** in [step 2](#steps), not **Save without charging**.
**4. Check your `payment_method_types`.** If you restrict the checkout, the list must include `USD_CARD` or `NGN_CARD`. A checkout restricted to `NGN_BANK_TRANSFER` cannot save anything. See [Restrict payment methods](/guides/checkout/checkout-sessions#restrict-payment-methods).
**5. Do not send a free price.** A checkout with `price_type: "free"` takes no payment, so no card comes out of it. To collect a card without charging, leave the price out, as in **Save without charging**.
If all five check out and you still get this error, send your account ID and the full error response to [support@bachs.io](mailto:support@bachs.io).
## Errors
These are refusals at the request itself, before any card is charged. They are the ones worth handling.
| Error code | Cause | What to do |
| - | - | - |
| `NO_SAVED_PAYMENT_METHOD` | The customer has no saved card. | Send them to a checkout that saves one. A customer who has paid you before has not necessarily saved a card. |
| `SAVED_PAYMENT_METHOD_NOT_FOUND` | The `payment_method` you named does not belong to this customer. | Check the `pm_` id. Cards are scoped to one customer, so another customer's card reads as missing. |
| `PAYMENT_METHOD_UNUSABLE` | The saved card has expired or been removed. | Ask the customer to save a new card. |
| `PAYMENT_METHOD_NOT_ENABLED` | Your account cannot take card payments in this currency. | Check which currencies your account can collect in. See [Payment method support](/guides/payments/payment-method-support). |
| `CHECKOUT_CANNOT_SAVE_PAYMENT_METHOD` | The checkout offers no card, so nothing could be saved. | Cards are the only method that can be charged again. See [Why a checkout cannot save a card](#why-a-checkout-cannot-save-a-card). |
| `NOT_FOUND` | No customer with that id. | Customer ids start with `cust_`. |
If the request fails with a network error or a `5xx`, do not assume nothing happened. The charge may have gone through. Retry with the same `Idempotency-Key`, which returns the original charge instead of creating a second one.
## Next steps
* [The payment object](/api-reference/payments/object) for every field on a charge
* [Set up webhooks](/guides/webhooks/overview) to receive `payment_method.saved`, `collection.succeeded` and `collection.failed`
* [Idempotency](/guides/idempotency) for safe retries
* [Subscriptions](/guides/subscriptions/overview) if you want Bachs to run the billing cycle
* [Refunds](/guides/refunds) to return money from a charge
# Deposit Limits
Source: https://docs.bachs.io/guides/payments/deposit-limits
Per-currency caps on the maximum amount a customer can pay in a single charge.
Bachs enforces a maximum deposit amount per charge to manage risk. Limits are set per account and per currency, and apply to every payment collected through your integration, including checkout sessions. [Virtual account deposits](#virtual-account-deposits) work differently, because a bank transfer cannot be refused once it is sent.
## How limits work
Each currency has a maximum single-deposit amount. When a payment is initiated, Bachs checks the charge amount against the limit for that currency before processing. If the amount exceeds the cap, the request is rejected immediately with a `DEPOSIT_LIMIT_EXCEEDED` error, no charge is attempted.
For checkout sessions with local pricing, limits are checked independently for each currency in the pricing configuration.
## What your limit is
Limits are set per account and per currency. There is no single published figure: what you are allowed depends on your business type, what you sell, the customers you serve, and your risk profile, and it can change as your account builds history with us.
Because of that, do not hardcode a limit in your integration. Read it from the API instead. When a charge exceeds the cap, the `DEPOSIT_LIMIT_EXCEEDED` error returns `details.max_allowed_amount` for the currency in question, which is the authoritative value for that account at that moment. See [Handling limit errors](#handling-limit-errors).
For non-USD currencies, a limit expressed in USD is converted using live exchange rates at the time of the request, so the local-currency cap moves with rates.
## Request a limit increase
If you need a higher Bachs checkout limit, email [support@bachs.io](mailto:support@bachs.io) with the following:
* Your Bachs account email
* Your current checkout limit (from `details.max_allowed_amount`, or your dashboard)
* The new limit you're requesting
* Why you need the increase
* What you're selling or the service you provide
* The type of customers you serve
* Your expected payment volume
This helps us understand your business and review your request. We'll get back to you once your request has been reviewed.
## Handling limit errors
When a deposit exceeds the limit, the API returns HTTP `400` with `DEPOSIT_LIMIT_EXCEEDED`:
```json theme={"dark"}
{
"detail": "Deposit limit exceeded for NGN. Requested: 5000000 NGN, Maximum allowed: 2000000 NGN",
"error_code": "DEPOSIT_LIMIT_EXCEEDED",
"details": {
"requested_amount": "5000000",
"max_allowed_amount": "2000000",
"currency": "NGN",
"effective_org_id": "acct_7KpQ2mNv4XbR9dLc"
}
}
```
The `details` object tells you exactly what was requested and what the cap is, so you can surface a clear message to your customer or split the payment into smaller charges.
Check `details.max_allowed_amount` and `details.currency` to show your customer the maximum they can pay in a single charge rather than a generic error.
## Connected accounts
If your integration uses connected accounts, limits are resolved against the **effective organization**: typically the connected account itself, since each account carries its own limit. The `effective_org_id` field in the error response tells you which organization's limit was applied.
## Virtual account deposits
A deposit into a [virtual account](/guides/virtual-accounts/overview) is a bank transfer the customer has already sent, so it is never rejected and never returns `DEPOSIT_LIMIT_EXCEEDED`. Instead:
* **Two limits apply:** one on a single deposit, and one on all deposits into the account in a day, counted in UTC. By default both are NGN 5,000,000. Your account may have its own figures.
* **A deposit over either limit is held for manual review.** You still receive `collection.succeeded` straight away, with nothing in it to say the deposit is held. The money reaches neither your pending nor your available balance until we release it, and no event is sent when we do.
If you expect large deposits, ask for higher limits before they arrive. Email [support@bachs.io](mailto:support@bachs.io) with the details listed under [Request a limit increase](#request-a-limit-increase), and say the limits are for virtual account deposits. If a large deposit has not reached your balance, send us its `charge_id`.
# Payment method support
Source: https://docs.bachs.io/guides/payments/payment-method-support
Every payment method Bachs supports, the currencies each one accepts, and which products they work with.
Payment methods accept certain currencies and work with certain products. Check that the methods you need cover your customers before you build.
* [Currency support](#currency-support)
* [Product support](#product-support)
* [Why a checkout has no payment method](#why-a-checkout-has-no-payment-method)
* [How methods are named](#how-methods-are-named)
This page describes what the platform supports. Your own account may have fewer methods enabled, and a single checkout can offer fewer still. The `payment_methods` array on a [checkout session](/guides/checkout/checkout-sessions) is the list your customer actually sees.
## Currency support
Each row below is one corridor. A corridor name is the value you send in `payment_method_types`, and for everything except card it is also the value the API returns on `payment_method`. See [How methods are named](#how-methods-are-named) for the card exception.
| Payment method | Corridor | Currency | Type |
| - | - | - | - |
| Card | `USD_CARD` | `USD` | Fiat |
| Card | `NGN_CARD` | `NGN` | Fiat |
| Bank transfer | `NGN_BANK_TRANSFER` | `NGN` | Fiat |
| Mobile money | `MOMO_GHS` | `GHS` | Fiat |
| Mobile money | `MOMO_KES` | `KES` | Fiat |
| Mobile money | `MOMO_TZS` | `TZS` | Fiat |
| Mobile money | `MOMO_UGX` | `UGX` | Fiat |
| Mobile money | `MOMO_XAF` | `XAF` | Fiat |
| Mobile money | `MOMO_XOF` | `XOF` | Fiat |
| Mobile money | `MOMO_RWF` | `RWF` | Fiat |
| Mobile money | `MOMO_MWK` | `MWK` | Fiat |
| Mobile money | `MOMO_ZMW` | `ZMW` | Fiat |
| Crypto | `CRYPTO` | `BNB_BEP20`, `ETH_ETH`, `SOL_SOL`, `USDC_BEP20`, `USDT_BEP20`, `USDT_ERC20`, `USDT_SOL`, `USDT_TRC20` | Crypto |
A crypto code names the asset and the network together. The same asset on a different network is a different value, and sending to the wrong network loses the funds.
Amounts below a currency's minimum are not offered. A method can be enabled and still not appear on a small checkout for that reason.
## Product support
| Payment method | One-time payment | Subscription | Save for later | Free trial | Payment links |
| - | - | - | - | - | - |
| Card (`USD`) | ✓ Supported | ✓ Supported | ✓ Supported | ✓ Supported | ✓ Supported |
| Card (`NGN`) | ✓ Supported | Limited availability | While paying only | Unsupported | ✓ Supported |
| Bank transfer | ✓ Supported | Unsupported | Unsupported | Unsupported | ✓ Supported |
| Mobile money | ✓ Supported | Unsupported | Unsupported | Unsupported | ✓ Supported |
| Crypto | ✓ Supported | Unsupported | Unsupported | Unsupported | ✓ Supported |
**Subscription** means the method can back a recurring charge. Only cards can, because later cycles are billed without the customer present, and NGN cards are not enabled for subscriptions by default. Ask us if you need them.
**Save for later** means the method can be stored on a customer and charged again, which a [setup checkout](/guides/checkout/checkout-sessions) collects. Only the card rail matching the checkout's currency can do this, so a setup checkout in USD offers the USD card and nothing else. An `NGN` card can be saved only while the customer pays. It cannot be saved on a checkout that charges nothing. See [Why a checkout cannot save a card](/guides/payments/charge-a-saved-card#why-a-checkout-cannot-save-a-card).
**Free trial** means the method can start a subscription that takes no money up front. It requires saving the method without charging it, so it follows the same rule as Save for later.
## Why a checkout has no payment method
A checkout that cannot offer anything is refused when you create it rather than handed to a customer who cannot pay. Creating it would send that customer to a page with nothing to pay with.
Four error codes cover this, and which one you get tells you where the problem is.
| Code | What it means |
| - | - |
| `ACCOUNT_NOT_ACTIVATED` | The account has methods configured, but is not approved to accept live payments yet. Go to step 2. |
| `ACCOUNT_PAYMENT_METHODS_RESTRICTED` | Every method on the account is restricted. Your own settings cannot lift this. Go to step 2. |
| `CHECKOUT_HAS_NO_PAYMENT_METHOD` | Nothing was available before any restriction of yours was applied, and the account is not the reason. Start at step 1. |
| `CHECKOUT_RESTRICTION_LEAVES_NO_PAYMENT_METHOD` | Your own `payment_method_types` narrowed the checkout to nothing. A subscription checkout restricted to bank transfer is the common way to hit it. |
Work through these in order.
**1. Read what your account has enabled.** Call [`GET /v1/accounts/checkout/settings`](/api-reference/organizations/get-checkout-settings). At least one corridor in `enabled_payment_methods` needs `"enabled": true`. `CRYPTO` also needs at least one asset set to `true` in its `currencies` map, since a `CRYPTO` entry that is enabled with every asset `false` offers nothing.
**2. Confirm the account can currently use it.** `enabled_payment_methods` is your own configuration. It does not tell you whether the account is able to collect with those methods, and a method can read as enabled while the account cannot offer it. Two things cause that, and they have their own error codes. `ACCOUNT_NOT_ACTIVATED` means the account has not finished verification and is not live yet, which is the most common cause on a new live key: see [Go live](/go-live), or keep building against a sandbox key, where every method is available. `ACCOUNT_PAYMENT_METHODS_RESTRICTED` means the methods have been restricted on the account, which you cannot change from your own settings: contact [support@bachs.io](mailto:support@bachs.io).
**3. Check the currency you are collecting in.** When `adaptive_pricing` is `false`, the checkout can only be paid in its `base_currency`, so any method that does not accept that currency drops out. Either turn adaptive pricing on, or price the checkout in a currency your enabled methods accept. The [currency support](#currency-support) table above lists which method accepts what.
**4. Check what the checkout is for.** Subscriptions, free trials, and setup checkouts can only be paid by card, as the [product support](#product-support) table sets out. They need a card enabled in a currency you can charge, and enabling bank transfer or mobile money does not help them.
If all four check out and you still cannot create a checkout, send your account ID and the full error response to [support@bachs.io](mailto:support@bachs.io).
## How methods are named
A corridor name is the same wherever it appears, with one exception. The value you use to restrict a checkout in [`payment_method_types`](/guides/checkout/checkout-sessions#restrict-payment-methods) is the value the API returns as `payment_method` on a `payment` or `checkout` object, for every method except card.
```json theme={"dark"}
{ "payment_method_types": ["USD_CARD"] }
```
That offers `USD_CARD` and drops `NGN_CARD`. A card charge reports `payment_method` as `CARD`, not `USD_CARD` or `NGN_CARD`. Read the charge's currency to tell which card corridor collected it. Bank transfer, mobile money, and crypto have no such exception: the corridor name you restrict with is the value you get back.
| Where it appears | Card | Bank transfer | Mobile money | Crypto |
| - | - | - | - | - |
| `payment_method_types` value (request) | `USD_CARD` / `NGN_CARD` | `NGN_BANK_TRANSFER` | `MOMO_GHS` … `MOMO_ZMW` | `CRYPTO` |
| `payment_method` (response) | `CARD` | `NGN_BANK_TRANSFER` | `MOMO_GHS` … `MOMO_ZMW` | `CRYPTO` |
## Related
* [Restrict payment methods](/guides/checkout/checkout-sessions#restrict-payment-methods): limit what one checkout offers
* [Supported currencies](/for-you/supported-currencies): collection, balance, and withdrawal currencies
# Statuses
Source: https://docs.bachs.io/guides/payments/statuses
Understand payment charge statuses, what each one means, and how substatuses add detail.
A charge tracks the outcome of a customer's payment. Its `status` tells you where the charge stands. Some statuses also have a `substatus` that explains what is happening or why the charge ended.
Use [Retrieve a payment](/api-reference/payments/get-payment) to read a charge, or use [webhooks](/guides/webhooks/overview) to receive payment updates as they happen.
## Charge statuses
| Status | Meaning |
| - | - |
| `open` | The charge can still be paid. A payment method may be waiting for the customer, or the customer may be able to try again. |
| `incomplete` | The checkout ended without a successful payment. See `substatus` for more detail. |
| `succeeded` | The payment was successfully collected. |
| `accepted` | An underpayment or overpayment was accepted as the final payment outcome. |
| `underpaid` | The customer paid less than the amount due. |
| `overpaid` | The customer paid more than the amount due. |
| `failed` | The charge ended after a payment attempt failed and no further attempt is available. |
| `refunded` | The payment was fully refunded. |
| `partially_refunded` | Part of the payment was refunded. Check `refunded_amount` for the amount returned. |
| `auto_refunded` | The payment was fully refunded automatically. |
## How statuses and substatuses relate
`status` describes the charge's overall state. `substatus` adds detail to certain statuses. It does not replace `status`, and it does not by itself confirm that money was collected.
For an `open` charge, `substatus` describes the current payment attempt:
| Status | Substatus | Meaning |
| - | - | - |
| `open` | `awaiting_payment` | A payment attempt is active and waiting for payment. |
| `open` | `awaiting_approval` | The customer needs to approve the payment, for example with a PIN, OTP, 3DS check, or phone prompt. |
| `open` | `last_attempt_failed` | The latest attempt failed, but the charge is still open and another attempt may be made. |
| `open` | `null` | No payment method has been selected, or there is no current attempt detail to show. |
For a charge that has ended without payment, `substatus` explains why:
| Status | Substatus | Meaning |
| - | - | - |
| `incomplete` | `abandoned` | The checkout ended without payment. |
| `incomplete` | `expired` | The charge timed out, another payment method completed the payment, or the merchant cancelled it. |
| `failed` | `insufficient_funds` | The account or wallet did not have enough funds. |
| `failed` | `declined` | The payment was declined. |
| `failed` | `authentication_failed` | The payment was not approved. |
| `failed` | `expired_card` | The card has expired. |
| `failed` | `invalid_details` | Payment details were invalid. |
| `failed` | `timed_out` | The payment attempt took too long. |
| `failed` | `provider_error` | The payment provider could not process the attempt. |
The `open` substatus reflects the current attempts and can change as they progress. For example, an `open` charge with `last_attempt_failed` is still payable. A failed attempt does not mean the whole charge has failed.
A completed charge keeps its ending `substatus`. For compatibility, webhooks can report the reason as a status such as `cancelled` or `expired`, while the Payments API reports `incomplete` with a substatus. Check the status and substatus together when handling these records.
## Decide what to do
* Keep the order pending while the charge is `open`.
* Fulfill when the payment reaches `succeeded`, `accepted`, or `overpaid`, applying your own rules for the amount received.
* Do not fulfill an `incomplete` or `failed` charge.
* When a charge is refunded, use its refund status and `refunded_amount` to update your records.
* For an asynchronous payment, use webhooks for updates and retrieve the charge when you need its current state.
For refund processing and refund-specific statuses, see the [Refunds guide](/guides/refunds).
# Supported currencies
Source: https://docs.bachs.io/guides/payouts/global-payouts
Check payout currencies, methods, funding requirements and destination details.
Choose where you want your payout to arrive, then prepare the details required for that destination. You can save multiple destinations belonging to you and select one for each payout.
## Supported currencies and methods
| Destination currency | Method |
| - | - |
| NGN | Nigerian bank transfer |
| GHS, KES, TZS, UGX | Bank transfer |
| GBP | Faster Payments |
| EUR | SEPA |
| USD | ACH, Wire or RTP |
| CAD | EFT, Interac to a bank account or Interac to an email address |
| GHS, KES, TZS | Mobile money |
| USDT, USDC | Stablecoin payout on a supported network |
A supported currency does not support every method. For example, KES supports bank transfer and mobile money, while UGX supports bank transfer.
EUR bank destinations cover Austria, Belgium, Cyprus, Germany, Estonia, Spain, Finland, France, Greece, Croatia, Ireland, Italy, Lithuania, Luxembourg, Latvia, Malta, the Netherlands, Portugal, Slovenia and Slovakia.
SWIFT payouts are not currently offered. The BIC required for a SEPA destination identifies the bank; it does not select a SWIFT payout.
For stablecoins, choose the exact asset and network your wallet supports: `USDT_TRC20`, `USDT_BEP20`, `USDT_ERC20`, `USDT_SOL`, `USDC_ERC20`, `USDC_BEP20`, `USDC_SOL` or `USDC_BASE`. See [Supported currencies](/for-you/supported-currencies#withdrawals) for the wider currency reference.
## Mobile money networks
Mobile money payouts reach these networks. A network that is not listed cannot receive a payout in that currency, even if you can collect from it.
| Currency | Country | Networks | Smallest payout | Largest payout |
| - | - | - | - | - |
| GHS | Ghana | MTN, Telecel (formerly Vodafone), AirtelTigo | GHS 5 | GHS 25,000 |
| KES | Kenya | M-Pesa (Safaricom), Airtel, T-Kash (Telkom) | KES 20 | KES 250,000 |
| TZS | Tanzania | Vodacom M-Pesa, Tigo Pesa, Airtel, Halopesa | TZS 1,000 | TZS 5,000,000 |
The smallest and largest amounts apply to one payout, in the currency that arrives.
Mobile money payouts are not available in UGX, RWF, MWK, ZMW, XAF or XOF yet. UGX can pay out to a bank account.
When you save a mobile money destination through the API, send the network name in `mobile_provider` exactly as the network list returns it:
```bash theme={"dark"}
curl 'https://sandbox-api.bachs.io/v1/payouts/mobile-money-providers?country_code=GH' \
-H 'Authorization: Bearer sk_sandbox_example'
```
```json theme={"dark"}
{
"country": "GH",
"providers": [
{ "name": "AIRTEL", "country": "GH" },
{ "name": "MTN", "country": "GH" },
{ "name": "VODAFONE", "country": "GH" }
]
}
```
| Country | `mobile_provider` values |
| - | - |
| Ghana (`GH`) | `MTN`, `VODAFONE`, `AIRTEL` |
| Kenya (`KE`) | `SAFARICOM`, `AIRTEL`, `T-KASH` |
| Tanzania (`TZ`) | `Vodacom`, `Tigo`, `Airtel Tanzania`, `Halopesa` |
Send `phone_number` with the country code, for example `233240000000` for Ghana. A network outside this list is accepted when you save the destination, but the payout fails when it is sent.
## Which balance funds the payout?
* Payouts to Nigerian bank accounts in NGN can use your available NGN balance.
* International bank and mobile money routes use your available USD balance. Your NGN balance cannot fund these routes.
* For a payout in a different currency from its source balance, get a quote before creating the payout.
* Keep enough available funds for the payout and its fee.
See [Overview](/guides/payouts/overview#amounts-conversion-and-fees) for amount and fee semantics. The currency a destination receives is separate from the [currencies you can hold as balances](/for-you/supported-currencies#balance-currencies).
## Prepare your destination details
| Destination | Details to prepare |
| - | - |
| Bank in Nigeria, Ghana, Kenya, Uganda or Tanzania | Supported bank code, account number and account holder information |
| GBP / Faster Payments | Account holder name, bank name, six-digit sort code and account number |
| EUR / SEPA | Account holder name, bank name, IBAN and BIC |
| USD / ACH, Wire or RTP | Selected scheme, account holder name, bank name, nine-digit routing number, account number and US bank address |
| CAD / EFT or Interac to a bank account | Selected scheme, account holder name, bank name, three-digit institution number, five-digit transit number and account number |
| CAD / Interac to an email address | Selected scheme, account holder name and your Interac email |
| Mobile money in GHS, KES or TZS | Supported provider and your wallet phone number |
| Stablecoin wallet | Wallet address and the exact supported asset/network combination |
Use [List Banks](/api-reference/reference/list-banks) for supported local bank codes. The US bank address is the bank branch's address, not your personal address; prepare its street address, city, state, postal code and country.
On the dashboard, choose the destination type and follow the country or asset/network fields shown in the form. You can save a destination during the payout flow or in **Settings → Payout Destinations and Currencies**.
Through the API, send `type` explicitly to distinguish a bank account, mobile money wallet or crypto wallet. Select `scheme` for USD and CAD bank destinations. The [destination object](/api-reference/payout-destinations/object) describes the API fields.
## Check the destination before paying out
Payouts are available to all users, subject to account requirements and destination approval. Complete the account's required identity information and checks, then confirm its destination is approved and usable in the dashboard or read `is_usable` through the API.
`GET /v1/currencies/payout-supported` lists configured payout currencies. It does not establish that every method or destination is usable for your account. If a supported route returns `PAYOUT_CURRENCY_NOT_ENABLED`, contact [support](mailto:support@bachs.io) with the error details.
For connected accounts, the inline onboarding `payout_destination` field does not accept banks that require international routing schemes. Register GBP, EUR, USD and CAD bank destinations through `POST /v1/payouts/destinations`, acting for the account with `X-Account-Id`, and complete its other [onboarding requirements](/connect/requirements).
## Create a payout
Follow [Create a payout](/guides/payouts/payout-using-api) using the Dashboard or API instructions to save a destination, send a same-currency or converted payout, and track the result.
For the shared create, tracking and recovery flow, see [Overview](/guides/payouts/overview).
# Overview
Source: https://docs.bachs.io/guides/payouts/overview
Understand payouts and choose the dashboard or API flow.
A payout moves money from your Bachs balance to your own bank account or wallet. You can save multiple destinations and choose a usable destination for each payout.
Payouts are available to all Bachs users. Your account must still meet the payout requirements, and your destination must be approved and usable.
For example, you can pay out an NGN balance to your Nigerian bank account, or use your USD balance to send GBP to your UK bank account. See [Supported currencies](/guides/payouts/global-payouts) for supported routes and the details to prepare.
## Before you start
* Confirm that the account has an active `payouts` capability and has completed its required identity checks. International payouts require account identity information, including the name, contact email and address.
* Check your **available balance**, including enough funds for the payout fee, in **Balances** on the dashboard or with [Get Balances](/api-reference/accounts/get-balances).
* Use a destination belonging to the account holder. Check its approval and usability in the dashboard, or read its `is_usable` field through the API.
International bank and mobile money routes use your available USD balance. An NGN balance cannot fund these routes. A payout currency is not necessarily a currency you can hold as a balance; see [Supported balance currencies](/for-you/supported-currencies#balance-currencies).
The API examples in this section use `https://sandbox-api.bachs.io` and illustrative values. Sandbox payouts are simulated and do not move real money. A successful sandbox test does not establish live destination approval or bank delivery times.
## Choose how to create payouts
| Method | Use it when | Start here |
| - | - | - |
| Dashboard | You want to create and track a payout without writing code | **Balances → Withdraw**, explained in [Create a payout](/guides/payouts/payout-using-api) under Dashboard |
| API | You want your application to create and track payouts | [Create a payout](/guides/payouts/payout-using-api) under API |
| Automatic payouts | You want eligible settled collections paid out on a schedule | [Automatic payouts](/guides/payouts/payout-schedules), with Dashboard and API instructions |
You do not need an API key for the dashboard flow. API integrations use server-side secret keys with the endpoint's required scopes. Keep secret keys out of browser code.
The dashboard currently uses labels such as **Withdraw** and **Withdrawals** for payout actions and history. The guides quote those labels so you can find the controls.
## How a payout works
Add your bank account or wallet in the dashboard, or register it with [Create Destination](/api-reference/payouts/create-payout-destination). Reuse the saved destination for future payouts. It must be approved and usable before funds can be sent.
The dashboard prepares the estimate and any conversion quote for you. Through the API, use [Create Payout Quote](/api-reference/payouts/create-payout-quote) immediately before a cross-currency payout. A same-currency API payout does not need a quote.
Confirm the destination, amount, conversion rate if applicable and total debit in the dashboard. Through the API, call [Create Payout](/api-reference/payouts/create-payout) with `amount` for a same-currency payout or `quote_id` without `amount` for a converted payout. Send an `Idempotency-Key` and store the returned payout ID.
Check the record in **Balances → Withdrawals** on the dashboard. API integrations listen for [`payout.paid`](/guides/webhooks/events/payout-paid) and [`payout.failed`](/guides/webhooks/events/payout-failed), or retrieve the payout with [Get Payout](/api-reference/payouts/get-payout). An accepted or initiated payout is not proof of completion.
## Amounts, conversion and fees
On the dashboard, review **To receive**, the fee and **Total debited** before confirming. Through the API, these two request amount fields have different meanings:
| Request | Meaning of `amount` |
| - | - |
| Same-currency Create Payout | The amount your destination receives. The fee is charged on top. |
| Create Payout Quote | The source amount to convert, before the fee. The quote returns the destination amount as `to_amount`. |
With a quote, send `quote_id` without `amount` when creating the payout. The quote fixes the source and destination amounts.
For illustration, a `5000.00` NGN payout with a `100.00` NGN fee debits `5100.00` NGN. This is an example, not a published fee rate. Review [Fees](/for-you/fees#withdrawal-fees) and reconcile the payout against `total_debited` in `source_currency`.
## Accepted is not completed
A successful API create response or the dashboard's **Withdrawal started** screen means the payout was initiated. It does not mean your bank account or wallet has received the funds.
The API exposes these status values:
| Status | What your integration should do |
| - | - |
| `pending` | Keep tracking. |
| `processing` | Keep tracking. |
| `completed` | Record the payout as completed. |
| `failed` | Read `failure_reason` and reconcile the returned funds before deciding what to do next. |
Use dashboard payout history or [List Payouts](/api-reference/payouts/list-payouts) to reconcile payouts across a period. API integrations can use retrieval and listing to recover after missed webhook deliveries.
## Recover from an uncertain request
A failed confirmation or interrupted request can leave the outcome uncertain. The payout may have been created even though you did not see a successful response.
On the dashboard, check **Balances → Withdrawals** before starting another payout. If you cannot establish what happened, contact [support](mailto:support@bachs.io).
For an API timeout or `5xx` response:
1. Retrieve the payout if you have its ID. Otherwise, inspect recent payouts and reconcile against your stored request details and reference.
2. If a payout exists, track that payout instead of creating another one.
3. If the outcome remains uncertain, investigate before submitting another payout. Contact [support](mailto:support@bachs.io) if you cannot establish what happened.
Use one `Idempotency-Key` per intended payout and keep it for retries of that same request. A new payout needs a new key. Idempotency stores successful responses; it is not an unconditional guarantee that repeating a request after every error cannot create a duplicate.
## Connected accounts
When acting for a connected account, use `X-Account-Id` consistently for that account's balance, destination and payout. Its `payouts` capability must be active, and the destination must belong to its account holder.
Funding a connected account with a transfer and paying out from that account are separate operations. See [Connected account payouts](/connect/payouts) and [Onboarding requirements](/connect/requirements).
## Choose your next guide
* [Supported currencies](/guides/payouts/global-payouts): check coverage, funding rules and destination details.
* [Create a payout](/guides/payouts/payout-using-api): use the dashboard or API to save a destination, create a payout and track its result.
* [Automatic payouts](/guides/payouts/payout-schedules): configure automatic payouts of eligible settled collections through the dashboard or API.
# Automatic payouts
Source: https://docs.bachs.io/guides/payouts/payout-schedules
Set up automatic payouts from the dashboard or API for eligible settled customer collections.
A payout schedule automatically sends eligible settled customer collections to your own default destination. Configure each balance currency separately: for example, pay out NGN twice a week while keeping USD on `manual`.
You can configure the schedule in the dashboard or through the API. The schedule decides when Bachs attempts a payout; it does not guarantee when a bank or wallet receives it. Once created, a scheduled payout follows the same completion process as a payout you initiate yourself.
## Which funds are eligible?
Schedules pay out **settled customer collections**, rather than sweeping every credit to your balance.
| Balance credit | Eligible for a schedule? |
| - | - |
| A customer collection that has settled | Yes |
| A top-up you funded | No |
| A transfer received from another account | No |
| A currency conversion | No |
If a scheduled payout fails, its original collections become eligible again when their claim is released. The returned balance credit is not a new customer collection.
Each run takes whole eligible collections, oldest first, up to the amount your available balance covers. It stops at the first collection it cannot cover. A `minimum_amount` is a floor in the balance currency: if the eligible total is below it, the run pays nothing and waits for a later run. It is not the amount each payout will send.
## Choose an interval
The API uses the interval names below. The dashboard offers Instant, Daily, Weekly and Monthly, with a separate control to disable an existing schedule.
| `interval` | When a payout is attempted |
| - | - |
| `manual` | Automatic payouts are off. Create payouts yourself when needed. |
| `instant` | Triggered after eligible funds settle. This is not a bank delivery-time guarantee. |
| `daily` | Every day at `anchor_hour_utc`. |
| `weekly` | On the days in `weekly_payout_days`, at `anchor_hour_utc`. |
| `monthly` | On the dates in `monthly_payout_days`, at `anchor_hour_utc`. |
* `anchor_hour_utc` accepts `0` to `23` and defaults to `10` for clock-based intervals.
* `weekly_payout_days` accepts full weekday names, such as `["monday", "thursday"]`. Its default is `["monday"]`.
* `monthly_payout_days` accepts days `1` to `31`. Its default is `[1]`.
* Send weekly days only with `weekly`, and monthly days only with `monthly`. Other pairings are rejected.
The API's `anchor_hour_utc` is in UTC. The dashboard time picker displays a named local timezone; check that label when choosing a time.
A day that does not exist in a shorter month runs on that month's last day. For example, `[31]` runs on April 30. Dates that collapse onto the same day produce one scheduled run.
For `instant`, `next_run_at` is `null` because it reacts to settlement instead of a clock schedule. Collections that settle close together can be grouped into one payout. A minimum can hold small eligible totals until there is enough to send.
## Set a default destination
The schedule uses the approved, active default destination for the payout currency. The dashboard setup lets you choose it. API integrations set `is_default` with [Update Destination](/api-reference/payouts/update-payout-destination).
If there is no usable default destination, the schedule cannot pay out that currency. Before deleting or replacing a default destination, prepare and approve a replacement.
By default, `payout_currency` matches the balance currency. For a supported conversion, such as USD to NGN, set `payout_currency` explicitly. Bachs gets the conversion quote when the run happens, so the rate is not locked when you save the schedule. NGN to USD is not a supported schedule conversion.
Fees follow the same rules as other payouts. Keep enough available balance to cover the debit and fee; see [Overview](/guides/payouts/overview#amounts-conversion-and-fees).
## Set up automatic payouts
## 1. Open the setup
Open **Balances** and find the balance currency whose settled collections you want to pay out. Select its **Automatic withdrawals** control; **Set up withdrawals** appears as the setup action.
## 2. Choose the destination
Choose the approved, active destination you want the schedule to use. If you need another destination, select **Add a bank account** to open destination settings, then return to the setup when it is usable.
The selected destination becomes the default used for the payout currency. Check it carefully before continuing.
## 3. Choose the schedule
Select Instant, Daily, Weekly or Monthly. For a clock-based schedule, choose the day where applicable and the time in the timezone shown by the picker. You can also set a minimum to hold eligible collections until the total is worth sending.
The dashboard currently lets you choose one weekly or monthly day, with monthly dates from 1 to 28. Use the API for multiple weekly or monthly days, or month-end dates from 29 to 31.
## 4. Review and enable
Review the destination, schedule and any minimum on **Check and turn on**, then click **Turn on auto-payout**.
To change an existing setup, open the balance card's **Edit withdrawals** action. The summary offers **Edit automatic withdrawal settings**; save your edits with **Save changes**. To stop automatic payouts, select **Disable**.
Created payouts appear under **Balances → Withdrawals**. Check their records for completion; the saved schedule does not establish that a payout was sent or received.
Use `GET /v1/balance_settings` with `balance:read` to read the settings, and `POST /v1/balance_settings` with `balance:write` to update them. Neither endpoint takes an account ID in its path.
This illustrative sandbox example schedules NGN payouts for Mondays and Thursdays at 09:00 UTC, provided eligible collections total at least NGN 5,000:
```bash theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/balance_settings \
-H 'Authorization: Bearer sk_sandbox_example' \
-H 'Content-Type: application/json' \
-d '{
"schedule_by_currency": {
"NGN": {
"interval": "weekly",
"weekly_payout_days": ["monday", "thursday"],
"anchor_hour_utc": 9,
"minimum_amount": "5000.00"
}
}
}'
```
Read the saved configuration after updating it:
```bash theme={"dark"}
curl https://sandbox-api.bachs.io/v1/balance_settings \
-H 'Authorization: Bearer sk_sandbox_example'
```
See [Get payout schedule](/api-reference/payouts/get-payout-schedule) and [Update payout schedule](/api-reference/payouts/update-payout-schedule) for the full field reference.
### Understand what an update replaces
* **An omitted currency keeps its current schedule.** Sending only NGN leaves USD unchanged.
* **A named currency's schedule is replaced in full.** Send the complete intended configuration for that currency, including fields you want to retain. Omitted fields are cleared or revert to their defaults.
* **Use `"interval": "manual"` to turn a currency off.** Leaving that currency out of the request does not turn it off.
The old `cadence`, `enabled` and `anchor_day` fields are rejected. Use the documented interval and day fields.
## Connected accounts
Use the same settings endpoints with `X-Account-Id` for an account your platform manages. The account needs its own eligible settled collections, an active `payouts` capability and an approved default destination.
Transfer-funded balances are not eligible for automatic schedules. Your integration must initiate those connected-account payouts separately; see [Connected account payouts](/connect/payouts).
## Track runs and payouts
On the dashboard, review the saved schedule on its balance card and track created payouts in **Balances → Withdrawals**.
API integrations read these fields in the schedule response:
| Field | What it tells you |
| - | - |
| `next_run_at` | The next clock-based run. `null` for `manual` and `instant`. |
| `last_run_at` | When a run last happened; it does not establish bank delivery. |
| `last_withdrawal_id` | The payout created by the last run that moved money. Retrieve it with Get Payout. |
| `disabled_reason` | Why the schedule was automatically set back to `manual`, if that happened. |
Scheduled payouts appear in [List Payouts](/api-reference/payouts/list-payouts), can be retrieved with [Get Payout](/api-reference/payouts/get-payout), and emit `payout.paid` and `payout.failed`. Their generated `reference` begins with `auto_`.
If the schedule cannot create a payout for three consecutive attempts, it returns to `manual` and records `disabled_reason`. Investigate the cause before enabling it again. Setting an automatic interval clears the failure count and resumes scheduling.
# Create a payout
Source: https://docs.bachs.io/guides/payouts/payout-using-api
Create and track a payout from the dashboard or through the API.
Send money from your Bachs balance to your own bank account or wallet using the dashboard or API. Choose the instructions below for how you want to create the payout.
For either method, meet the [Overview](/guides/payouts/overview#before-you-start). Use a supported destination belonging to you and keep enough available funds to cover the payout and fee. International bank and mobile money routes use USD funds.
The dashboard currently labels the payout action **Withdraw**. The instructions below use the button and tab names you will see there. You do not need an API key or a separate API quote request to use this flow. Check that you are viewing the intended account and environment; sandbox payouts are simulated.
## 1. Open the payout form
Open **Balances**, select the balance you want to use and click **Withdraw**. Choose the **Currency Balance** and enter the amount, then click **Continue**.
Use an available NGN balance for an NGN bank payout, or your available USD balance for an international bank or mobile money route. Pending funds are not available to pay out.
## 2. Choose or add a destination
On **Select destination**, choose one of your saved, usable destinations.
If you have not saved one, select **Add payout destination**. To add another alongside existing destinations, select **Add new destination**. Choose the bank, mobile money or crypto wallet type offered for the route and enter the requested details. For bank and mobile money destinations, select the country; for a crypto wallet, choose the supported asset and network.
You can also manage saved destinations in **Settings → Payout Destinations and Currencies**. See [Supported currencies](/guides/payouts/global-payouts#prepare-your-destination-details) for the details to prepare.
Check the saved destination's review status. If it is still under review, wait until it is approved and usable before sending the payout. Save it once and reuse it for future payouts.
Select your usable destination and click **Continue**.
To add a destination from the payout form or from settings, use the **Add payout destination** panel.
## 3. Review and confirm
On **Confirm withdrawal**, check the destination and the displayed amounts:
* **To receive** is the amount your bank account or wallet should receive.
* **Withdrawal fee** or **Conversion fee** shows the fee for the route.
* **Total debited** is the amount taken from the source balance, including the fee.
* **Exchange rate** appears when currencies differ.
The dashboard prepares the estimate and conversion quote. Review the current figures before clicking **Confirm withdrawal**. If the form offers **Retry quote**, refresh the quote and review the new figures before confirming.
Your first payout can require a one-time account review. This can delay sending it; it does not change how you track its status.
## 4. Track the payout
**Withdrawal started** means the payout was initiated, not that the destination has received it.
Return to **Balances → Withdrawals** and open the record to inspect its status, timeline and details. Track it until it finishes. If the confirmation fails or its outcome is unclear, check the records before starting another payout; contact [support](mailto:support@bachs.io) if you cannot establish what happened.
Your key needs `payouts:write` to create destinations and payouts, and `payouts:read` to retrieve them. These examples use illustrative sandbox values. Replace the API key, account details and returned IDs with your own values. Sandbox does not move real money.
The API flow is the same for every supported destination: save it, check usability, create the payout and track the result. Get a quote first if the source and destination currencies differ.
## 1. Save your destination
Choose a destination type and currency from [Supported currencies](/guides/payouts/global-payouts). Use the fields required for that destination. The examples below show bank accounts; wallet destinations use the same endpoint with their own type and fields.
For a local bank, get its `bank_code` from [List Banks](/api-reference/reference/list-banks), using the destination country. For example, use `country=NG` for Nigeria.
```bash NGN bank theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/payouts/destinations \
-H 'Authorization: Bearer sk_sandbox_example' \
-H 'Content-Type: application/json' \
-d '{
"type": "bank_account",
"currency": "NGN",
"bank_code": "058",
"account_number": "0123456789"
}'
```
```bash USD bank using ACH theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/payouts/destinations \
-H 'Authorization: Bearer sk_sandbox_example' \
-H 'Content-Type: application/json' \
-d '{
"type": "bank_account",
"currency": "USD",
"name": "US operating account",
"scheme": "ach",
"account_name": "Example Business LLC",
"bank_name": "Bank of America",
"routing_number": "026009593",
"account_number": "3010001234567",
"bank_address": {
"line1": "100 North Tryon Street",
"city": "Charlotte",
"state": "NC",
"postal_code": "28255",
"country": "US"
}
}'
```
```bash GBP bank using Faster Payments theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/payouts/destinations \
-H 'Authorization: Bearer sk_sandbox_example' \
-H 'Content-Type: application/json' \
-d '{
"type": "bank_account",
"currency": "GBP",
"name": "UK operating account",
"account_name": "Example Business Ltd",
"bank_name": "Barclays",
"sort_code": "20-00-00",
"account_number": "55779911"
}'
```
Store the returned destination ID (`pd_...`) and reuse it for future payouts. Check that the account details belong to you. For NGN banks, confirm that the resolved `account_name` matches your account; a separate bank-account resolve request is optional.
For other bank routes and wallet types, see [Supported currencies](/guides/payouts/global-payouts#prepare-your-destination-details) and [Create Destination](/api-reference/payouts/create-payout-destination). USD and CAD banks require a `scheme`. The US `bank_address` describes the bank branch, not your personal address. A EUR `swift_bic` identifies the bank for SEPA; it does not select a SWIFT payout.
## 2. Check that the destination is usable
Read `is_usable` before sending a payout. If it is `false`, retrieve the destination and resolve its review or account requirements:
```bash theme={"dark"}
curl https://sandbox-api.bachs.io/v1/payouts/destinations/pd_example \
-H 'Authorization: Bearer sk_sandbox_example'
```
Replace `pd_example` with the saved ID. Check `status` and any `status_reason`, and proceed when `is_usable` is `true`. Creating a destination does not always make it usable immediately.
## 3. Create the payout
Choose the request based on the source balance and destination currency.
### Same currency
Send the destination ID and the amount your destination should receive. The fee is charged on top. Do not request a conversion quote.
```bash NGN to NGN theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/payouts \
-H 'Authorization: Bearer sk_sandbox_example' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: example-ngn-payout-001' \
-d '{
"destination": "pd_example_ngn",
"amount": "9000.00",
"reference": "example-ngn-001"
}'
```
```bash USD to USD theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/payouts \
-H 'Authorization: Bearer sk_sandbox_example' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: example-usd-payout-001' \
-d '{
"destination": "pd_example_usd",
"amount": "500.00",
"reference": "example-usd-001"
}'
```
### Different currencies
International bank and mobile money routes use your available USD balance. An NGN balance cannot fund those routes.
For a supported conversion, request a quote immediately before the payout. This example converts USD to GBP; the quote's `amount` is the source amount before the fee:
```bash theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/payouts/quotes \
-H 'Authorization: Bearer sk_sandbox_example' \
-H 'Content-Type: application/json' \
-d '{
"from_currency": "USD",
"to_currency": "GBP",
"amount": "500.00",
"payout_method": "BANK_TRANSFER"
}'
```
Read `from_amount`, `to_amount`, `exchange_rate` and `expires_at`. `to_amount` is what the destination receives. Quotes last 30 seconds. Use the returned `quote_id` to create the payout, leaving `amount` out:
```bash theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/payouts \
-H 'Authorization: Bearer sk_sandbox_example' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: example-gbp-payout-001' \
-d '{
"destination": "pd_example_gbp",
"quote_id": "pqt_example_usd_gbp",
"reference": "example-gbp-001"
}'
```
Replace the example destination and quote IDs with the returned values. Use the payout method corresponding to your destination when requesting a quote; this bank example uses `BANK_TRANSFER`.
For either request, store the returned payout ID. In the response, `amount` is in the destination currency; `fee` and `total_debited` are in `source_currency`. Reconcile against `total_debited`.
Use one `Idempotency-Key` per intended payout. If a request times out or returns `5xx`, follow [Overview](/guides/payouts/overview#recover-from-an-uncertain-request) before resubmitting. An expired quote does not establish that an earlier payout request failed.
## 4. Track the result
A successful create response means acceptance, not delivery. Listen for [`payout.paid`](/guides/webhooks/events/payout-paid) and [`payout.failed`](/guides/webhooks/events/payout-failed), or retrieve the payout:
```bash theme={"dark"}
curl https://sandbox-api.bachs.io/v1/payouts/pay_example \
-H 'Authorization: Bearer sk_sandbox_example'
```
Replace `pay_example` with the returned payout ID. Continue tracking `pending` and `processing`. Record `completed` as completion; for `failed`, inspect `failure_reason` and reconcile the returned funds.
## Reuse or change a destination
Reuse its saved ID for another payout, with a new idempotency key. You can save several of your own destinations and choose any usable one.
To switch bank accounts, create another destination. Changing routing or holder details on an existing destination requires renewed review. See [Update Payout Destination](/api-reference/payouts/update-payout-destination) for renaming a destination or making it the default.
## Common problems
| Error | What to check |
| - | - |
| `BANK_ROUTING_DETAILS_INVALID` | Required bank fields, their formats and the selected scheme. Use `detail` to identify the problem. |
| `PAYOUT_SOURCE_CURRENCY_UNSUPPORTED` | The source balance must be able to fund the route. International bank and mobile money routes require USD funds. |
| `PAYOUT_RECIPIENT_INCOMPLETE` | Complete the account's required identity information, including name, contact email and address. |
| `PAYOUT_CURRENCY_NOT_ENABLED` | Check the supported route and contact support with the error details if it should be available. |
| `AMOUNT_NOT_ALLOWED_WITH_QUOTE` | Remove `amount` when sending `quote_id`. |
| `QUOTE_EXPIRED` | After establishing that no earlier payout was created, get a new quote immediately before a new attempt. |
See [Errors](/errors) for the response format.
## Connected accounts and automatic payouts
For a connected account, use `X-Account-Id` consistently for its own balance, destination and payout. Funding it with a transfer is a separate operation; see [Connected account payouts](/connect/payouts).
[Automatic payouts](/guides/payouts/payout-schedules) apply to eligible settled customer collections. If your integration funds a connected account through transfers, initiate its payouts separately instead of enabling a schedule.
# Sell in local currencies
Source: https://docs.bachs.io/guides/products/local-pricing
Set prices in your customers' local currencies to build trust and improve payment acceptance.
In this guide you'll set local-currency prices on a product so customers see and pay in their own currency. Charging in a familiar currency builds trust, avoids the FX fees a customer's bank would add, and gets more payments approved.
Bachs gives you two levers that work together:
* **Adaptive pricing** automatically converts your price into a customer's local currency at checkout, using the live exchange rate.
* **Currency options** let you set your own exact price per currency, overriding the automatic conversion for markets you care about.
## Before you start
* A **sandbox API key** (`sk_sandbox_...`). See [Authentication](/authentication).
* Know your product's **primary currency**. It can be any supported currency, and it's the default customers are charged in, and the one you can't add as a currency option. See [Charge in any currency](/guides/checkout/any-currency-checkout) for the full list and how settlement works.
## How pricing resolves at checkout
This applies to a product whose primary currency is `USD`. A product priced in any other currency is charged in that currency, and the rest of this section does not apply to it. See [Charge in any currency](/guides/checkout/any-currency-checkout#how-the-customers-currency-is-chosen).
For a `USD` product, Bachs detects the customer's location at checkout and picks a price in this order:
1. The **currency option** you set for their currency, if you configured one.
2. The **adaptive** auto-converted price in their local currency, if adaptive pricing is on.
3. Your **primary** price, as a fallback.
Currency options are always accepted on the product, but they only take effect when **adaptive pricing is enabled** in your dashboard. With adaptive pricing off, everyone pays the primary currency.
## Steps
Add `currency_options` to the product's price. Each entry sets an exact amount for one additional currency, so you control the price in that market instead of relying on the live rate.
```bash Create a product with local prices theme={"dark"}
curl https://sandbox-api.bachs.io/v1/products \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Pro plan",
"price": {
"price_type": "fixed",
"amount": "10.00",
"currency": "USD",
"currency_options": [
{ "currency": "GHS", "amount": "150.00" },
{ "currency": "KES", "amount": "1300.00" }
]
}
}'
```
```json Response (price) theme={"dark"}
{
"price_type": "fixed",
"amount": "10.00",
"currency": "USD",
"currency_options": [
{ "currency": "GHS", "amount": "150.00" },
{ "currency": "KES", "amount": "1300.00" }
]
}
```
A customer in Ghana pays exactly GHS 150.00; one in Kenya pays KES 1,300.00. Everyone else falls back to adaptive conversion or the USD primary price.
Currency options cannot include the product's primary currency, and each currency can appear only once. Amounts are decimal strings, like all Bachs money.
Currency options only take effect once adaptive pricing is enabled. Turn it on in your dashboard under checkout settings. With it on, customers in currencies you didn't set explicitly get an automatic conversion; with it off, everyone pays your primary currency.
Send the customer to a [checkout](/guides/checkout/checkout-sessions). Bachs resolves the price for their location using the order above. You can pin a currency for testing with `billing_currency`:
```bash theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"product_cart": [{ "product_id": "prod_abc123", "quantity": 1 }],
"customer": { "email": "kwame@example.com" },
"billing_currency": "GHS"
}'
```
However the customer pays, Bachs settles to your account in `USD` or `NGN`. If they paid in a local currency without a currency option, Bachs handles the conversion and deposits the equivalent.
A USD product that resolves to an NGN currency option is treated as NGN-initiated for settlement. NGN-initiated checkout payments settle to your NGN balance by default unless you choose USD settlement in withdrawal settings. See [NGN checkout settlement](/guides/checkout/any-currency-checkout#ngn-checkout-settlement).
## Supported currencies
You can set currency options in any of these:
| Code | Currency | Code | Currency |
| - | - | - | - |
| USD | United States Dollar | TZS | Tanzanian Shilling |
| NGN | Nigerian Naira | UGX | Ugandan Shilling |
| GHS | Ghanaian Cedi | XAF | Central African CFA |
| KES | Kenyan Shilling | XOF | West African CFA |
| MWK | Malawian Kwacha | ZMW | Zambian Kwacha |
| RWF | Rwandan Franc | | |
Your primary currency can be any of these too. It cannot also be a currency option on the same product.
## Custom and free products
Currency options carry the same pricing shape as the base price:
* **Fixed** products: set `amount` per currency (as above).
* **Custom** (pay-what-you-want) products: set `preset_amount`, `minimum_amount`, and `maximum_amount` per currency instead of `amount`.
* **Free** products: no amounts needed.
## Update the currencies later
Update a product's price to change its `currency_options`. Send the complete set you want to keep; the list is replaced, so omitting a currency removes it.
## Next steps
* [Products](/guides/products/overview): pricing, currencies, and recurring products.
* [The product object](/api-reference/products/object): the full `currency_options` field reference.
* [Accept a payment](/guides/checkout/checkout-sessions): send the customer to checkout.
# Products
Source: https://docs.bachs.io/guides/products/overview
Define your billing catalog with fixed, free, or custom pricing, multi-currency.
A **product** in Bachs is a billable item your customers can purchase. You define the name, pricing, and currencies once; Bachs handles the rest at checkout.
***
## Create a product
Products are created from your dashboard or via the API. An API creation request needs `name` and a `price` object with `currency`. If you omit `price_type`, Bachs uses `fixed`; fixed pricing also needs `amount` as a decimal string. Choose `free` or `custom` explicitly for those modes. An omitted or null fixed-price `amount` is invalid.
For example, this creates a fixed-price product using the default `price_type`:
```json theme={"dark"}
{
"name": "Starter plan",
"price": { "currency": "USD", "amount": "12.00" }
}
```
### Name
The title of your product. This is shown to customers at checkout and appears across your dashboard and reports.
### Pricing
A product's pricing is set by its `price_type`. Bachs supports three modes:
* **`fixed`** (the default): Provide `amount` as a decimal string; every customer pays that exact amount. You may omit `price_type` when creating this kind of product.
* **`free`**: No charge. Omit `amount` or send `null`.
* **`custom`**: The customer decides how much to pay, commonly called pay-what-you-want. Set `preset_amount` for a suggested price shown prefilled at checkout, and `minimum_amount` and/or `maximum_amount` to bound what the customer can enter. A `preset_amount` set alongside bounds must fall within them. Set `minimum_amount` to `"0.00"` to allow free.
`preset_amount`, `minimum_amount`, and `maximum_amount` are for `custom` pricing. A non-null `amount` is for `fixed` pricing; omit it or send `null` for `free` and `custom`.
### Amount
Amounts are **decimal strings** in the currency: `"29.00"` for \$29.00, `"75000.00"` for ₦75,000.00. Always send a string, never a number, and always two decimal places.
This applies to `amount`, `minimum_amount`, `maximum_amount`, and any `currency_options` amounts.
### Currency
Every product has a primary currency, which is the default currency your customers are charged in. It can be any supported currency, including one your account does not hold a balance in. See [Charge in any currency](/guides/checkout/any-currency-checkout) for the full list.
The customer's payment currency, your Bachs balance currency and your payout destination currency are separate. Balances can be held in USD or NGN; you can pay out to your own accounts in the [supported payout currencies](/for-you/supported-currencies#withdrawals). International bank and mobile money payouts use your USD balance, with a quote when the destination currency differs. See [Supported currencies](/guides/payouts/global-payouts).
### Local-currency pricing
By default customers pay in your product's primary currency. You can also set exact prices per currency with `currency_options`, or, on a `USD`-priced product, let adaptive pricing auto-convert at checkout so customers pay in their own local currency.
See [Sell in local currencies](/guides/products/local-pricing) for the full setup and the list of supported currencies.
***
## Recurring pricing
Give a product a `billing_cycle` to make it **recurring**. A recurring product is billed on a schedule through a [subscription](/guides/subscriptions/overview); a product with no `billing_cycle` is a one-time purchase.
```json theme={"dark"}
{
"name": "Pro plan",
"price": { "price_type": "fixed", "amount": "10.00", "currency": "USD" },
"billing_cycle": { "interval": "month", "frequency": 1 },
"trial_period": { "interval": "day", "frequency": 14 }
}
```
* **`billing_cycle`**: the cadence. Product creation requires both `interval` and `frequency`; the checkout response uses `recurring.interval_count` for its interval count. `interval` is `day`, `week`, `month`, or `year`; `frequency` is the number of intervals per cycle (`{ "interval": "month", "frequency": 3 }` = every three months).
* **`trial_period`** *(optional)*, a free [trial](/guides/subscriptions/trials) before the first charge, expressed as the same cadence shape. Only meaningful on a recurring product.
A product's `billing_cycle` is **fixed once set**. You can add a cadence to a one-time product, but you can't change an existing one; create a new product for a different cadence. This protects existing subscribers from surprise changes.
Completing a [checkout](/guides/checkout/checkout-sessions#recurring-products) for a recurring product creates the subscription and bills the first cycle. Subscriptions are **USD card** only today.
***
## Update a product
You can update a product's name, description, and price at any time.
***
## Archive a product
Products on Bachs cannot be deleted. Instead you can archive a product to stop it from being available for new purchases.
Archiving does not affect existing customers. They keep their access. You can unarchive a product at any time from the dashboard or via the API.
# Issue a refund
Source: https://docs.bachs.io/guides/refunds
Refund a payment in full or in part, see which payments can be refunded, and know when the money reaches the customer.
In this guide you'll issue a refund against a payment you already collected, then track it to completion. You can refund the full amount or part of it.
A refund returns money to the customer. You request it in the currency your payment settled in, and the customer is paid back in the currency they paid. Refunds are asynchronous: you create one, then a `refund.*` webhook tells you the outcome.
## Before you start
* A **sandbox API key** (`sk_sandbox_...`) with the `refunds:write` scope. See [Permissions](/api-reference/permissions).
* A **payment to refund**. Its `charge_id` comes from the [payment](/api-reference/payments/object) or the `collection.succeeded` webhook.
* A **webhook endpoint** to receive the result. See [Set up webhooks](/guides/webhooks/overview).
## What can be refunded
Check `is_refundable` on the payment. When it is `false`, the refund is refused, so there is no point in trying. When it is `true`, the refund is accepted in almost every case, with one exception noted below.
A payment can be refunded when all of this is true:
* Its status is `succeeded`, `accepted`, or `underpaid`. A payment that is still `created` or `processing`, or that already `failed`, cannot be refunded.
* Its payment method supports refunds. See the table below.
* It does not already carry a refund. A payment takes **one** refund. Once you refund part of a payment, you cannot come back later for the rest.
A refund that **failed** does not count. Nothing moved, so the payment becomes refundable again and you can create a new refund for it.
### Supported refund currencies
| Payment method | Currencies | Can be refunded |
| - | - | - |
| Card | USD, NGN | Yes |
| Bank transfer | NGN | No |
| Mobile money | GHS, KES, TZS, UGX, XAF, XOF, RWF, MWK, ZMW | Yes |
| Crypto | USDT, USDC, ETH, SOL, BNB, CELO on their supported networks | Yes, with a `refund_address` |
## Steps
Call `POST /v1/refunds` with the payment you're refunding and a unique `reference`. Omit `amount` for a full refund, or pass a decimal string for a partial one.
```bash Full refund theme={"dark"}
curl https://sandbox-api.bachs.io/v1/refunds \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"charge_id": "ch_1a2b3c4d5e6f",
"reference": "refund_9876",
"reason": "Customer requested cancellation"
}'
```
```json Response theme={"dark"}
{
"refund_id": "rfnd_4b9c2e7a1d35a0f81c62",
"charge_id": "ch_1a2b3c4d5e6f",
"reference": "refund_9876",
"status": "processing",
"requested_amount": "29.00",
"refunded_amount": null,
"refund_fee_amount": "0.00",
"fee_bearer": "org",
"reason": "Customer requested cancellation",
"created_at": "2026-04-27T12:00:00Z",
"updated_at": "2026-04-27T12:00:00Z",
"completed_at": null
}
```
The refund starts in `processing`, and your balance is reserved for it straight away. When a refund succeeds, the payment becomes `refunded` for a full refund or `partially_refunded` for a partial refund, and `is_refundable` becomes `false`. When we refund a payment automatically, it becomes `auto_refunded` instead, so you can tell the two apart.
For a **partial refund**, add an `amount` in the payment's settlement currency:
```bash Partial refund theme={"dark"}
curl https://sandbox-api.bachs.io/v1/refunds \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "charge_id": "ch_1a2b3c4d5e6f", "reference": "refund_9877", "amount": "10.00" }'
```
Pass a unique `reference` per refund (max 128 characters). Reusing one returns a duplicate error. Add an `idempotency_key` (max 255 characters) to make retries safe: the same key on the same payment returns the refund you already created, for 24 hours.
Refunds finish asynchronously. Listen for the `refund.*` events to know the outcome.
```json refund.paid theme={"dark"}
{
"id": "evt_5c4b3a2f1e",
"type": "refund.paid",
"created_at": "2026-04-27T12:03:00Z",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"refund_id": "rfnd_4b9c2e7a1d35a0f81c62",
"charge_id": "ch_1a2b3c4d5e6f",
"reference": "refund_9876",
"status": "success",
"requested_amount": "29.00",
"refunded_amount": "29.00",
"refund_fee_amount": "0.00",
"fee_bearer": "org",
"reason": "Customer requested cancellation"
}
}
```
You get `refund.created` when the refund is accepted, `refund.paid` when it goes through, and `refund.failed` if it does not.
| Event | `status` in the payload | What it means |
| - | - | - |
| `refund.created` | `processing` | Accepted. Your balance is reserved and the outcome is not known yet. |
| `refund.paid` | `success` | The money has left us on its way to the customer. Final. |
| `refund.failed` | `failed` | Nothing moved. Your reserved balance is released and you can try again. Final. |
Retrieve a refund by ID to see where it is, or list refunds for reconciliation.
```bash Retrieve theme={"dark"}
curl https://sandbox-api.bachs.io/v1/refunds/rfnd_4b9c2e7a1d35a0f81c62 \
-H "Authorization: Bearer $BACHS_API_KEY"
```
```bash List theme={"dark"}
curl https://sandbox-api.bachs.io/v1/refunds \
-H "Authorization: Bearer $BACHS_API_KEY"
```
If a create call fails with a network error (500, 502, 503, 504), do not assume the refund was not created. Retrieve it with [the by-charge endpoint](/api-reference/refunds/object) before you try again, or a blind retry answers `409 CONFLICT`.
## How long a refund takes
A refund passes through two stages, and only the first one is ours.
1. **With us.** We reserve your balance and send the refund to the route that collected the payment. This is where `refund.created` reaches you.
2. **With the customer's bank, wallet, or card issuer.** We report `refund.paid` when the money has left us. How long it then takes to appear in the customer's account is set by their bank, not by us.
Tell customers the second stage exists. A `refund.paid` webhook is not the same as money the customer can see.
| Payment method | When the customer sees the money |
| - | - |
| Card (USD and NGN) | 5 to 7 working days |
| Bank transfer (NGN) | 1 to 7 working days |
| Mobile money | 1 to 7 working days |
| Crypto | As soon as the network confirms the transaction |
These windows start from `refund.paid`, not from the moment you created the refund. They are what the banks and wallets take, so quote them as a range and not as a promise.
## What a refund costs you
* **You request the amount in the settlement currency**, the currency the payment paid you in. The customer gets back the currency they paid, converted at the rate the original payment settled at.
* **If the payment converted currencies, you fund the conversion at today's rate.** Buying back the customer's currency can cost more or less than you were paid, and the difference is yours. If we sold you a guaranteed rate on the payment, that rate stands and the movement is ours.
* **Fees already charged are not returned.** Our processing fee on the original payment stays charged, and on Connect the platform fee is not reversed either. See [Refunds on Connect charges](/connect/refunds).
* **`fee_bearer`** decides who absorbs any fee the refund itself carries: `org` (you) or `customer` (taken out of what the customer receives). If you omit it, your account's fee handling decides. `refund_fee_amount` is `"0"` when no refund fee applies.
## Crypto refunds
For a payment made in crypto, pass `refund_address`, the wallet the money goes back to, on the same network as the original payment. Without it the refund is refused.
```bash theme={"dark"}
{ "charge_id": "ch_...", "reference": "refund_9878", "refund_address": "0xabc...def" }
```
A crypto refund cannot be recalled. Check the address with the customer before you create it.
## When a refund is refused
| Status and `error_code` | Message | What happened | What to do |
| - | - | - | - |
| `400` `BAD_REQUEST` | Refunds are not supported for this charge | The route that collected the payment cannot return money. | Send a [payout](/guides/payouts/overview) instead. |
| `400` `BAD_REQUEST` | Charge is not refundable in status `...` | The payment never completed, or it is already refunded. | Read the payment's status and `is_refundable`. |
| `400` `BAD_REQUEST` | Insufficient balance to refund | Your balance cannot fund the refund. Nothing was written and nothing moved. | Fund the balance, then create the refund again. |
| `400` `BAD_REQUEST` | Refund amount `...` exceeds refundable balance | The `amount` is more than the payment can return. | Ask for the remaining amount or less. |
| `400` `BAD_REQUEST` | Refunds are not supported at the moment | Refunds are turned off across the platform. | Nothing on your side. Contact support. |
| `409` `CONFLICT` | A refund already exists for this charge | The payment already carries a refund that did not fail. | Retrieve it instead of creating another. |
| `409` `CONFLICT` | Refund reference already exists | You reused a `reference`. | Send a new one. |
## From the dashboard
You can refund without writing code. Open **Transactions**, click the payment, and choose **Issue a refund**. The button is offered on any payment with `is_refundable: true`, with the same one exception as the API. It appears in the same list and fires the same webhooks.
## Testing
In the sandbox, force an outcome with `simulated_outcome`:
```bash theme={"dark"}
{ "charge_id": "ch_...", "reference": "refund_test", "simulated_outcome": "success" }
```
Values are `success` and `failed`. Sandbox refunds move no real money.
## Next steps
* [The payment object](/api-reference/payments/object): find a payment's `charge_id` and `is_refundable`.
* [The refund object](/api-reference/refunds/object): every field on a refund.
* [Refunds on Connect charges](/connect/refunds): which balance a refund debits on a split payment.
* [Set up webhooks](/guides/webhooks/overview): receive `refund.*` events.
# Payment recovery
Source: https://docs.bachs.io/guides/subscriptions/failed-payments
How Bachs retries failed renewal charges, emails the customer a link to update their card, and what happens when retries run out.
When a renewal charge fails, Bachs doesn't cancel the subscription straight away. It moves to `past_due` and enters an automated **payment recovery** (dunning) flow: a fixed schedule of retries, plus an email to the customer with a hosted link to update their card. Most failures are transient (an expired card, a temporary decline), so recovery gives them a chance to fix it before anything is lost. The same card-update flow is reachable from the [customer portal](/guides/customer-portal/overview), so a customer who never opens the email can still fix it themselves.
***
## The retry schedule
The first failed charge sets the invoice's status so Bachs can retry it on a fixed cadence:
* **Retry 1**: 1 day after the first failure
* **Retry 2**: 3 days after retry 1
* **Retry 3**: 5 days after retry 2
That's three retries over roughly nine days. While retries are pending, the subscription stays `past_due` and each failed attempt emits `invoice.payment_failed` and re-sends the customer email. A success at any point marks the invoice paid, reactivates the subscription (`past_due → active`), and emits `invoice.paid`.
If the third retry fails, recovery is **exhausted**.
***
## When recovery is exhausted
What happens when retries run out is a per-account setting, so you can match your business.
The subscription moves to `canceled` with a cancellation reason of `payment_failed`, and Bachs emits `customer.subscription.deleted`. This is terminal: a later payment can't revive it, and the customer starts a new subscription.
The subscription moves to `unpaid` and stays there, emitting `customer.subscription.updated`. It's recoverable: if the customer later pays (for example by updating their card), the subscription reactivates. Use this if you'd rather keep trying to win the customer back than end the relationship.
You set this under your subscription settings; the default is **Cancel**.
***
## Helping customers recover
### The failed-payment email
On every failed charge, Bachs emails the customer to tell them the payment failed. While the subscription is still recoverable (`past_due` or `unpaid`), that email includes a button linking to a **hosted "update payment method" page**. The copy differs depending on whether retries are still pending or recovery has been exhausted.
The email is best-effort and never blocks billing; if it can't be sent, retries still run on schedule.
### The recovery link
The hosted page lets the customer enter a new card without you building any UI. Bachs mints the link on demand when the failed-payment email is sent:
* It's only created while the subscription is `past_due` or `unpaid`.
* The link is valid for 30 days.
* When the customer completes it, Bachs saves the new card, charges the outstanding invoice, and on success reactivates the subscription (`past_due`/`unpaid` → `active`).
## Statuses at a glance
| Status | When | Recoverable? |
| - | - | - |
| `past_due` | A renewal charge failed; retries are running | Yes. A retry or card update reactivates it |
| `unpaid` | Retries exhausted, account set to *mark unpaid* | Yes. A later payment reactivates it |
| `canceled` | Retries exhausted, account set to *cancel* | No. Terminal |
***
## Related
Change the plan, or cancel from the API.
The `invoice.*` and `customer.subscription.*` events recovery emits.
# Managing subscriptions
Source: https://docs.bachs.io/guides/subscriptions/manage
Change the plan, move a trial, or cancel, using the API or your dashboard.
Once a subscription exists you can change it over its lifetime. Every change is available through the [Update Subscription](/api-reference/subscriptions/update-subscription) endpoint (and cancellation through [Cancel Subscription](/api-reference/subscriptions/cancel-subscription)), or from your dashboard.
**One change per request.** Each update carries exactly one intent: change the plan, *or* the payment method, *or* the trial, *or* the metadata. Combining them in a single request returns `400`.
Fetch the current state first with [Get Subscription](/api-reference/subscriptions/get-subscription); every update returns the full, updated subscription object.
***
## Change the plan
Move a subscriber to a different plan by sending the target `product_id`. Bachs resolves the price from that product for the subscription's currency, so you name the plan and the plan owns the price. Include a [`proration_behavior`](/guides/subscriptions/proration) to say how the price difference is settled.
```bash theme={"dark"}
PATCH /v1/subscriptions/sub_1a2b3c4d5e6f
```
```json theme={"dark"}
{
"product_id": "prod_premium",
"proration_behavior": "invoice_now"
}
```
* `invoice_now`: apply now and settle the difference immediately (upgrade charged, downgrade credited).
* `next_cycle`: apply now, roll the difference into the next renewal.
* `none`: change the terms with no proration.
The target product must bill at the same interval as the subscription and have a price in its currency, otherwise the request returns `400`. A downgrade's credit goes to the customer's [credit balance](/guides/subscriptions/proration#customer-credit) and is applied to future invoices automatically. See [Proration](/guides/subscriptions/proration) for the full model.
Per-seat and quantity-based pricing are coming soon. Today a plan is a fixed-price product.
***
## Manage the trial
Add, extend, or end a trial by setting `trial_end`. A future timestamp adds or extends the trial (nothing is charged); a past-or-now timestamp ends it and bills the first cycle immediately.
```json theme={"dark"}
{ "trial_end": "2026-08-10T12:00:00Z" }
```
Full details, including ending a trial early, are on the [Trials](/guides/subscriptions/trials#adding-extending-or-ending-a-trial) page.
***
## Update the metadata
Merge into the subscription's key-value `metadata` (up to 20 keys). Sent keys are added or overwritten and any keys you don't mention are left untouched. To remove one key, send it with an empty-string value; to clear everything, send `""`.
```json theme={"dark"}
{ "metadata": { "plan_tier": "pro", "crm_id": "cust_8842" } }
```
```json theme={"dark"}
{ "metadata": { "crm_id": "" } } // removes crm_id, keeps the rest
```
Metadata is first attached at the [checkout session](/guides/checkout/checkout-sessions) that starts the subscription and copied onto it on success; this endpoint lets you change it afterward. The system-managed `checkout_id` key is preserved and can't be overwritten.
***
## Cancel a subscription
Cancel with the [Cancel Subscription](/api-reference/subscriptions/cancel-subscription) endpoint. You choose *when* the cancellation takes effect with `cancel_at_period_end`.
```bash theme={"dark"}
DELETE /v1/subscriptions/sub_1a2b3c4d5e6f
```
The subscription keeps working until `current_period_end` (the customer paid for that period), then transitions to `canceled`. Bachs sets `cancel_at_period_end: true` and emits `customer.subscription.updated`.
```json theme={"dark"}
{ "cancel_at_period_end": true, "reason": "Customer requested" }
```
This is the gentler option: no partial refund, no interruption, access continues to the date they already paid for.
The subscription moves to `canceled` right now. Bachs sets `canceled_at`, clears the next billing date, and emits `customer.subscription.deleted`. This is terminal.
```json theme={"dark"}
{ "cancel_at_period_end": false, "reason": "Fraud" }
```
There's no automatic refund; if you owe one, issue it separately.
`reason` is optional (max 255 characters) and is stored on the subscription. Canceling an already-canceled subscription returns `400`.
### What makes a subscription `canceled`
A subscription reaches the terminal `canceled` status in exactly three ways:
1. **Immediate cancel**: a `DELETE` with `cancel_at_period_end: false`.
2. **Scheduled cancel reaching the period end**: after a `DELETE` with `cancel_at_period_end: true`, when `current_period_end` passes.
3. **Payment recovery exhausted**: a subscription in [recovery](/guides/subscriptions/failed-payments#when-recovery-is-exhausted) runs out of retries and your account is set to *cancel* (the default). The cancellation reason is `payment_failed`.
Once `canceled`, a subscription is terminal; a later payment can't revive it. The customer must start a new subscription.
***
## What customers can do themselves
When a payment fails, the customer receives an email with a hosted link to update their card. Completing it charges the outstanding invoice and reactivates the subscription, with no action needed from you. See [Payment recovery](/guides/subscriptions/failed-payments#the-recovery-link).
***
## Related
How plan-change price differences are settled.
List, retrieve, update, and cancel subscriptions programmatically.
# Subscriptions
Source: https://docs.bachs.io/guides/subscriptions/overview
Recurring billing on top of your products. A subscription is one customer's ongoing relationship with a recurring product.
A **subscription** is the recurring relationship between a customer and one of your [products](/guides/products/overview). It's created when a customer completes a [checkout](/guides/checkout/checkout-sessions) for a product that has a recurring price, and it keeps generating invoices and charges on every renewal until you or the customer end it.
Subscriptions currently bill **USD cards** only. Recurring billing on other rails and currencies is rolling out; the API and objects below don't change when it does.
***
## How subscriptions work
When a customer completes a checkout for a product that has a `billing_cycle`, Bachs creates a `subscription` and bills the first cycle. There is no "create subscription" call; subscriptions always start from a checkout. See [Creating a subscription](#creating-a-subscription).
At the end of each billing period, Bachs advances the subscription to the next cycle, opens an invoice, and charges the customer's saved card off-session. You don't schedule anything.
If a renewal charge fails, the subscription moves to `past_due` and Bachs runs an automated [payment recovery](/guides/subscriptions/failed-payments) flow of retries plus a customer email with a hosted link to update their card.
Every state change emits a webhook: `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, and the `invoice.*` events. Grant and revoke access off these, not off the redirect. See [Subscription webhook events](/guides/webhooks/overview).
***
## Recurring pricing
A subscription is driven entirely by how you price the underlying product. Recurring pricing is set when you [create a product](/guides/products/overview#recurring-pricing):
* **Billing interval**: `day`, `week`, `month`, or `year`.
* **Interval count** (`frequency`), the number of intervals per cycle. `{ "interval": "month", "frequency": 3 }` bills every three months.
* **Currency**: a subscription keeps the currency it was created in at checkout and bills every renewal in that currency. Today that is USD. NGN subscriptions are available only on accounts where they have been enabled.
A product with a `billing_cycle` is a recurring product; a product without one is one-time.
The billing interval is **fixed once a product is created**. To offer a different cadence (say, a yearly plan next to a monthly one), create a separate product and show both at checkout.
***
## Statuses
A subscription is always in exactly one status.
| Status | Meaning |
| - | - |
| `trialing` | In a free [trial](/guides/subscriptions/trials). No charge has been taken yet; the first charge happens when the trial ends. |
| `active` | Billing normally. The card is charged at the end of each cycle. |
| `past_due` | A renewal charge failed and Bachs is [retrying](/guides/subscriptions/failed-payments). The customer has been emailed a link to update their card. |
| `unpaid` | Recovery was exhausted and your account is set to keep unpaid subscriptions. A later successful payment reactivates it. |
| `canceled` | Terminal. Either you/the customer canceled, or recovery was exhausted and your account is set to cancel. No further charges. |
Transitions you'll observe: `trialing → active` (trial ends), `active → past_due` (charge fails), `past_due → active` (a retry or card update succeeds), `past_due → unpaid | canceled` (recovery exhausted, [configurable](/guides/subscriptions/failed-payments#when-recovery-is-exhausted)), `active → canceled` (cancellation).
`canceled` is terminal; a later payment can't revive it. `unpaid` and `past_due` are both recoverable.
***
## Creating a subscription
Subscriptions are created **through checkout**; there is no `POST /v1/subscriptions`. Any checkout session for a recurring product becomes a subscription checkout automatically.
```bash theme={"dark"}
POST /v1/checkout-sessions
```
```json theme={"dark"}
{
"product_cart": [{ "product_id": "prod_abc123", "quantity": 1 }],
"customer": { "email": "customer@example.com" },
"billing_currency": "USD",
"success_url": "https://example.com/thanks"
}
```
If `prod_abc123` has a `billing_cycle`, completing this checkout creates the subscription and bills cycle one. Bachs collects the card during checkout and reuses it for every renewal. See [Checkout Sessions](/guides/checkout/checkout-sessions#recurring-products).
You can click through this flow right now: the Pro and Team plans on the [live demo](https://snapkit.bachs.io) start real sandbox subscriptions through the overlay checkout.
Change the plan, move a trial, or cancel, using the API.
***
## The subscription object
```json theme={"dark"}
{
"id": "sub_1a2b3c4d5e6f",
"customer": {
"customer_id": "cust_xyz789",
"email": "customer@example.com",
"name": "Jane Doe"
},
"payment_method_id": "pm_7h8i9j0k",
"status": "active",
"collection_method": "charge_automatically",
"currency": "USD",
"amount": "10.00",
"billing_cycle": { "interval": "month", "frequency": 1 },
"quantity": 1,
"current_period_start": "2026-07-13T12:00:00Z",
"current_period_end": "2026-08-13T12:00:00Z",
"previously_billed_at": "2026-07-13T12:00:00Z",
"next_billed_at": "2026-08-13T12:00:00Z",
"trial_end": null,
"cancel_at_period_end": false,
"canceled_at": null,
"created_at": "2026-07-13T12:00:00Z",
"product": { "id": "prod_abc123", "name": "Pro plan", "billing_cycle": { "interval": "month", "frequency": 1 } },
"items": [
{
"id": "il_11aa22bb",
"status": "active",
"quantity": 1,
"recurring": true,
"price_type": "fixed",
"unit_amount": "10.00",
"currency": "USD",
"previously_billed_at": "2026-07-13T12:00:00Z",
"next_billed_at": "2026-08-13T12:00:00Z"
}
],
"metadata": { "plan": "pro" }
}
```
* **Money is a string** at the currency's precision (`"10.00"`), always paired with `currency`.
* **Dates are ISO-8601 UTC.**
* `previously_billed_at` is the start of the period that was last billed; `next_billed_at` is the next scheduled charge.
* IDs are prefixed: `sub_` (subscription), `inv_` (invoice), `il_` (line item), `pm_` (payment method), `cust_` (customer), `prod_`/`price_` (catalog).
***
## Next steps
Change plans, move a trial, or cancel, using the API.
How the price difference is settled when a subscription changes mid-cycle.
How Bachs retries failed renewals and what happens when retries run out.
Offer a free period before the first charge. In beta.
A hosted page where customers cancel, switch plans, and update their card themselves.
***
## FAQ
A product is the thing you sell: name, pricing, currency. A subscription is one specific customer's ongoing relationship with that product. Bachs treats one-time and recurring purchases as the same kind of object (both are products); the only difference is whether the price has a `billing_cycle`.
The first charge happens at checkout, or when the [trial](/guides/subscriptions/trials) ends if one is configured. After that, the card is charged at the end of every billing cycle. If a renewal charge fails, the subscription moves to `past_due` and enters [payment recovery](/guides/subscriptions/failed-payments).
A subscription locks in the amount it was created with, so existing subscribers aren't surprised by catalog changes. Editing a product's price only affects **new** subscriptions. To move an existing subscriber to different pricing, [change the plan](/guides/subscriptions/manage#change-the-plan) on their subscription.
Every change emits a [webhook](/guides/webhooks/overview). Treat webhooks as the source of truth for granting and revoking access; redirects can be lost if the customer closes the tab.
# Proration
Source: https://docs.bachs.io/guides/subscriptions/proration
How Bachs settles the price difference when a subscription changes mid-cycle.
When a subscription's price changes partway through a billing period (because the customer moved to a different plan), there's an unused portion of the current period they've already paid for. **Proration** reconciles that difference. Bachs prorates by the exact time remaining in the period, so the customer is only charged or credited for what they actually use.
You choose how the difference is settled with `proration_behavior` on the [Update Subscription](/api-reference/subscriptions/update-subscription) endpoint.
***
## Proration behaviors
| Value | What happens |
| - | - |
| `invoice_now` | The change applies immediately and Bachs settles the difference right away: an upgrade is charged now on a one-off invoice, a downgrade becomes a credit. |
| `next_cycle` | The change applies immediately, but the prorated difference is deferred and rolled into the **next renewal invoice** instead of being charged now. |
| `none` | The terms change with **no proration** at all: no charge, no credit for the remainder of the current period. |
Always send `proration_behavior` explicitly so the outcome is predictable. If you omit it, Bachs settles the change immediately (`invoice_now`).
`reset` (charge the full new price and restart the cycle) is not supported yet; sending it returns `400`.
***
## Upgrades and downgrades
Proration cuts both ways, and Bachs handles each side differently.
**Upgrade (net charge).** Moving to a more expensive plan owes money for the rest of the period. With `invoice_now` this is charged to the card immediately; with `next_cycle` it's added to the next renewal.
**Downgrade (net credit).** Moving to a cheaper plan means the customer overpaid for the rest of the period. Bachs **always** turns that into customer credit, regardless of the `proration_behavior` you chose, rather than refunding to the card.
### Customer credit
A downgrade credit is added to the customer's **credit balance**, a running per-customer, per-currency balance held on your account. It isn't paid back to the card. Instead, Bachs draws it down automatically against the customer's future invoices before charging their card:
* On the next invoice, available credit is applied first.
* If credit fully covers an invoice, that invoice is marked paid and the card is never charged.
* Any leftover credit carries forward to the invoice after that.
This means a customer who downgrades typically sees smaller (or zero) charges on their next few renewals until the credit is used up.
***
## How the amount is calculated
For the portion of the period remaining at the moment of the change:
```text theme={"dark"}
credit for the old plan = old_amount × fraction_of_period_remaining
charge for the new plan = new_amount × fraction_of_period_remaining
net = charge − credit
```
* A positive `net` is a charge (upgrade); a negative `net` is a credit (downgrade).
* `fraction_of_period_remaining` is the exact share of the current billing period still ahead, between `0` and `1`.
* Amounts are rounded to two decimal places.
***
## Related
Change a subscription's plan and pass the proration behavior you want.
What happens when an immediate proration charge fails.
Seat-based (per-unit) pricing and its proration are coming soon. This page covers plan changes; seats will be documented when they ship.
# Offer a free trial
Source: https://docs.bachs.io/guides/subscriptions/trials
Give customers a free period before their first charge. The card is saved at checkout and charged when the trial ends.
In this guide you'll add a free trial to a product, start a trialing subscription, and manage the trial (extend or end it) on an existing subscription. A trial lets a customer use your product before the first charge: their card is saved at checkout, but nothing is billed until the trial ends.
Trials are in beta. The behavior below might change.
## Before you start
* A **sandbox API key** (`sk_sandbox_...`). See [Authentication](/authentication).
* Familiarity with [selling a subscription](/guides/subscriptions/overview): a trial is a recurring product with a trial window.
## Steps
A trial is a property of the **product**: a `trial_period` duration alongside the recurring `billing_cycle`. Set it when you create the product.
```bash Create a product with a 14-day trial theme={"dark"}
curl https://sandbox-api.bachs.io/v1/products \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Pro plan",
"price": { "price_type": "fixed", "amount": "10.00", "currency": "USD" },
"billing_cycle": { "interval": "month", "frequency": 1 },
"trial_period": { "interval": "day", "frequency": 14 }
}'
```
`trial_period` is a duration: `{ "interval": "day", "frequency": 14 }` is 14 days. It's only meaningful on a recurring product (one with a `billing_cycle`); it does nothing on a one-time product.
There's no separate trial call. When a customer completes a [checkout](/guides/checkout/checkout-sessions) for a product that has a `trial_period`, Bachs saves their card **without charging it**, creates the subscription in `trialing`, and schedules the first charge for the end of the trial.
```json Subscription created in trial theme={"dark"}
{
"id": "sub_1a2b3c4d5e6f",
"status": "trialing",
"trial_end": "2026-07-27T12:00:00Z",
"current_period_end": "2026-07-27T12:00:00Z",
"next_billed_at": "2026-07-27T12:00:00Z"
}
```
When the trial ends, Bachs bills the first cycle off-session using the saved card, the subscription moves to `active`, and normal renewals begin. If the customer cancels before the trial ends, they're never charged.
Manage a trial on an existing subscription through a single field, `trial_end`, on [Update a subscription](/api-reference/subscriptions/object). A trial change stands alone; don't combine it with other changes in the same request.
```bash Extend the trial theme={"dark"}
curl -X PATCH https://sandbox-api.bachs.io/v1/subscriptions/sub_1a2b3c4d5e6f \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "trial_end": "2026-08-10T12:00:00Z" }'
```
```bash End the trial now theme={"dark"}
curl -X PATCH https://sandbox-api.bachs.io/v1/subscriptions/sub_1a2b3c4d5e6f \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "trial_end": "2026-07-13T12:00:00Z" }'
```
* A **future** `trial_end` extends (or starts) the trial and postpones billing. This works even on an `active` subscription, which becomes `trialing`.
* A `trial_end` of **now or the past** ends the trial immediately: the subscription becomes `active`, a fresh billing cycle starts, and Bachs charges the first cycle right away.
Ending a trial is only valid while the subscription is `trialing`. Sending a past `trial_end` on a subscription that isn't trialing returns `400`.
## What you'll receive
* **Trial started:** `customer.subscription.created` with `status: "trialing"`.
* **Trial added, extended, or ended:** `customer.subscription.updated`.
* **Trial ends and the first cycle is billed:** `invoice.paid`, or `invoice.payment_failed` if the saved card fails (which starts [payment recovery](/guides/subscriptions/failed-payments)).
Treat these webhooks as the source of truth for granting and revoking access. See [Set up webhooks](/guides/webhooks/overview).
## Next steps
* [Live demo](https://snapkit.bachs.io): buy the 14-day trial on the demo storefront and watch the subscription start as `trialing`.
* [Sell a subscription](/guides/subscriptions/overview): the full recurring flow a trial sits on top of.
* [The subscription object](/api-reference/subscriptions/object): the `trialing` status and `trial_end` field.
* [Payment recovery](/guides/subscriptions/failed-payments): what happens if the first charge fails.
# Transactions
Source: https://docs.bachs.io/guides/transactions
The complete record of every charge attempt on your account: successful, failed, pending, and everything in between.
The Transactions page shows every payment record created under your organization. This includes charges that succeeded, failed, expired before payment, were underpaid, or were later refunded. It's the operational view of your account: not only revenue, but the full picture.
***
## Charge Statuses
Each payment carries a status that reflects its current state in the payment lifecycle. These map directly to the values returned by the API.
| Status | What it means |
| - | - |
| `created` | Charge record exists but no payment action has been taken yet |
| `processing` | Payment received and currently being confirmed |
| `succeeded` | Payment completed and funds settled |
| `accepted` | Payment accepted; an alternative terminal success state for some rails |
| `failed` | Payment attempt was declined or could not be processed |
| `expired` | Checkout was opened but the customer did not complete payment in time |
| `cancelled` | Customer explicitly abandoned or cancelled the checkout |
| `refunded` | Full refund issued against the original charge |
| `partially_refunded` | A portion of the original charge was refunded |
| `auto_refunded` | We automatically refunded the full charge |
| `underpaid` | Customer sent less than the required amount |
| `overpaid` | Customer sent more than the required amount |
`succeeded` and `accepted` are both terminal success states. Settlement has occurred in both cases.
***
## From the Dashboard
Navigate to **Transactions** in your dashboard to see the full payment log for your organization.
**Filter by status.** Narrow the list to a specific charge state. Useful when triaging failed payments or reviewing refund activity without sifting through the full log.
**Filter by date.** Scope results to a specific time period for reconciliation or end-of-month reviews.
**View a charge.** Click any row to open the full payment detail: amount, fees, customer info, payment method, rail used, and a complete status timeline.
**Issue a refund.** Any charge with `is_refundable: true` can be refunded directly from its detail view. You don't need to leave the Transactions page.
Not every completed charge is refundable. Always check `is_refundable` before attempting a refund. Charges paid via certain rails or in specific currencies may not support refunds.
***
## Via API
The full charge log is available through the payments endpoints. Use these when building internal tooling, support dashboards, or automated reconciliation workflows.
Explore the payments API
***
## Reconciliation
When reconciling withdrawals against charges, use `amount_collected` (the settlement-side figure after fees) rather than `amount`, which is the gross charge before deductions.
| Field | What it represents |
| - | - |
| `amount` | What the customer was charged |
| `amount_paid` | What the customer actually sent |
| `amount_remaining` | Outstanding balance (relevant for `underpaid`) |
| `amount_collected` | What was settled to your account after fees |
For charges with partial payment or overpayment scenarios, `amount_paid` and `amount_remaining` give you the granular breakdown.
# Virtual accounts
Source: https://docs.bachs.io/guides/virtual-accounts/overview
Get a permanent NGN bank account number, receive bank transfers into it, and match each deposit to the customer who sent it.
A **virtual account** is a permanent NGN bank account number that belongs to your account, or to a connected account you manage. Anyone can send money to it from any Nigerian bank, at any time, and each deposit becomes a payment in your Bachs balance.
In this guide you'll get your number, share it, test your integration, and match each deposit to the customer who sent it. By the end you'll have a working way to take repeat bank transfers without a checkout.
***
## Is a virtual account right for you?
A virtual account is one number for your whole business. It is the right tool when the same people pay you again and again. It is the wrong tool when you need each payment tied to one order.
| | Virtual account | [Bank-transfer checkout](/guides/checkout/checkout-sessions#restrict-payment-methods) |
| - | - | - |
| Account number | One permanent number per account | A new number for each payment, valid for 30 minutes |
| Amount | Any amount | Fixed to the order |
| Knows which order it pays for | No. You match it yourself | Yes. It carries your `checkout_id` and `reference` |
| Best for | Retainers, rent, school fees, wallet top-ups, regular clients | One-off orders and invoices |
| Fee | 1%, capped at NGN 300 | 1.5%, capped at NGN 2,000 |
You get **one virtual account per account, per currency**. There is no number per customer. If you need a separate number for each seller or client, give each one a [connected account](/connect/overview) and issue the number there. NGN is the only currency available today.
***
## Get your account number
### Before you start
Bachs requests the `virtual_accounts` capability for every Nigerian account automatically. To turn it on, the account needs:
* Its product information
* Identity verification
* The representative's BVN
Once those are in, Bachs reviews the account. This review is not automatic, so allow time for it. We email you when you can create your number.
Virtual accounts are rolling out account by account. If your capability is active but you cannot create a number, your account is not enabled yet. Contact [support](mailto:support@bachs.io) with your account ID.
Go to **Balance** and find your NGN balance card. Click **Get virtual account** on the card. You may also see a **Get your virtual account** banner above your balances.
Click **Request virtual account**. This creates your number straight away; there is no second approval at this point.
You need to be an owner, or have permission to manage payins. If the panel asks for details instead, or says your request is with us, your account isn't approved yet. It tells you what, if anything, it needs from you.
Click **Account details** on your NGN balance card. You'll see the **Account number**, **Bank name** and **Account name**, each with a copy button.
Your API key needs `virtual_accounts:write` to create the number and `virtual_accounts:read` to read it. Existing keys do not get these scopes automatically. Add them through [Edit scopes](/developer-portal/api-keys#editing-scopes).
Listen for [`capability.updated`](/guides/webhooks/events/capability-updated) with `data.capability: "virtual_accounts"` and `data.status: "active"`. If you call `POST` before then, you get `403 FORBIDDEN`.
`POST /v1/virtual-accounts` with the currency. Calling it again returns the same number with `200`, so a retry after a timeout never leaves you with two numbers.
```bash Request theme={"dark"}
curl -X POST https://sandbox-api.bachs.io/v1/virtual-accounts \
-H "Authorization: Bearer sk_sandbox_abc123xyz..." \
-H "Content-Type: application/json" \
-d '{ "currency": "NGN" }'
```
```json Response theme={"dark"}
{
"id": "va_8Hs2kQ4mZpXv",
"currency": "NGN",
"account_number": "9902847361",
"bank_name": "Example Bank",
"bank_code": "000",
"status": "active",
"created_at": "2026-09-22T09:14:02.000Z"
}
```
`GET /v1/virtual-accounts?currency=NGN` returns the same object. It returns `404 NOT_FOUND` if the account has no number in that currency yet.
You create a connected account's number yourself. We don't email the account or create the number for it.
A connected account created without a capability list gets `virtual_accounts` requested automatically if it is in Nigeria. If you name capabilities when you create the account, include `virtual_accounts` under the `merchant` configuration. See [Request a capability](/connect/capabilities#request-a-capability).
Read the account's `requirements.entries[]` and submit what is due, including a representative with their BVN in `id_numbers` with `type: "bvn"`. See [Submitting requirements](/connect/requirements#submitting).
When `capability.updated` reports `virtual_accounts` as `active` for that account, call `POST /v1/virtual-accounts` with your platform's key and `X-Account-Id: acct_3Wq8ZfT1yHnJ5sVe`.
Each connected account is enabled separately during the rollout. A `404 NOT_FOUND` on `POST` means that account isn't enabled yet.
***
## Share the number
Give your customers the **account number**, **bank name** and **account name** together.
* **Account name:** your business name. Some issuing banks add a prefix, so a sender may see something like `Bachs Checkout - Your Business`. Tell regular customers what to expect so they don't think they have the wrong account.
* **Connected accounts:** the sender sees the connected account's business name, not your platform's.
* **No reference needed:** customers don't need a reference to pay. If they add your invoice number in their bank's narration or description field, matching is much easier (see [Match deposits to customers](#match-deposits-to-customers)).
***
## Test your integration in sandbox
A sandbox number looks real but **can never receive money**. It starts with `90` and its bank is **Sandbox Test Bank**. There is no way to simulate a deposit into it today.
In sandbox you can test:
* Creating and reading the number, including the idempotent retry
* Your handler's signature check, by sending a sample event:
```bash theme={"dark"}
bachs trigger collection.succeeded
```
The sample event is shaped like a checkout payment, not a deposit. It has a `checkout_id`, no `payment_method_details`, and a lowercase `status`. Test your deposit-parsing code against the [deposit example below](#receive-a-deposit) instead.
Refunds are not blocked in sandbox, but a live deposit cannot be refunded. Don't build a refund path for deposits on the strength of a sandbox test.
***
## Receive a deposit
When a deposit arrives, you get [`collection.succeeded`](/guides/webhooks/events/collection-succeeded). A deposit is easy to tell apart from a checkout payment because `checkout_id` is `null` and `virtual_account.type` is `permanent`.
```json collection.succeeded for a deposit theme={"dark"}
{
"id": "evt_3ab4e0d5d27445cf8a52ab3d8cb8f0b1",
"type": "collection.succeeded",
"created_at": "2026-09-22T10:41:18.402Z",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"charge_id": "ch_1a2b3c4d5e6f",
"checkout_id": null,
"reference": null,
"status": "SUCCEEDED",
"amount": "250000.00",
"currency": "NGN",
"settlement_amount": "249700.00",
"settlement_currency": "NGN",
"processing_fee": "300.00",
"processing_fee_currency": "NGN",
"fee_bearer": "merchant",
"product_cart": null,
"customer": { "id": null },
"payment_method_details": {
"type": "bank_transfer",
"bank_transfer": {
"sender_name": "JANE ADEYEMI",
"sender_bank": "Guaranty Trust Bank",
"sender_bank_code": "058",
"sender_account_number": "2294879124",
"session_id": "000013260922104115000821734502",
"narration": "INV-1042",
"virtual_account": {
"id": "va_8Hs2kQ4mZpXv",
"account_number": "9902847361",
"bank_name": "Example Bank",
"type": "permanent",
"expires_at": null
}
}
},
"metadata": {}
}
}
```
* **Fields can be `null`.** Every sender field is always present, but the sending bank can leave any of them out. `narration` in particular is often blank. Code for `null` on each one.
* **Deduplicate on the event `id`.** A redelivered event has the same `id`.
* **The money is available immediately.** NGN deposits move to your available balance as soon as they are confirmed, unless the deposit is over your limit (see [Large deposits are held for review](#what-can-go-wrong)).
* **The fee comes off first.** Each deposit costs 1% of the amount, capped at NGN 300, so a NGN 250,000 deposit settles NGN 249,700. See [Fees](/for-you/fees).
***
## Match deposits to customers
A deposit tells you who sent money and how much. It does not tell you what the money is for. Matching is your job, so decide your rules before you go live.
A reliable order to try:
1. **A known payer.** Save each customer's `sender_account_number` the first time you confirm one of their payments. Later deposits from that account are almost certainly theirs.
2. **An invoice number in the narration.** Ask customers to put your invoice number in their transfer description. Look for it in `narration`.
3. **Amount and timing.** An exact amount from a customer with one open invoice is a strong hint, but treat it as a suggestion.
4. **Everything else goes to a review queue.** Don't guess. Show unmatched deposits to someone who can check them.
```js Match a deposit in your collection.succeeded handler theme={"dark"}
// Run after you have verified the signature and checked the event id is new.
async function matchDeposit(event) {
const payment = event.data;
if (payment.checkout_id !== null) return; // a checkout payment, handled elsewhere
const transfer = payment.payment_method_details?.bank_transfer ?? {};
const payer = transfer.sender_account_number
? await db.payers.findByAccountNumber(transfer.sender_account_number)
: null;
const invoice =
(await db.invoices.findByNumberIn(transfer.narration)) ??
(payer ? await db.invoices.onlyOpenInvoice(payer.id, payment.amount) : null);
if (invoice) {
await db.invoices.markPaid(invoice.id, { chargeId: payment.charge_id });
} else {
await db.unmatchedDeposits.add({
chargeId: payment.charge_id,
amount: payment.amount,
senderName: transfer.sender_name ?? null,
narration: transfer.narration ?? null,
});
}
}
```
Amounts are decimal strings. Compare them as strings, or with a decimal library, never as floats.
***
## Catch up on missed deposits
If your endpoint was down, you can find the deposits you missed in two ways.
**Replay the events.** Redeliver a deposit's `collection.succeeded` with `POST /v1/webhooks/replay` and its `charge_id`, or by event ID with `bachs events replay`. See [Replay events](/guides/webhooks/replay-events).
**List your payments.** `GET /v1/payments` has no filter for deposits, so filter the results yourself. A deposit has:
* `payment_method.type: "ngn_bank_transfer"`
* no `source.checkout_id`
The list does not include sender details. Call `GET /v1/payments/{payment_id}` for each deposit to get `payment_method_details`.
```bash List recent payments theme={"dark"}
curl "https://sandbox-api.bachs.io/v1/payments?limit=100" \
-H "Authorization: Bearer sk_sandbox_abc123xyz..."
```
***
## What can go wrong
Each account has a limit on a single deposit and on a day's deposits added together. By default both are NGN 5,000,000, with the day counted in UTC. Your account may have its own limits.
A deposit over either limit is **not refused**, because a bank transfer cannot be stopped. It is held for a manual review before it reaches your balance:
* You still receive `collection.succeeded` straight away, with nothing in it to say the deposit is held.
* The amount appears in neither your pending nor your available balance until we release it.
* No event is sent on release.
If a large deposit has not reached your balance, contact [support](mailto:support@bachs.io) with the `charge_id`. To raise your limits ahead of time, see [Deposit limits](/guides/payments/deposit-limits#virtual-account-deposits).
The sending bank decides what it passes on. Any of `sender_name`, `sender_bank`, `sender_bank_code`, `sender_account_number` and `narration` can be `null`. When they are, the deposit goes to your review queue (see [Match deposits to customers](#match-deposits-to-customers)). The `session_id` is the interbank reference your customer's bank can use to trace the transfer.
A deposit cannot be refunded through the API. The payment has `is_refundable: false`, and a refund request returns `400`. Contact [support](mailto:support@bachs.io) with the `charge_id` to return it.
If the issuing bank refuses to create the number, `POST` returns `400` with `VIRTUAL_ACCOUNT_REFUSED` and the bank's reason. Fix what the reason names, then try again. If the reason isn't clear, contact [support](mailto:support@bachs.io). See [Virtual account errors](/api-reference/error-reference#virtual-accounts) for every code.
A number can be marked `inactive`. It then no longer appears when you read it, but money sent to it is still received and credited. If you see this, contact [support](mailto:support@bachs.io) before you give out a different number.
***
## Go-live checklist
* [ ] Your live API key has `virtual_accounts:read` and `virtual_accounts:write`.
* [ ] Your webhook endpoint verifies signatures and deduplicates on the event `id`.
* [ ] Your handler copes with `null` in every sender field.
* [ ] You have matching rules and a review queue for deposits you can't match.
* [ ] You know your deposit limits, and who to contact when a large deposit is held.
* [ ] You've told regular customers the account name they'll see in their bank app.
***
## Related
* [The virtual account object](/api-reference/virtual-accounts/object)
* [collection.succeeded](/guides/webhooks/events/collection-succeeded)
* [The payment object](/api-reference/payments/object)
* [Deposit limits](/guides/payments/deposit-limits)
* [Bank-transfer checkout](/guides/checkout/checkout-sessions#restrict-payment-methods)
* [Capabilities](/connect/capabilities) and [Requirements](/connect/requirements)
# account.updated
Source: https://docs.bachs.io/guides/webhooks/events/account-updated
Occurs when an account's onboarding state changes.
Sent when an account's requirements change: a field is submitted, accepted, rejected, or a reviewer asks for an additional one. Use it to drive an onboarding progress screen instead of polling the account object.
Delivered to endpoints with `event_source` set to `connect` or `all`. The default for a new endpoint is `account`, its own events only, so a platform that never sets `event_source` receives nothing about the accounts it owns. See [Connect events](/guides/webhooks/overview#connect-events).
```json Event theme={"dark"}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "account.updated",
"created_at": "2026-08-07T11:04:22.518000+00:00",
"organization_id": "acct_3Wq8ZfT1yHnJ5sVe",
"account": "acct_3Wq8ZfT1yHnJ5sVe",
"data": {
"account": "acct_3Wq8ZfT1yHnJ5sVe",
"outstanding": ["company.registration_number"]
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `account.updated`.When the event occurred, in UTC.The account the requirement change happened on. On an event from an account you own, this is that account, not your platform.The origin account's id, repeated at the top level. Present only when the origin has a parent; absent on your platform's own events. This is how a platform tells which of its accounts the event concerns.The account's id. Matches `organization_id`.The field keys still being asked for, for example `company.registration_number` or `persons.per_3a91c0d7.id_document`. An empty array means nothing is left for the account to provide. It does not mean a capability is enabled.
An empty `outstanding` means nothing is outstanding, not that a capability is enabled. A capability only goes live on an explicit review decision. Gate features on [capability.updated](/guides/webhooks/events/capability-updated) instead.
## When it fires
`account.updated` fires when the account's requirement state changes: the account submits one or more fields, a reviewer accepts a field, a reviewer rejects a field, or a reviewer asks for an additional field. Each of these recomputes `outstanding` and emits the event with the new list.
It does not fire on its own when a capability's status changes with no requirement change behind it, and it does not fire on a schedule. A capability moving to `active` or `restricted` is reported on `capability.updated`, not here.
## What to do on receipt
Read `data.outstanding` and update your onboarding progress screen for the account named in `account` (or `organization_id` if `account` is absent). The list alone answers "is anything left to provide": show the field keys while it has entries, and show the account as waiting on review once it is empty.
For a screen that needs more than the list of keys, read the account with `GET /v1/accounts/{account_id}` and use its `requirements` block, which splits the same fields into `currently_due`, `eventually_due`, `past_due` and `pending_verification`, carries rejections in `errors`, and gives a per-field view in `entries`. See [Requirements](/connect/requirements).
Do not use this event to unlock a payment method or a payout. Use it only to tell the account what it still needs to provide.
## Related
* [capability.updated](/guides/webhooks/events/capability-updated)
* [Requirements](/connect/requirements)
* [Onboarding](/connect/onboarding)
# capability.updated
Source: https://docs.bachs.io/guides/webhooks/events/capability-updated
Occurs when a capability on an account changes status.
Sent when a capability is enabled or turned off. This is the event to gate features on: an account can perform an action once its capability reaches `active`, and not before.
Delivered to endpoints with `event_source` set to `connect` or `all`. The default for a new endpoint is `account`, its own events only, so a platform that never sets `event_source` receives nothing about the accounts it owns. See [Connect events](/guides/webhooks/overview#connect-events).
```json Event theme={"dark"}
{
"id": "evt_2b3c4d5e6f7g8h9i",
"type": "capability.updated",
"created_at": "2026-08-07T11:06:41.204000+00:00",
"organization_id": "acct_3Wq8ZfT1yHnJ5sVe",
"account": "acct_3Wq8ZfT1yHnJ5sVe",
"data": {
"account": "acct_3Wq8ZfT1yHnJ5sVe",
"capability": "payouts",
"status": "active",
"requested": true
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `capability.updated`.When the event occurred, in UTC.The account the capability change happened on. On an event from an account you own, this is that account, not your platform.The origin account's id, repeated at the top level. Present only when the origin has a parent; absent on your platform's own events. This is how a platform tells which of its accounts the event concerns.The account's id. Matches `organization_id`.The capability that changed, for example `payouts`, `transfers`, `conversions`, or a payment method capability such as `card_collection`. See [Capabilities](/connect/capabilities) for the full list.The capability's new status. In this event, always one of `active` (enabled, the account can perform the action) or `restricted` (not enabled). The status read API also defines `pending` and `unsupported`, but nothing in this codebase writes either today, so this event never carries them; do not build handling for them.Whether the account ever requested this capability. A `restricted` capability that was requested is in progress; one that was never requested is outside the account's setup.
The payload does not carry `status_details`. When `data.status` is `restricted` and you need the reason, read `status_details` from [`GET /v1/accounts/{account_id}/capabilities`](/api-reference/accounts/list-capabilities).
A capability can reach the same status more than once over its life, for example `restricted` to `active` to `restricted` to `active`. Each transition is delivered as its own event, so treat a repeat of the same `status` as a real change rather than a duplicate.
## When it fires
`capability.updated` fires whenever a capability's stored status is written: a merchant requesting a capability (which lands it `restricted`, with `requested` now `true`), an admin enabling a capability (`active`), or an admin restricting one, individually or in bulk. It does not fire when only the account's requirements change with no capability write behind it; that is `account.updated`.
## What to do on receipt
Read `data.capability` and `data.status` for the account named in `account` (or `organization_id` if `account` is absent), and update whatever in your system depends on that capability being enabled. Only `active` means the account can perform the action; treat every other status as not enabled and fall back to your existing state until you receive `active`.
## Related
* [account.updated](/guides/webhooks/events/account-updated)
* [Capabilities](/connect/capabilities)
* [Onboarding](/connect/onboarding)
# checkout.completed
Source: https://docs.bachs.io/guides/webhooks/events/checkout-completed
Occurs when a customer finishes a checkout session, whether or not a payment was collected.
Sent when a checkout session finishes successfully: payment, setup, and subscription checkouts all fire it. Use it as the single "the customer is done" signal, then check `data.payment_status` to see whether a payment was actually collected.
A free checkout (a `$0` cart, or a `setup`-mode checkout that only saves a payment method) also fires `checkout.completed`, with `data.payment_status` set to `no_payment_required`. No charge is created and no `collection.succeeded` event is sent for it. `checkout.completed` is the only signal you'll get.
`data.customer_details` is additive. If you already branch on `data.customer === null`, that behavior is unchanged: a checkout with no customer record behind it still sends `customer: null`, exactly as before. What's new is that a checkout with no customer record behind it, such as a payment link with a fixed price or one created with `customer_creation: if_required`, now carries the buyer's identity on `customer_details`, where before it sent nothing at all.
```json Event theme={"dark"}
{
"id": "evt_7f6e5d4c3b2a1908",
"type": "checkout.completed",
"created_at": "2026-07-20T09:15:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"checkout_id": "chk_6R7s8T9u0V1w2X3y",
"status": "completed",
"mode": "payment",
"payment_status": "paid",
"amount": "19.00",
"currency": "USD",
"reference": "order_1042",
"customer": {
"customer_id": "cust_1a2b3c4d5e6f",
"email": "jane@example.com",
"name": "Jane Doe",
"phone_number": null,
"metadata": {},
"billing_address": {
"line1": "40 Yaba Road",
"line2": null,
"city": "Lagos",
"state": "Lagos",
"postal_code": "101245",
"country": "NG"
},
"created_at": "2026-06-01T12:00:00Z",
"updated_at": "2026-06-01T12:00:00Z"
},
"customer_details": {
"email": "jane@example.com",
"name": "Jane Doe"
},
"charge": {
"id": "ch_1a2b3c4d5e6f",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"customer_id": "cust_1a2b3c4d5e6f",
"amount": "19.00",
"currency": "USD",
"settlement_currency": "USD",
"settlement_amount": "18.62",
"status": "succeeded",
"metadata": {},
"created_at": "2026-07-20T09:14:40Z",
"updated_at": "2026-07-20T09:15:00Z"
},
"subscription": null,
"success_url": "https://example.com/success",
"cancel_url": "https://example.com/cancel",
"metadata": {
"order_id": "ORD-12345"
},
"completed_at": "2026-07-20T09:15:00.000000+00:00",
"expires_at": "2026-07-20T10:14:00.000000+00:00",
"created_at": "2026-07-20T09:14:00.000000+00:00"
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `checkout.completed`.When the event occurred, in UTC.Your account's ID.The checkout session that completed.Always `completed`.The checkout's mode: `payment`, `setup`, or `subscription`.Whether a payment was collected at checkout. `paid` when a charge was made, `no_payment_required` when nothing was due, which covers a free (\$0) checkout, a `setup`-mode checkout, or a subscription on a free trial (billed at trial end).The checkout amount, as a decimal string. `"0"` for a free checkout.The checkout currency code.Checkout reference you supplied, when available.The customer record for this checkout, or `null` when none backs it, such as a checkout created with `customer_creation: if_required` or a payment link with a fixed price. See [`customer` vs `customer_details`](/api-reference/checkout-sessions/object#customer-vs-customer-details) for the full matrix.What the buyer supplied: `{ email, name }`. Present whenever an identity was collected, whether or not it produced a customer record. `null` only when no identity was collected at all.The resulting charge, in the same shape as `GET /v1/payments/charges/{charge_id}`. `null` for a free checkout, since no charge is created when nothing is collected.`{ subscription_id }` for a `subscription`-mode checkout. `null` for `payment` and `setup` modes.Where the customer was redirected after completing.Where the customer would have been redirected had they canceled.Public metadata stored on the checkout.When the checkout completed, in UTC.The checkout's original expiry time, in UTC.When the checkout session was created, in UTC.
# checkout.expired
Source: https://docs.bachs.io/guides/webhooks/events/checkout-expired
Occurs when an open checkout session lapses past its expiry without the customer completing it.
Sent when a checkout session was never completed and its expiry time has passed. Use it to release held inventory, mark an order as abandoned, or send a recovery email.
Expiry is not always final. A payment that was already on its way, such as a bank transfer, can still complete the checkout after this event, and [`checkout.completed`](/guides/webhooks/events/checkout-completed) follows. Keep the order so a late payment can still fulfil it.
```json Event theme={"dark"}
{
"id": "evt_5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a09",
"type": "checkout.expired",
"created_at": "2026-07-20T10:14:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"checkout_id": "chk_6R7s8T9u0V1w2X3y",
"status": "expired",
"mode": "payment",
"payment_status": null,
"amount": "19.00",
"currency": "USD",
"reference": "order_1042",
"customer": {
"customer_id": "cust_1a2b3c4d5e6f",
"email": "jane@example.com",
"name": "Jane Doe",
"phone_number": null,
"metadata": {},
"billing_address": {
"line1": "40 Yaba Road",
"line2": null,
"city": "Lagos",
"state": "Lagos",
"postal_code": "101245",
"country": "NG"
},
"created_at": "2026-06-01T12:00:00Z",
"updated_at": "2026-06-01T12:00:00Z"
},
"charge": null,
"subscription": null,
"success_url": "https://example.com/success",
"cancel_url": "https://example.com/cancel",
"metadata": {
"order_id": "ORD-12345"
},
"completed_at": null,
"expires_at": "2026-07-20T10:14:00.000000+00:00",
"created_at": "2026-07-20T09:14:00.000000+00:00"
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `checkout.expired`.When the event occurred, in UTC.Your account's ID.The checkout session that expired.Always `expired`.The checkout's mode: `payment`, `setup`, or `subscription`.Always `null`. An expired checkout never collected payment.The amount the checkout would have collected, as a decimal string.The checkout currency code.Checkout reference you supplied, when available.The customer attached to the checkout, or `null` if none was attached.Always `null`. An expired checkout has no charge.Always `null`. An expired checkout never started a subscription.Where the customer would have been redirected on success.Where the customer would have been redirected had they canceled.Public metadata stored on the checkout.Always `null`. The checkout was never completed.When the checkout's expiry lapsed, in UTC.When the checkout session was created, in UTC.
# collection.failed
Source: https://docs.bachs.io/guides/webhooks/events/collection-failed
Occurs when a payment attempt fails. The checkout stays open, so the customer can try again.
Sent when a payment attempt fails, for example a declined card. This is not final: the checkout stays open, and the customer can try again or choose another payment method. Bachs sends this event once per payment, for the first failure. Don't cancel the order on it; use [`checkout.expired`](/guides/webhooks/events/checkout-expired) to learn that a checkout was never paid.
```json Event theme={"dark"}
{
"id": "evt_87f6f546ba7148ffb1ecaf2a23f11a2f",
"type": "collection.failed",
"created_at": "2026-09-22T10:43:11.102Z",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"charge_id": "ch_1a2b3c4d5e6f",
"checkout_id": "chk_6R7s8T9u0V1w2X3y",
"reference": "ORD-20260922-1041",
"status": "FAILED",
"amount": "75000.00",
"currency": "NGN",
"settlement_amount": "73875.00",
"settlement_currency": "NGN",
"processing_fee": null,
"processing_fee_currency": null,
"fee_bearer": "merchant",
"product_cart": [
{ "product_id": "prod_1a2b3c", "quantity": 2 }
],
"customer": {
"id": "cust_xyz789",
"email": "jane@example.com",
"name": "Jane Doe"
},
"reason": "Payment authorization failed",
"metadata": {}
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`.Always `collection.failed`.When the event occurred, in UTC.Your account's ID.Charge ID for support and reconciliation workflows.Checkout that originated the charge, when available.Checkout reference, when available.The uppercase terminal state. `FAILED`: the payment was attempted and did not succeed. `EXPIRED`: nobody paid before the charge closed. The payment object returns these states in lowercase, so do not compare the values directly.Original charge amount in `data.currency`.Customer payment currency code.What would have been credited to you had the charge succeeded, worked out when the charge was created. A failed charge credits nothing, so do not read this as money you hold.Settlement currency.A human-readable reason for the failure, when available.Products the customer was buying, when a checkout created the charge. Each item contains `product_id`, `quantity`, and `amount` (present only for custom-priced products).The customer. `id` is always present and can be `null`, for a guest or for a charge no checkout created. `email` and `name` are included only when a checkout collected them.Bachs processing fee in `data.processing_fee_currency`. `null` while the final settlement figure is still open.Currency of `data.processing_fee`. `null` when `processing_fee` is null.Who absorbs the processing fee, `customer` or `merchant`.How the money moved. `null` here: no attempt collected anything, so there is no payer to name. See [collection.succeeded](/guides/webhooks/events/collection-succeeded) for the shape it takes when a payment does succeed.Public metadata stored with the charge.
# collection.succeeded
Source: https://docs.bachs.io/guides/webhooks/events/collection-succeeded
Occurs when a payment is successfully collected, including a virtual account deposit.
Sent when a charge reaches a successful state. For a checkout payment, use `data.checkout_id` or your supplied `data.reference` to find the order before fulfilling it or granting access. A [virtual account deposit](/guides/virtual-accounts/overview) has no checkout or order reference; reconcile the sender and amount against your records before marking any order paid. Deduplicate deliveries using the event `id`.
`data.charge_id` may be `null` in some scenarios:
* **Test webhooks:** events sent from the webhook test tool are not backed by a real charge.
* **Legacy payments:** collections processed before the charge model was introduced may not have an associated charge record.
* **Manual reconciliation:** administrative payment confirmations applied outside the standard charge flow do not create a charge.
Always guard against a null `charge_id` before using it for lookups.
The example below is for an account's own checkout. Events forwarded to a platform from a connected account also include a top-level `account` field with the connected account's ID.
```json Event theme={"dark"}
{
"id": "evt_3ab4e0d5d27445cf8a52ab3d8cb8f0b1",
"type": "collection.succeeded",
"created_at": "2026-09-22T10:41:18.402Z",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"charge_id": "ch_1a2b3c4d5e6f",
"checkout_id": "chk_6R7s8T9u0V1w2X3y",
"reference": "ORD-20260922-1041",
"status": "SUCCEEDED",
"amount": "75000.00",
"currency": "NGN",
"settlement_amount": "73875.00",
"settlement_currency": "NGN",
"processing_fee": "1125.00",
"processing_fee_currency": "NGN",
"fee_bearer": "merchant",
"product_cart": [
{ "product_id": "prod_1a2b3c", "quantity": 2 },
{ "product_id": "prod_9x8y7z", "quantity": 1, "amount": "5000.00" }
],
"customer": {
"id": "cust_xyz789",
"email": "jane@example.com",
"name": "Jane Doe"
},
"payment_method_details": {
"type": "bank_transfer",
"bank_transfer": {
"sender_name": "JANE ADEYEMI",
"sender_bank": "Guaranty Trust Bank",
"sender_bank_code": "058",
"sender_account_number": "2294879124",
"session_id": "000013260922104115000821734502",
"narration": "ORD-20260922-1041",
"virtual_account": {
"id": null,
"account_number": "9902847361",
"bank_name": "Example Bank",
"type": "one_time",
"expires_at": "2026-09-22T11:11:18.402Z"
}
}
},
"metadata": {
"order_id": "ORD-20260922-1041"
}
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `collection.succeeded`.When the event occurred, in UTC.The account where the event originated. For a connected-account payment, this is the connected account's ID, not your platform's ID.Present on an event delivered to a platform from a connected account; contains that connected account's ID. See [Connect events](/guides/webhooks/overview#connect-events).Charge ID for reconciliation and retrieval calls. May be `null`, see note above.Checkout that originated the charge. `null` for a fixed virtual-account deposit and other payments without a checkout.Checkout reference you supplied, when available.The uppercase charge state that triggered this event. `SUCCEEDED`: the full amount was collected. `ACCEPTED`: the payment was accepted on terms other than the exact amount requested. `OVERPAID`: more arrived than was due, with the difference in the overpayment fields below. The payment object returns these states in lowercase, so do not compare the values directly.Original charged amount in `data.currency`.Customer payment currency code.Amount credited in `data.settlement_currency`.Currency used for settlement credit.Bachs processing fee in `data.processing_fee_currency`. `null` when the final settlement value has not yet been determined.Currency of `data.processing_fee`, typically the settlement currency. `null` when `processing_fee` is `null`.Who absorbed the processing fee. Either `customer` (fee added on top of the charge amount) or `merchant` (fee deducted from settlement).Products purchased in a one-time checkout session. Each item contains `product_id`, `quantity`, and `amount` (present only for custom-priced products). `null` when no checkout created the charge.The customer who made the payment. `id` is always present and can be `null` for a guest or a payment received without a checkout, such as a [virtual account deposit](/guides/virtual-accounts/overview). `email` and `name` appear only when a checkout collected them.How the money moved, keyed by `type`. For a bank transfer, `bank_transfer` can include the sender's details, the interbank `session_id`, their `narration`, and the virtual account that received the money. Treat each nested field as optional because the sending bank may omit it. See [the payment object](/api-reference/payments/object) for the full shape.Public metadata stored with the charge.Why the charge reached this state, when we have something to say. Absent on an ordinary success.Present only when `status` is `OVERPAID`: the amount that was due.Present only when `status` is `OVERPAID`: the amount that actually arrived.Present only when `status` is `OVERPAID`: the difference between the two, which the customer paid on top.
# collection.underpaid
Source: https://docs.bachs.io/guides/webhooks/events/collection-underpaid
Occurs when a crypto payment arrives for less than the amount due.
Sent when a crypto payment arrives for less than the charge amount. The customer can send the rest to complete the payment. A bank transfer that arrives short does not send this event: the checkout does not complete, and you can [refund](/guides/refunds) the amount received.
```json Event theme={"dark"}
{
"id": "evt_6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a",
"type": "collection.underpaid",
"created_at": "2026-02-22T17:10:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"charge_id": "ch_1a2b3c4d5e6f",
"reference": "order_1042",
"checkout_id": "chk_6R7s8T9u0V1w2X3y",
"amount_paid": "50000.00",
"amount_expected": "75000.00",
"amount_remaining": "25000.00",
"currency": "NGN",
"status": "UNDERPAID",
"metadata": {}
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`.Always `collection.underpaid`.When the event occurred, in UTC.Your account's ID.The underpaid charge's ID.Checkout reference, when available.The checkout that originated the charge.Amount the customer actually paid, in `data.currency`.Amount that was due.The shortfall: `amount_expected` minus `amount_paid`.Customer payment currency code.Always `UNDERPAID`, in uppercase. The payment object returns this state in lowercase, so do not compare the two directly.Public metadata stored with the charge.
# conversion.completed
Source: https://docs.bachs.io/guides/webhooks/events/conversion-completed
Occurs when a currency conversion completes successfully.
Sent when a conversion settles. Use it to record the converted balance.
```json Event theme={"dark"}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "conversion.completed",
"created_at": "2026-04-27T12:00:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"conversion_id": "cnv_1a2b3c4d5e",
"quote_id": "cq_1a2b3c4d5e",
"from_currency": "USD",
"to_currency": "NGN",
"from_amount": "100.00",
"to_amount": "165000.00",
"exchange_rate": "1650.00",
"status": "completed",
"created_at": "2026-04-27T12:00:00Z"
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `conversion.completed`.When the event occurred, in UTC.Your account's ID.The conversion's ID.The quote the conversion was executed against.The source currency, as an ISO 4217 code.The target currency, as an ISO 4217 code.The amount converted from, as a decimal string.The amount received in the target currency.The FX rate applied.Conversion status, e.g. `completed`, `failed`.
# conversion.failed
Source: https://docs.bachs.io/guides/webhooks/events/conversion-failed
Occurs when a currency conversion fails.
Sent when a conversion fails. Use it to retry or surface the failure.
```json Event theme={"dark"}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "conversion.failed",
"created_at": "2026-04-27T12:00:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"conversion_id": "cnv_1a2b3c4d5e",
"quote_id": "cq_1a2b3c4d5e",
"from_currency": "USD",
"to_currency": "NGN",
"from_amount": "100.00",
"to_amount": "165000.00",
"exchange_rate": "1650.00",
"status": "failed",
"created_at": "2026-04-27T12:00:00Z"
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `conversion.failed`.When the event occurred, in UTC.Your account's ID.The conversion's ID.The quote the conversion was executed against.The source currency, as an ISO 4217 code.The target currency, as an ISO 4217 code.The amount converted from, as a decimal string.The amount received in the target currency.The FX rate applied.Conversion status, e.g. `completed`, `failed`.
# customer.created
Source: https://docs.bachs.io/guides/webhooks/events/customer-created
Occurs when a new customer is created.
Sent when a customer is created, whether from a checkout or the API. Use it to sync customers into your system.
A checkout created without a `customer` fires this event too, once the buyer identifies themselves on the hosted page. The one case that does not is a checkout created with `customer_creation: "if_required"`, which creates no record and so emits nothing. See [Whether a guest becomes a customer](/guides/checkout/checkout-sessions#whether-a-guest-becomes-a-customer-customer-creation).
```json Event theme={"dark"}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "customer.created",
"created_at": "2026-04-27T12:00:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"customer_id": "cust_1a2b3c4d5e6f",
"email": "jane@example.com",
"name": "Jane Doe",
"phone_number": "+2348012345678",
"metadata": {},
"billing_address": {
"line1": "40 Yaba Road",
"line2": null,
"city": "Lagos",
"state": "Lagos",
"postal_code": "101245",
"country": "NG"
},
"created_at": "2026-04-27T12:00:00Z",
"updated_at": "2026-04-27T12:00:00Z"
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `customer.created`.When the event occurred, in UTC.Your account's ID.The customer's ID, prefixed `cust_`.The customer's email address.The customer's name.The customer's phone number in E.164 format.Your own key-value data on the customer.The customer's billing address: `line1`, `line2`, `city`, `state`, `postal_code`, and `country` (ISO-3166-1 alpha-2). `null` when no address is stored.When the customer was created, in UTC.When the customer was last updated, in UTC.
# customer.subscription.created
Source: https://docs.bachs.io/guides/webhooks/events/customer-subscription-created
Occurs when a subscription is created after a customer completes a recurring checkout.
Sent when a new subscription is created. Use it to provision access to the recurring product.
```json Event theme={"dark"}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "customer.subscription.created",
"created_at": "2026-04-27T12:00:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"subscription_id": "sub_1a2b3c4d5e",
"customer": {
"customer_id": "cust_1a2b3c4d5e6f",
"email": "jane@example.com",
"name": "Jane Doe",
"phone_number": "+2348012345678",
"metadata": {},
"billing_address": {
"line1": "40 Yaba Road",
"line2": null,
"city": "Lagos",
"state": "Lagos",
"postal_code": "101245",
"country": "NG"
},
"created_at": "2026-03-01T12:00:00Z",
"updated_at": "2026-03-01T12:00:00Z"
},
"product_id": "prod_abc123",
"status": "active",
"collection_method": "charge_automatically",
"currency": "USD",
"amount": "10.00",
"billing_cycle": { "interval": "month", "frequency": 1 },
"quantity": 1,
"current_period_start": "2026-04-01T00:00:00Z",
"current_period_end": "2026-05-01T00:00:00Z",
"next_billed_at": "2026-05-01T00:00:00Z",
"trial_end": null,
"cancel_at_period_end": false,
"canceled_at": null,
"created_at": "2026-03-01T12:00:00Z",
"items": [],
"metadata": {}
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `customer.subscription.created`.When the event occurred, in UTC.Your account's ID.The subscription's ID.The full customer object: `customer_id`, `email`, `name`, `phone_number`, `metadata`, `billing_address`, `created_at`, and `updated_at`.The product the subscription bills.Subscription status: `trialing`, `active`, `past_due`, `unpaid`, `canceled`, or `paused`.How renewals are collected, e.g. `charge_automatically`.The billing currency, as an ISO 4217 code.The recurring amount, as a decimal string.The cadence: `{ interval, frequency }`.Start of the current billing period, in UTC.End of the current billing period, in UTC.When the subscription next renews, in UTC.When the trial ends, or `null` if not trialing.Whether the subscription is set to end at the period end.When the subscription was canceled, or `null`.The line items being billed.Your own key-value data on the subscription.
# customer.subscription.deleted
Source: https://docs.bachs.io/guides/webhooks/events/customer-subscription-deleted
Occurs when a subscription is canceled and will no longer renew.
Sent when a subscription is canceled. Use it to revoke access at the right time (immediately, or at period end).
```json Event theme={"dark"}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "customer.subscription.deleted",
"created_at": "2026-04-27T12:00:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"subscription_id": "sub_1a2b3c4d5e",
"customer": {
"customer_id": "cust_1a2b3c4d5e6f",
"email": "jane@example.com",
"name": "Jane Doe",
"phone_number": "+2348012345678",
"metadata": {},
"billing_address": {
"line1": "40 Yaba Road",
"line2": null,
"city": "Lagos",
"state": "Lagos",
"postal_code": "101245",
"country": "NG"
},
"created_at": "2026-03-01T12:00:00Z",
"updated_at": "2026-03-01T12:00:00Z"
},
"product_id": "prod_abc123",
"status": "canceled",
"collection_method": "charge_automatically",
"currency": "USD",
"amount": "10.00",
"billing_cycle": { "interval": "month", "frequency": 1 },
"quantity": 1,
"current_period_start": "2026-04-01T00:00:00Z",
"current_period_end": "2026-05-01T00:00:00Z",
"next_billed_at": "2026-05-01T00:00:00Z",
"trial_end": null,
"cancel_at_period_end": false,
"canceled_at": "2026-04-15T00:00:00Z",
"created_at": "2026-03-01T12:00:00Z",
"items": [],
"metadata": {}
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `customer.subscription.deleted`.When the event occurred, in UTC.Your account's ID.The subscription's ID.The full customer object: `customer_id`, `email`, `name`, `phone_number`, `metadata`, `billing_address`, `created_at`, and `updated_at`.The product the subscription bills.Subscription status: `trialing`, `active`, `past_due`, `unpaid`, `canceled`, or `paused`.How renewals are collected, e.g. `charge_automatically`.The billing currency, as an ISO 4217 code.The recurring amount, as a decimal string.The cadence: `{ interval, frequency }`.Start of the current billing period, in UTC.End of the current billing period, in UTC.When the subscription next renews, in UTC.When the trial ends, or `null` if not trialing.Whether the subscription is set to end at the period end.When the subscription was canceled, or `null`.The line items being billed.Your own key-value data on the subscription.
# customer.subscription.updated
Source: https://docs.bachs.io/guides/webhooks/events/customer-subscription-updated
Occurs when a subscription changes: a plan change, trial move, payment-method swap, or status transition.
Sent whenever a subscription changes. Key off `status` and the period fields to reconcile access.
```json Event theme={"dark"}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "customer.subscription.updated",
"created_at": "2026-04-27T12:00:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"subscription_id": "sub_1a2b3c4d5e",
"customer": {
"customer_id": "cust_1a2b3c4d5e6f",
"email": "jane@example.com",
"name": "Jane Doe",
"phone_number": "+2348012345678",
"metadata": {},
"billing_address": {
"line1": "40 Yaba Road",
"line2": null,
"city": "Lagos",
"state": "Lagos",
"postal_code": "101245",
"country": "NG"
},
"created_at": "2026-03-01T12:00:00Z",
"updated_at": "2026-03-01T12:00:00Z"
},
"product_id": "prod_abc123",
"status": "active",
"collection_method": "charge_automatically",
"currency": "USD",
"amount": "10.00",
"billing_cycle": { "interval": "month", "frequency": 1 },
"quantity": 1,
"current_period_start": "2026-04-01T00:00:00Z",
"current_period_end": "2026-05-01T00:00:00Z",
"next_billed_at": "2026-05-01T00:00:00Z",
"trial_end": null,
"cancel_at_period_end": false,
"canceled_at": null,
"created_at": "2026-03-01T12:00:00Z",
"items": [],
"metadata": {}
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `customer.subscription.updated`.When the event occurred, in UTC.Your account's ID.The subscription's ID.The full customer object: `customer_id`, `email`, `name`, `phone_number`, `metadata`, `billing_address`, `created_at`, and `updated_at`.The product the subscription bills.Subscription status: `trialing`, `active`, `past_due`, `unpaid`, `canceled`, or `paused`.How renewals are collected, e.g. `charge_automatically`.The billing currency, as an ISO 4217 code.The recurring amount, as a decimal string.The cadence: `{ interval, frequency }`.Start of the current billing period, in UTC.End of the current billing period, in UTC.When the subscription next renews, in UTC.When the trial ends, or `null` if not trialing.Whether the subscription is set to end at the period end.When the subscription was canceled, or `null`.The line items being billed.Your own key-value data on the subscription.
# customer.updated
Source: https://docs.bachs.io/guides/webhooks/events/customer-updated
Occurs when a customer's details change.
Sent when a customer is updated. Use it to keep your copy of the customer in sync.
Creating a customer with an email that already exists returns the existing customer with your changes applied, so that call emits `customer.updated` rather than `customer.created`.
```json Event theme={"dark"}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "customer.updated",
"created_at": "2026-04-27T12:00:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"customer_id": "cust_1a2b3c4d5e6f",
"email": "jane@example.com",
"name": "Jane Doe",
"phone_number": "+2348012345678",
"metadata": {},
"billing_address": {
"line1": "40 Yaba Road",
"line2": null,
"city": "Lagos",
"state": "Lagos",
"postal_code": "101245",
"country": "NG"
},
"created_at": "2026-04-27T12:00:00Z",
"updated_at": "2026-04-27T12:00:00Z"
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `customer.updated`.When the event occurred, in UTC.Your account's ID.The customer's ID, prefixed `cust_`.The customer's email address.The customer's name.The customer's phone number in E.164 format.Your own key-value data on the customer.The customer's billing address: `line1`, `line2`, `city`, `state`, `postal_code`, and `country` (ISO-3166-1 alpha-2). `null` when no address is stored. Sent whenever an update changes it.When the customer was created, in UTC.When the customer was last updated, in UTC.
# dispute.created
Source: https://docs.bachs.io/guides/webhooks/events/dispute-created
Occurs when a customer's bank raises a dispute (chargeback) against a charge.
Sent when a dispute is opened. Respond before `response_deadline_at` with evidence.
Delivered to endpoints with `event_source` set to `connect` or `all`. See [Connect events](/guides/webhooks/overview#connect-events).
```json Event theme={"dark"}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "dispute.created",
"created_at": "2026-04-27T12:00:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"account": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"dispute_id": "dsp_1a2b3c4d5e",
"charge_id": "ch_9f4c1d2e7b6a4f8e9c0d1a2b3c4d5e6f",
"amount": "75000.00",
"currency": "NGN",
"status": "needs_response",
"is_response_editable": true,
"reason": "fraudulent",
"response_deadline_at": "2026-05-10T00:00:00Z",
"created_at": "2026-04-27T12:00:00Z",
"updated_at": "2026-04-27T12:00:00Z",
"charge_amount": "75000.00",
"charge_currency": "NGN"
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `dispute.created`.When the event occurred, in UTC.Whoever the dispute belongs to. On a connected account's charge, this is the account, not your platform.The connected account the event is attributed to. Present only on events from a connected account.The dispute's ID.The disputed charge.The disputed amount, as a decimal string.The dispute currency, as an ISO 4217 code.Dispute status, e.g. `needs_response`, `under_review`, `won`, `lost`, `closed`, `prevented`.Whether you can still submit or update evidence.The reason the dispute was raised.When your evidence is due, in UTC.When the dispute was created, in UTC.When the dispute was last updated, in UTC.The charge's full amount, as a decimal string in `data.charge_currency`. A dispute can cover less than the full charge when only part of an order is disputed, so this is not always equal to `data.amount`.The charge's currency, as an ISO 4217 code.
# dispute.updated
Source: https://docs.bachs.io/guides/webhooks/events/dispute-updated
Occurs when a dispute's status changes on the network side. Saving or submitting evidence does not send this event.
Sent when a dispute progresses. Key off `status` to know whether it was won or lost.
Delivered to endpoints with `event_source` set to `connect` or `all`. See [Connect events](/guides/webhooks/overview#connect-events).
```json Event theme={"dark"}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "dispute.updated",
"created_at": "2026-04-27T12:00:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"account": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"dispute_id": "dsp_1a2b3c4d5e",
"charge_id": "ch_9f4c1d2e7b6a4f8e9c0d1a2b3c4d5e6f",
"amount": "75000.00",
"currency": "NGN",
"status": "under_review",
"is_response_editable": true,
"reason": "fraudulent",
"response_deadline_at": "2026-05-10T00:00:00Z",
"created_at": "2026-04-27T12:00:00Z",
"updated_at": "2026-04-27T12:00:00Z",
"charge_amount": "75000.00",
"charge_currency": "NGN"
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `dispute.updated`.When the event occurred, in UTC.Whoever the dispute belongs to. On a connected account's charge, this is the account, not your platform.The connected account the event is attributed to. Present only on events from a connected account.The dispute's ID.The disputed charge.The disputed amount, as a decimal string.The dispute currency, as an ISO 4217 code.Dispute status, e.g. `needs_response`, `under_review`, `won`, `lost`, `closed`, `prevented`.Whether you can still submit or update evidence.The reason the dispute was raised.When your evidence is due, in UTC.When the dispute was created, in UTC.When the dispute was last updated, in UTC.The charge's full amount, as a decimal string in `data.charge_currency`. A dispute can cover less than the full charge when only part of an order is disputed, so this is not always equal to `data.amount`.The charge's currency, as an ISO 4217 code.
# invoice.created
Source: https://docs.bachs.io/guides/webhooks/events/invoice-created
Occurs when an invoice is created for a subscription cycle or a one-off charge.
Sent when an invoice is created, before collection. Use it to record the amount due.
```json Event theme={"dark"}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "invoice.created",
"created_at": "2026-04-27T12:00:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"invoice_id": "inv_1a2b3c4d5e",
"subscription": { "subscription_id": "sub_1a2b3c4d5e" },
"customer": { "customer_id": "cust_1a2b3c4d5e6f", "email": "jane@example.com", "name": "Jane Doe" },
"charge": null,
"status": "open",
"collection_method": "charge_automatically",
"currency": "USD",
"subtotal": "10.00",
"total": "10.00",
"amount_paid": "0.00",
"amount_remaining": "10.00",
"period_start": "2026-04-01T00:00:00Z",
"period_end": "2026-05-01T00:00:00Z",
"attempt_count": 0,
"next_payment_attempt": "2026-04-01T00:05:00Z",
"created_at": "2026-04-01T00:00:00Z",
"metadata": {}
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `invoice.created`.When the event occurred, in UTC.Your account's ID.The invoice's ID.The subscription this invoice belongs to, or `null` for a one-off.The customer the invoice is for.The payment that collected the invoice, once collection is attempted.Invoice status: `draft`, `open`, `paid`, `uncollectible`, or `void`.How the invoice is collected, e.g. `charge_automatically`.The invoice currency, as an ISO 4217 code.The subtotal before credits, as a decimal string.The total due, as a decimal string.Amount paid so far.Amount still due.Start of the billing period, in UTC.End of the billing period, in UTC.Number of collection attempts made.When the next collection attempt is scheduled.Your own key-value data on the invoice.
# invoice.paid
Source: https://docs.bachs.io/guides/webhooks/events/invoice-paid
Occurs when an invoice is paid in full.
Sent when an invoice is paid. Use it to extend the subscription period or mark the bill settled.
```json Event theme={"dark"}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "invoice.paid",
"created_at": "2026-04-27T12:00:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"invoice_id": "inv_1a2b3c4d5e",
"subscription": { "subscription_id": "sub_1a2b3c4d5e" },
"customer": { "customer_id": "cust_1a2b3c4d5e6f", "email": "jane@example.com", "name": "Jane Doe" },
"charge": null,
"status": "paid",
"collection_method": "charge_automatically",
"currency": "USD",
"subtotal": "10.00",
"total": "10.00",
"amount_paid": "10.00",
"amount_remaining": "0.00",
"period_start": "2026-04-01T00:00:00Z",
"period_end": "2026-05-01T00:00:00Z",
"attempt_count": 0,
"next_payment_attempt": "2026-04-01T00:05:00Z",
"created_at": "2026-04-01T00:00:00Z",
"metadata": {}
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `invoice.paid`.When the event occurred, in UTC.Your account's ID.The invoice's ID.The subscription this invoice belongs to, or `null` for a one-off.The customer the invoice is for.The payment that collected the invoice, once collection is attempted.Invoice status: `draft`, `open`, `paid`, `uncollectible`, or `void`.How the invoice is collected, e.g. `charge_automatically`.The invoice currency, as an ISO 4217 code.The subtotal before credits, as a decimal string.The total due, as a decimal string.Amount paid so far.Amount still due.Start of the billing period, in UTC.End of the billing period, in UTC.Number of collection attempts made.When the next collection attempt is scheduled.Your own key-value data on the invoice.
# invoice.payment_failed
Source: https://docs.bachs.io/guides/webhooks/events/invoice-payment-failed
Occurs when an attempt to collect an invoice fails.
Sent when collecting an invoice fails. Use it to start dunning or notify the customer to update their card.
```json Event theme={"dark"}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "invoice.payment_failed",
"created_at": "2026-04-27T12:00:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"invoice_id": "inv_1a2b3c4d5e",
"subscription": { "subscription_id": "sub_1a2b3c4d5e" },
"customer": { "customer_id": "cust_1a2b3c4d5e6f", "email": "jane@example.com", "name": "Jane Doe" },
"charge": null,
"status": "open",
"collection_method": "charge_automatically",
"currency": "USD",
"subtotal": "10.00",
"total": "10.00",
"amount_paid": "0.00",
"amount_remaining": "10.00",
"period_start": "2026-04-01T00:00:00Z",
"period_end": "2026-05-01T00:00:00Z",
"attempt_count": 1,
"next_payment_attempt": "2026-04-01T00:05:00Z",
"created_at": "2026-04-01T00:00:00Z",
"metadata": {}
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `invoice.payment_failed`.When the event occurred, in UTC.Your account's ID.The invoice's ID.The subscription this invoice belongs to, or `null` for a one-off.The customer the invoice is for.The payment that collected the invoice, once collection is attempted.Invoice status: `draft`, `open`, `paid`, `uncollectible`, or `void`.How the invoice is collected, e.g. `charge_automatically`.The invoice currency, as an ISO 4217 code.The subtotal before credits, as a decimal string.The total due, as a decimal string.Amount paid so far.Amount still due.Start of the billing period, in UTC.End of the billing period, in UTC.Number of collection attempts made.When the next collection attempt is scheduled.Your own key-value data on the invoice.
# payout.created
Source: https://docs.bachs.io/guides/webhooks/events/payout-created
Occurs when a payout is created and begins processing.
Sent when you initiate a payout. Use it to track the payout from request to settlement.
```json Event theme={"dark"}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "payout.created",
"created_at": "2026-04-27T12:00:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"withdrawal_id": "pay_4Xr9dLc0mNv7Kq2B",
"reference": "payout_9876",
"status": "pending",
"amount": "500.00",
"currency": "USD",
"from_currency": "USD",
"to_currency": "NGN",
"exchange_rate": "1650.00",
"to_amount": "825000.00",
"withdrawal_fee": "2.50",
"net_from_amount": "497.50"
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `payout.created`.When the event occurred, in UTC.Your account's ID.The payout's ID.The reference you set when you created the payout. `null` if you set none.Payout status, `pending` on this event.The payout amount in `data.currency`.The source currency, as an ISO 4217 code.The currency debited from your balance.The currency delivered to the destination.The FX rate applied, when a conversion occurred.The amount delivered in `data.to_currency`.The payout fee, when applicable.The net amount debited after fees.
# payout.failed
Source: https://docs.bachs.io/guides/webhooks/events/payout-failed
Occurs when a payout fails to be delivered.
Sent when a payout fails. Use it to notify your team and retry or correct the destination.
```json Event theme={"dark"}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "payout.failed",
"created_at": "2026-04-27T12:00:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"withdrawal_id": "pay_4Xr9dLc0mNv7Kq2B",
"reference": "payout_9876",
"status": "failed",
"amount": "500.00",
"currency": "USD",
"from_currency": "USD",
"to_currency": "NGN",
"exchange_rate": "1650.00",
"to_amount": "825000.00",
"withdrawal_fee": "2.50",
"net_from_amount": "497.50"
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `payout.failed`.When the event occurred, in UTC.Your account's ID.The payout's ID.The reference you set when you created the payout. `null` if you set none.Payout status, always `failed` on this event.The payout amount in `data.currency`.The source currency, as an ISO 4217 code.The currency debited from your balance.The currency delivered to the destination.The FX rate applied, when a conversion occurred.The amount delivered in `data.to_currency`.The payout fee, when applicable.The net amount debited after fees.
# payout.paid
Source: https://docs.bachs.io/guides/webhooks/events/payout-paid
Occurs when a payout is delivered to its destination.
Sent when a payout completes. Use it to confirm funds reached the destination account.
```json Event theme={"dark"}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "payout.paid",
"created_at": "2026-04-27T12:00:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"withdrawal_id": "pay_4Xr9dLc0mNv7Kq2B",
"reference": "payout_9876",
"status": "completed",
"amount": "500.00",
"currency": "USD",
"from_currency": "USD",
"to_currency": "NGN",
"exchange_rate": "1650.00",
"to_amount": "825000.00",
"withdrawal_fee": "2.50",
"net_from_amount": "497.50"
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `payout.paid`.When the event occurred, in UTC.Your account's ID.The payout's ID.The reference you set when you created the payout. `null` if you set none.Payout status, always `completed` on this event.The payout amount in `data.currency`.The source currency, as an ISO 4217 code.The currency debited from your balance.The currency delivered to the destination.The FX rate applied, when a conversion occurred.The amount delivered in `data.to_currency`.The payout fee, when applicable.The net amount debited after fees.
# refund.created
Source: https://docs.bachs.io/guides/webhooks/events/refund-created
Occurs when a refund is created and begins processing.
Sent when you create a refund. Use it to track the refund to completion.
```json Event theme={"dark"}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "refund.created",
"created_at": "2026-04-27T12:00:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"refund_id": "ref_1a2b3c4d5e",
"charge_id": "ch_1a2b3c4d5e6f",
"reference": "refund_9876",
"status": "processing",
"requested_amount": "10.00",
"refunded_amount": "0.00",
"refund_fee_amount": "0.00",
"fee_bearer": "merchant",
"reason": "Customer request"
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `refund.created`.When the event occurred, in UTC.Your account's ID.The refund's ID.The charge being refunded.The reference you set when you requested the refund. Required, so it is always present.Refund status, e.g. `processing`, `paid`, `failed`.The amount requested to refund, as a decimal string.The amount actually refunded so far.The fee charged on the refund, if any.Who absorbs the refund fee, `customer` or `merchant`.The reason for the refund, when provided.
# refund.failed
Source: https://docs.bachs.io/guides/webhooks/events/refund-failed
Occurs when a refund fails to be delivered.
Sent when a refund fails. Use it to retry or investigate.
```json Event theme={"dark"}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "refund.failed",
"created_at": "2026-04-27T12:00:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"refund_id": "ref_1a2b3c4d5e",
"charge_id": "ch_1a2b3c4d5e6f",
"reference": "refund_9876",
"status": "failed",
"requested_amount": "10.00",
"refunded_amount": "0.00",
"refund_fee_amount": "0.00",
"fee_bearer": "merchant",
"reason": "Customer request"
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `refund.failed`.When the event occurred, in UTC.Your account's ID.The refund's ID.The charge being refunded.The reference you set when you requested the refund. Required, so it is always present.Refund status, e.g. `processing`, `paid`, `failed`.The amount requested to refund, as a decimal string.The amount actually refunded so far.The fee charged on the refund, if any.Who absorbs the refund fee, `customer` or `merchant`.The reason for the refund, when provided.
# refund.paid
Source: https://docs.bachs.io/guides/webhooks/events/refund-paid
Occurs when a refund is successfully delivered to the customer.
Sent when a refund completes. Use it to mark your record refunded.
```json Event theme={"dark"}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "refund.paid",
"created_at": "2026-04-27T12:00:00.000000+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {
"refund_id": "ref_1a2b3c4d5e",
"charge_id": "ch_1a2b3c4d5e6f",
"reference": "refund_9876",
"status": "paid",
"requested_amount": "10.00",
"refunded_amount": "10.00",
"refund_fee_amount": "0.00",
"fee_bearer": "merchant",
"reason": "Customer request"
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `refund.paid`.When the event occurred, in UTC.Your account's ID.The refund's ID.The charge being refunded.The reference you set when you requested the refund. Required, so it is always present.Refund status, e.g. `processing`, `paid`, `failed`.The amount requested to refund, as a decimal string.The amount actually refunded so far.The fee charged on the refund, if any.Who absorbs the refund fee, `customer` or `merchant`.The reason for the refund, when provided.
# transfer.created
Source: https://docs.bachs.io/guides/webhooks/events/transfer-created
Occurs when a transfer is created between a platform and an account it owns.
Sent when funds move between your platform's balance and an account's, in either direction. Use it to confirm a [split payment](/connect/split-payments) share reached its recipient.
The account is the event's origin in both directions, so a platform on `event_source` `connect` and the account on `account` both receive it, whichever side created the transfer. The default for a new endpoint is `account`, its own events only, so a platform that never sets `event_source` receives nothing about transfers on the accounts it owns. See [Connect events](/guides/webhooks/overview#connect-events).
```json Event theme={"dark"}
{
"id": "evt_3c4d5e6f7g8h9i0j",
"type": "transfer.created",
"created_at": "2026-08-07T11:04:22.518000+00:00",
"organization_id": "acct_3Wq8ZfT1yHnJ5sVe",
"account": "acct_3Wq8ZfT1yHnJ5sVe",
"data": {
"transfer_id": "tr_8c1e04a7b93f2d6540ab",
"source": "acct_7KpQ2mNv4XbR9dLc",
"destination": "acct_3Wq8ZfT1yHnJ5sVe",
"amount": "7000.00",
"currency": "NGN",
"description": "Order #4471 seller share",
"metadata": {},
"kind": "payout",
"source_charge_id": "ch_9f21c4e05b8a",
"created_at": "2026-08-07T11:04:22.518000+00:00"
}
}
```
## Event fields
The event's unique identifier, prefixed `evt_`. Use it to deduplicate deliveries.Always `transfer.created`.When the event occurred, in UTC.The account the transfer involved. On an event from an account you own, this is that account, not your platform.The origin account's id, repeated at the top level. Present only when the origin has a parent; absent on your platform's own events. This is how a platform tells which of its accounts the event concerns.The transfer's id, prefixed `tr_`.Whoever was debited. Your platform on a transfer out, the account on a transfer back.Whoever was credited.The amount moved, as a decimal string in `data.currency`.The currency moved, as an ISO 4217 code. Both balances hold it; a transfer never converts.The description set on the transfer.The metadata set on the transfer, returned unchanged.What this movement is. `payout` is a seller's share of a sale you made, and `manual` is a transfer you created yourself. The platform's own cut of a sale is never a transfer, see [Platform fees](/connect/platform-fees).The charge that funded this movement, prefixed `ch_`, when one did. Null on a transfer you created yourself, which is tied to no charge.When the transfer was created, in UTC.
## When it fires
`transfer.created` fires once, at creation, for every transfer: one you create directly on the transfers endpoint, and one written automatically by settlement, an account's share of a destination charge the platform made. It does not fire again later; a transfer has no further state change to report once it exists.
Order transfers by `data.created_at` rather than by arrival. Webhook delivery does not guarantee ordering, and for money movement the timestamp is the reliable sequence.
## What to do on receipt
Match `data.transfer_id` (or `data.metadata`, if you set your own reference there) against the order or payout you expect it to confirm, and mark that share as delivered.
Branch on `data.kind` to tell the two movements apart without fetching anything: record a `payout` as a seller's share, and a `manual` transfer as whatever your own process made it. On a `payout`, `data.source_charge_id` names the charge that funded it, so you can tie the movement back to the sale in the same handler. No event fires for the platform's own cut of a sale: read it from the payment object's `platform_fee`, or list it directly at [Platform fees](/connect/platform-fees).
## Related
* [Split payments](/connect/split-payments)
* [Transfers](/connect/transfers)
* [Balances](/connect/balances)
# Setting Up Webhooks
Source: https://docs.bachs.io/guides/webhooks/overview
Receive real-time notifications when payment or withdrawal state changes. No polling required.
Webhooks let Bachs push event notifications to your server the moment something happens: when a payment completes, a withdrawal fails, or a charge is disputed. You configure an endpoint URL and choose which events to subscribe to. We handle the delivery.
***
## Prerequisites
* A publicly reachable HTTPS endpoint on your server ready to accept `POST` requests.
* Access to your Bachs developer portal.
Still building and have nothing deployed? [Local testing](/developer-portal/local-testing) forwards live events to a port on your machine, so you can write and debug your handler before you have a public URL.
***
## Setup
You can manage webhook endpoints two ways: from the dashboard (below), or with the [Webhook Endpoint API](/api-reference/webhooks/object) using an API key that has the `webhooks:write` scope. Both create the same endpoints with the same signing secrets.
In the dashboard, your role needs permission to manage webhooks. You need it to add, edit, enable, disable or delete an endpoint, to see or rotate its signing secret, and to resend events. The Owner, Super Admin and Developer roles have it by default. The Finance and Customer Support roles do not. API keys use [permissions](/api-reference/permissions) instead: `webhooks:write` for changes, and `webhooks:read` to read endpoints and signing secrets.
From your dashboard, click on your Developer Portal at the bottom left
In the developer portal, navigate to the **Webhooks** section.
Click **Add destination** and enter the HTTPS URL where Bachs should deliver events. PS: All endpoints added automatically have a signing secret.
We use it to sign every delivery so you can verify requests are genuinely from Bachs.
All webhook deliveries include an `X-Bachs-Signature` header. Validate it against the raw request body before processing the payload.
Choose the events you want to receive. You can subscribe to payment events, withdrawal events, or both. Only the events you select will be delivered to your endpoint.
| Category | Events |
| - | - |
| **Checkout** | `checkout.completed`, `checkout.expired` |
| **Payments** | `collection.succeeded`, `collection.failed`, `collection.underpaid` |
| **Payment methods** | `payment_method.saved` |
| **Subscriptions** | `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted` |
| **Invoices** | `invoice.created`, `invoice.paid`, `invoice.payment_failed` |
| **Withdrawals** | `payout.created`, `payout.paid`, `payout.failed` |
| **Refunds** | `refund.created`, `refund.paid`, `refund.failed` |
| **Disputes** | `dispute.created`, `dispute.updated` |
| **Conversions** | `conversion.completed`, `conversion.failed` |
| **Customers** | `customer.created`, `customer.updated` |
Once you save, your endpoint is live and will start receiving events immediately.
***
## Verifying your webhooks
Every webhook delivery is signed. Before processing any payload, verify the signature to confirm the request came from Bachs.
### Retrieve your signing secret
Each endpoint has an auto-generated signing secret. To find it:
Navigate to the **Webhooks** section in the developer portal.
Click on the endpoint you want to verify deliveries for.
The signing secret is displayed on the endpoint detail page. Copy it and store it securely in your environment variables.
### How the signature works
Each delivery includes these headers:
| Header | Value |
| - | - |
| `X-Bachs-Timestamp` | Unix timestamp (seconds) of when the event was sent |
| `X-Bachs-Signature` | HMAC-SHA256 hex digest of `"{timestamp}.{raw_body}"` |
| `X-Bachs-Signature-V2` | `t={timestamp},v1={signature}`. The same digest, with the scheme named and the timestamp carried inline. Repeats `v1=` once per currently valid secret. |
To verify, reconstruct the signed message using the timestamp and the raw request body, compute the HMAC-SHA256 using your secret, and compare it to the signature.
**Prefer `X-Bachs-Signature-V2` for new integrations.** It carries the same digest, so the algorithm you implement is identical. It also names the scheme (`v1=`), which lets us introduce a new one later without breaking your verifier, and it can carry more than one signature, which is what makes [rotating a secret](#rotating-a-signing-secret) safe. `X-Bachs-Signature` continues to be sent and is not going away without notice.
When verifying `X-Bachs-Signature-V2`, split on `,`, take the `t=` value as the timestamp, and accept the delivery if **any** `v1=` value matches what you computed. Comparing against only the first one will fail during a rotation.
```python theme={"dark"}
def verify_v2(header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
timestamp = int(parts["t"])
if abs(time.time() - timestamp) > tolerance:
return False # too old; likely a replay
signatures = [v for k, v in (p.split("=", 1) for p in header.split(",")) if k == "v1"]
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)
```
### Rotating a signing secret
Rotating adds a new secret and keeps the previous one valid for 24 hours. During that window every delivery is signed with **both**, so traffic keeps verifying while you deploy:
1. Rotate the secret and store the new value returned to you.
2. Deploy it. Until you do, the old secret still matches.
3. The old secret stops signing when the window closes.
If a secret is compromised and you need the old one dead immediately, rotate with a zero-hour overlap, but any endpoint still using the old secret will start rejecting deliveries at once.
Always read the raw request body before JSON parsing. Parsing the body first and re-serializing it can alter whitespace and byte order, which will break signature verification.
### Verification examples
```python Python theme={"dark"}
import hashlib
import hmac
import time
def verify_bachs_signature(
raw_body: bytes,
secret: str,
timestamp_header: str,
signature_header: str,
tolerance_seconds: int = 300,
) -> bool:
# Reject stale deliveries
timestamp = int(timestamp_header)
if abs(time.time() - timestamp) > tolerance_seconds:
return False
message = f"{timestamp}.{raw_body.decode('utf-8')}"
expected = hmac.new(
secret.encode(), message.encode("utf-8"), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)
```
```javascript Node.js theme={"dark"}
const crypto = require("crypto");
function verifyBachsSignature(
rawBody,
secret,
timestampHeader,
signatureHeader,
toleranceSeconds = 300
) {
const timestamp = parseInt(timestampHeader, 10);
// Reject stale deliveries
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
return false;
}
const message = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac("sha256", secret)
.update(message, "utf8")
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader)
);
}
```
```java Java theme={"dark"}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
public class BachsWebhookVerifier {
public static boolean verify(
String rawBody,
String secret,
String timestampHeader,
String signatureHeader,
int toleranceSeconds
) throws Exception {
long timestamp = Long.parseLong(timestampHeader);
// Reject stale deliveries
if (Math.abs(Instant.now().getEpochSecond() - timestamp) > toleranceSeconds) {
return false;
}
String message = timestamp + "." + rawBody;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] hashBytes = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (byte b : hashBytes) {
hex.append(String.format("%02x", b));
}
String expected = hex.toString();
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
signatureHeader.getBytes(StandardCharsets.UTF_8)
);
}
}
```
***
## Receiving Events
Every webhook delivery is a `POST` request with a JSON body. The envelope looks like this:
```json theme={"dark"}
{
"id": "evt_3ab4e0d5d27445cf8a52ab3d8cb8f0b1",
"type": "collection.succeeded",
"created_at": "2026-02-22T16:20:00.123456+00:00",
"organization_id": "acct_7KpQ2mNv4XbR9dLc",
"data": {}
}
```
Use the `id` field to deduplicate deliveries. We guarantee at-least-once delivery, so the same event may arrive more than once.
**Ignore fields you don't recognise.** We may add new fields to the envelope or to `data` at any time, and we treat that as a backwards-compatible change. Parse leniently: read the fields you need and ignore the rest. Strict deserialization that rejects unknown fields will start failing when a field is added. That includes Go's `DisallowUnknownFields`, Pydantic's `extra="forbid"`, and schema validation in front of your handler. Removing a field or changing the meaning of an existing one is a breaking change, and we will not do it silently.
### Responding to deliveries
Return `400` only for a delivery you reject, such as one with an invalid signature. If your own processing fails, for example a database write, return a `5xx` so the delivery is retried. See [Retries](#retries) for which responses are retried, and when.
Events are not guaranteed to arrive in the order they happened, and a retried event can arrive after a newer one. Before you overwrite stored state, check that the event is not older than the state you already have, for example by comparing `created_at`.
***
## Retries
A delivery succeeds when your endpoint returns any `2xx` status within 10 seconds. Anything else is a failed try. What we do next depends on how it failed:
| Your endpoint | What we do |
| - | - |
| Times out, cannot be reached, or returns a `5xx` | Try again |
| Returns `408` or `429` | Try again |
| Returns any other `4xx`, such as `400` or `401` | Stop. We treat it as a refusal, so trying again would not help. |
| Returns a redirect (`3xx`) | Stop. We do not follow redirects, so register the final URL. |
Each delivery gets up to 5 tries: the first one, then retries about 1 minute, 5 minutes, 15 minutes and 1 hour after each failed try. The last try comes about 80 minutes after the first. After the fifth failure we stop. To send the event again, [resend or replay it](/guides/webhooks/replay-events).
A resend or a replay is a single try. We do not retry it.
Return a `2xx` as soon as you have stored the event, and do the slow work after that. A handler that takes longer than 10 seconds counts as failed, even if it finishes the work.
***
## When an endpoint keeps failing
If every delivery to an endpoint fails for days, we warn you and then turn the endpoint off. This is what happens:
1. **After 24 hours of failures**, we email the account owner and admins. The email names the endpoint, says when it started failing, and gives the date we will turn it off.
2. **After 72 hours of failures, and at least 48 hours after that email**, we turn the endpoint off and email the account owner and admins again. On the [endpoint](/api-reference/webhooks/object), `enabled` becomes `false`, `disabled_at` is set, and `disabled_reason` is `consecutive_failures`.
"Failures" means every delivery failed, with no success in between. One successful delivery resets the count. Every failed try counts, including the ones we do not retry, such as a `401`.
You always get the full 48 hours after the warning, even when the endpoint has already been failing for 72 hours. We only check the times when a delivery fails, so an endpoint that receives no events in that window stays on.
### While an endpoint is off
* We send it no new events.
* Retries that were already waiting stop.
* Events that happen while it is off are not sent later, even after you turn it back on. They are still recorded on your account, so you can resend them.
### Turn it back on
Fix the cause first: check that the URL is reachable and returns a `2xx`. If it still fails after you turn it on, the 24-hour count starts again from the next failure.
Open the endpoint from **Webhooks** in the developer portal. An endpoint we turned off shows a notice with the date we turned it off. Click **Enable endpoint** on the notice, or turn on the **Enabled** switch.
The endpoint list shows each endpoint's status: **Enabled**, **Disabled**, or **Disabled automatically** when we turned it off.
Send `enabled: true` to [Update a webhook endpoint](/api-reference/webhooks/update-a-webhook-endpoint).
```bash Turn an endpoint back on theme={"dark"}
curl -X PATCH https://sandbox-api.bachs.io/v1/webhooks/endpoints/whe_a1e823c073ab743ce5969ceef2db4d42 \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "enabled": true }'
```
```json Response theme={"dark"}
{
"endpoint_id": "whe_a1e823c073ab743ce5969ceef2db4d42",
"name": "Production events",
"url": "https://api.example.com/webhooks/bachs",
"enabled": true,
"disabled_at": null,
"disabled_reason": null,
"event_types": ["collection.succeeded", "collection.failed"],
"event_source": "account",
"created_at": "2026-03-09T10:00:00.000Z",
"updated_at": "2026-03-13T08:41:20.000Z"
}
```
Turning an endpoint on clears its failure history, `disabled_at` and `disabled_reason`.
Then send the events it missed. [List your events](/api-reference/webhooks/list-webhook-events), keep the ones created after `disabled_at` whose type the endpoint subscribes to, and [resend each one to the endpoint](/api-reference/webhooks/resend-an-event-to-an-endpoint). Your handler should already ignore an event `id` it has seen, so a duplicate does no harm.
Note `disabled_at` before you turn the endpoint back on. Turning it on sets `disabled_at` to `null`, and you need it to know which events to resend.
### Turn an endpoint off yourself
Send `{ "enabled": false }` to [Update a webhook endpoint](/api-reference/webhooks/update-a-webhook-endpoint), or turn off the **Enabled** switch on the endpoint page in the dashboard and confirm.
The rules in [While an endpoint is off](#while-an-endpoint-is-off) apply. We do not email you, and `disabled_at` and `disabled_reason` stay `null`, so you can tell your choice from ours.
***
## Connect events
If you run a [Connect](/connect/overview) platform, an endpoint can also receive events that happened on your connected accounts. Each endpoint carries an `event_source`:
| `event_source` | Receives |
| - | - |
| `account` | Your own account's events only. The default. |
| `connect` | Your connected accounts' events only. |
| `all` | Both. |
An endpoint created without setting `event_source` receives nothing about your connected accounts. Set it explicitly when you want Connect events.
An event happens on one account, its origin. The origin's own endpoints receive it on `account` or `all`, and the origin's parent receives it on `connect` or `all`. Delivery walks up one level and no further, so an event never reaches a sibling account.
On an event from a connected account, `organization_id` is that connected account rather than your platform, and a top-level `account` field carries the same id. Read the account from the payload rather than assuming the event belongs to the account you authenticated as.
```json theme={"dark"}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "capability.updated",
"created_at": "2026-08-07T11:06:41.204000+00:00",
"organization_id": "acct_3Wq8ZfT1yHnJ5sVe",
"account": "acct_3Wq8ZfT1yHnJ5sVe",
"data": { "account": "acct_3Wq8ZfT1yHnJ5sVe", "capability": "payouts", "status": "active", "requested": true }
}
```
`account` is absent on your own account's events, so its presence tells you an event came from a connected account.
***
## Event reference
Every event has its own page with the payload shape and a field reference. Browse them under **Events** in the sidebar, grouped by resource:
* **Checkout**: `checkout.completed`, `checkout.expired`
* **Payments**: `collection.succeeded`, `collection.failed`, `collection.underpaid`
* **Payment methods**: `payment_method.saved`
* **Subscriptions**: `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`
* **Invoices**: `invoice.created`, `invoice.paid`, `invoice.payment_failed`
* **Withdrawals**: `payout.created`, `payout.paid`, `payout.failed`
* **Refunds**: `refund.created`, `refund.paid`, `refund.failed`
* **Disputes**: `dispute.created`, `dispute.updated`
* **Conversions**: `conversion.completed`, `conversion.failed`
* **Customers**: `customer.created`, `customer.updated`
* **Connect**: `account.updated`, `capability.updated`, `transfer.created`
To re-deliver a past event, see [Replay events](/guides/webhooks/replay-events).
# Replay Webhook Events
Source: https://docs.bachs.io/guides/webhooks/replay-events
Create a new outbound delivery attempt for a previously generated webhook event.
Use this when your endpoint missed an event or returned a non-`2xx` response and you want Bachs to deliver the event again.
***
## When To Use This Endpoint
Replay webhooks when:
* Your endpoint was temporarily unavailable during earlier delivery attempts.
* You fixed signature verification or payload handling and need a resend.
* You need to re-drive downstream processing for a specific event.
***
## Request
### Method & Path
```text theme={"dark"}
POST /v1/webhooks/replay
```
### Required Headers
| Header | Required | Description |
| - | - | - |
| `Authorization` | Yes | API key bearer token or dashboard bearer token. |
| `Content-Type` | Yes | Must be `application/json`. |
### Request Fields
At least one lookup field is required.
**Required:** No
Replay this exact event ID directly.
**Required:** No
Resolve the latest webhook event linked to this charge and replay it.
**Required:** No
Resolve checkout by reference, then replay the latest event for the linked charge.
### Resolution Priority
When multiple fields are provided, replay resolution behaves as:
1. `event_id` (direct event lookup)
2. `charge_id`
3. `reference`
### Request Example
```json theme={"dark"}
{
"event_id": "evt_3ab4e0d5d27445cf8a52ab3d8cb8f0b1"
}
```
***
## Response
### 200 - Success
```json theme={"dark"}
{
"event_id": "evt_3ab4e0d5d27445cf8a52ab3d8cb8f0b1",
"attempt_id": "wha_6f1e40f6bdf84c1980e1e1f6407f3f8a",
"attempt_no": 3,
"event_type": "collection.failed"
}
```
### Response Fields
Webhook event selected for replay.
Newly created delivery attempt ID.
Attempt sequence number for this event after replay was created.
Event type associated with the replayed event.
***
## Error Responses
**Cause:** No supported lookup field was provided.
**Resolution:** Provide at least one of `event_id`, `charge_id`, `reference`, or `transaction_id`.
**Cause:** Authorization is missing, invalid, or revoked.
**Resolution:** Send a valid bearer credential.
**Cause:** No matching webhook event could be resolved for your lookup values.
**Resolution:** Verify IDs/references and retry.
***
## Related Pages
* [Webhooks Overview](/guides/webhooks/overview)
* [Payment Webhook Events](/guides/webhooks/overview)
* [Withdrawal Webhook Events](/guides/webhooks/overview)
# Sandbox Environment
Source: https://docs.bachs.io/integrate/sandbox
A fully isolated environment for testing your integration. No real money, no production data.
The sandbox is a separate Bachs environment you can use to build and test your integration freely. It runs independently of your live account. Anything you do in sandbox has no effect on production.
Want to feel the sandbox before wiring anything? The [live demo](https://snapkit.bachs.io) is a storefront running entirely on it: real API, real checkout, test cards, no real money.
***
## Switching to Sandbox
To enter the sandbox, click the **organization switcher dropdown** at the top left of your dashboard and select **Sandbox**. The dashboard will reload showing your sandbox account with its own separate balance, charges, and settings.
To switch back, select your organization name from the same dropdown.
***
## Sandbox API
All API requests in sandbox mode should be directed to a different base URL:
| Environment | Base URL |
| - | - |
| Production | `https://api.bachs.io` |
| Sandbox | `https://sandbox-api.bachs.io` |
Replace the base URL in your integration when testing. Endpoints, request structure, and response shapes are identical.
API keys are environment-specific. Your production keys will not work against the sandbox URL. Generate a separate set of API keys from within your sandbox account.
***
## Isolated Environment
Sandbox is entirely separate from your live account. None of the following are shared between environments:
* Balance and charge history
* Customers and payment methods
* Payout destinations
* Webhook endpoints and event history
* API keys
Changes made in sandbox, including creating customers, initiating withdrawals, and configuring webhooks, will never appear in production and vice versa.
## Test payment outcomes
A sandbox payment succeeds by default and finalizes on its own, about a second after the customer pays. No real money moves. To test how your integration handles a failure:
* **Cards:** pay with one of these numbers. Any other card number succeeds.
| Card number | Result |
| - | - |
| `4000000000000002` | Declined |
| `4000000000009995` | Insufficient funds |
| `4000000000000069` | Expired card |
| `4000000000000119` | Processing error |
* **Bank transfer, mobile money and crypto:** use the test controls on the sandbox checkout page to make the payment succeed, fail, or arrive underpaid.
* **Refunds:** send `simulated_outcome` as `success` or `failed`. See [Issue a refund](/guides/refunds#testing).
Each outcome sends the same webhooks as in production, so test your handler against every result before you go live.
***
## Related
Generate API keys for sandbox and production.
Set up a webhook endpoint to receive sandbox events.
# Payments and billing for internet businesses
Source: https://docs.bachs.io/introduction
Accept payments globally, run any billing model, and settle in your currency, without building payment infrastructure.
Bachs is the payments and billing platform for SaaS, AI, and digital businesses. Accept payments from customers anywhere, run subscriptions and one-time sales from the same catalog, and settle in your own currency, all through one API and one dashboard.
> Building is no longer the bottleneck. With AI, small teams can ship world-class products. What comes next is monetization, scale, and global reach.
## What is Bachs?
Bachs gives you a single platform to collect money, manage billing, and handle the financial complexity of selling across borders. Instead of integrating several providers and stitching together compliance workarounds, you connect to Bachs once and get everything you need to run your revenue.
We're built for African internet businesses and the global customers they serve: your customer pays in their currency, and you settle in yours.
Our goal is to become the most comprehensive financial platform for every use case. We're starting with digital businesses first.
## What Bachs handles
Accept payments from customers across Africa and beyond. Bachs meets your customers with the methods they already use, so they never have to reach for an unfamiliar payment flow to buy from you. See [supported currencies and methods](/guides/products/local-pricing).
Run flexible recurring billing for your product: plans, [free trials](/guides/subscriptions/trials), upgrades and downgrades with [proration](/guides/subscriptions/proration), and [automatic payment recovery](/guides/subscriptions/failed-payments), without managing the infrastructure behind it. See [Subscriptions](/guides/subscriptions/overview).
Define your catalog once with fixed, free, or pay-what-you-want pricing, and set exact [local-currency prices](/guides/products/local-pricing) per market. See [Products](/guides/products/overview).
Integrate via our [REST API](/api-reference/overview) or run everything from the dashboard. Whether you're wiring up a checkout or monitoring revenue, Bachs works the way you work.
## Who is Bachs for?
Bachs is built for founders and teams building digital businesses, where the product lives online, the customers can be anywhere, and the billing needs to work without babysitting.
Whether you're a solo developer monetizing your first SaaS product or an early-stage startup scaling across African markets, Bachs gives you the payments and billing infrastructure to start collecting revenue without the overhead.
### You're a great fit if you're building
Recurring billing, subscription management, and global payment acceptance, from your first paying customer to your millionth.
Shipping an AI tool or a developer-facing API? Bachs handles the monetization layer so you can stay on the product, from the indie hacker charging for a resume analyzer to a team billing for API usage.
Newsletters, courses, communities, digital downloads. If you sell something digital and need a clean, reliable way to collect money, Bachs works for you.
Two-sided platforms that collect from buyers and pay out to sellers, without building and maintaining the financial plumbing yourself.
## Why Bachs is different
Stay focused on your product. Stop building a fintech layer you don't need.
With traditional payment providers, accepting multi-currency payments means inheriting multi-currency problems. Every currency you accept becomes one you're responsible for: FX exposure, conversion timing, and the operational weight of holding balances in markets you've never operated in.
But as a founder or digital business, you don't actually want to hold KES or UGX as a balance. You want USD, or NGN. You want your customer in Kenya, Uganda, or the U.S. to pay without friction.
Bachs separates those two things cleanly: **your customer pays in their currency, and we settle in yours.** We carry the FX risk and convert at settlement, so you get a global checkout without the operational weight of being a multi-currency business.
Cards aren't the default in Africa. Bachs supports cards alongside local payment methods, so one integration reaches every customer.
However your customer pays, your withdrawal lands in your currency. No US bank account, no middlemen. Your revenue lands where you are.
We built a product in Africa before we built Bachs. We know what it costs to scale here, and we built the infrastructure we wished existed.
Subscriptions, trials, proration, and automatic payment recovery, built in.
Most providers only move money. Bachs runs your products, checkout, subscriptions, and reconciliation from one place.
A single integration unlocks a complete checkout: local and global payment methods at once.
Test every flow before you go live in our [sandbox](/integrate/sandbox), no real money required.
We stitched several providers together for our own product before building Bachs. You shouldn't have to. One integration replaces the stack.
## Quickstart
[Sign up](https://app.bachs.io/signup) with your email. You can start building in the sandbox right away, before going live.
Open the developer section of your dashboard to create a sandbox key (`sk_sandbox_...`). See [Authentication](/authentication) to create and manage keys.
Define a product, then create a checkout session and send the customer to the hosted checkout URL.
```bash theme={"dark"}
curl https://sandbox-api.bachs.io/v1/checkout-sessions \
-H "Authorization: Bearer $BACHS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"product_cart": [{ "product_id": "prod_abc123", "quantity": 1 }],
"customer": { "email": "customer@example.com" },
"success_url": "https://yoursite.com/success",
"cancel_url": "https://yoursite.com/cancelled"
}'
```
Follow the full walkthrough in [Accept a payment](/guides/checkout/checkout-sessions). Prefer to see the result first? Click through the [live demo](https://snapkit.bachs.io), a storefront running entirely on the sandbox.
## Go live
Build in the sandbox for as long as you need. When you're ready to accept real payments, your account goes through verification once, and going live is a key swap. Most reviews finish within 48 hours. See [Go live](/go-live).
## Good to go?
Onboard and go live in days, or explore in the sandbox first.
Every endpoint, object, and error, with copy-paste examples.
Step-by-step walkthroughs for checkout, subscriptions, and more.
Get direct help from our team.