# Authentication and account Source: https://docs.cbpayapp.com/en/authentication JWT sessions, API keys, your account profile and company members Every call (except register and login) requires a credential in the `Authorization` header: ``` Authorization: Bearer ``` `X-API-Key: ` is accepted as an alternative header. ## Credential types Obtained from `POST /v1/auth/register` or `POST /v1/auth/login`, valid for **24 hours**. Meant for apps where users sign in. Along with the `access_token` you receive a **refresh token** to renew the session without asking for the password again — see [session renewal](#session-renewal-refresh-tokens). ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/auth/login \ -H "Content-Type: application/json" \ -d '{ "org": "cbpay", "email": "ana@example.com", "password": "…" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "access_token": "eyJ…", "expires_at": "2026-07-14T15:20:49Z", "refresh_token": "rt_6a1e6c22-….XXXXXXXX…", "refresh_expires_at": "2026-08-12T15:20:49Z", "account_id": "…", "role": "owner" } ``` Company accounts can have multiple **members** with roles — see [company members](#company-members) below. If the account's policy requires **OTP on login**, the response carries `otp_required: true` with a `pending_token` instead of the session: the second step completes at `POST /v1/auth/login/otp` with the code received over SMS/WhatsApp. Full flow in [security and 2FA](/en/security-2fa). You can also offer **sign up and sign in with Google, Apple, Microsoft or Facebook** (passwordless) — see [social login](/en/guides/social-login). Format `pk_.`. Never expires and is not tied to a session. Issue one with: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/api-keys \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "label": "production-backend" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "api_key_id": "…", "key_id": "a1b2c3d4e5f60718", "token": "pk_a1b2c3d4e5f60718.XXXXXXXX…", "note": "store this token now; it cannot be retrieved again" } ``` The plaintext token is shown **exactly once**. Only a hash is stored server side — if you lose it, issue a new key. ## Session renewal (refresh tokens) The `access_token` lasts 24 hours; the `refresh_token` (`rt_…`) lets you get a fresh pair **without re-login** for 30 days, renewed on every use up to a maximum of 90 days from the original login. It is **single-use**: every exchange returns a new `refresh_token` and invalidates the previous one (rotation). ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} sequenceDiagram participant App as Your front end participant API as CBPay API App->>API: POST /v1/auth/login API-->>App: access_token (24h) + refresh_token A Note over App: … hours pass, the access token expires … App->>API: POST /v1/auth/refresh { refresh_token: A } API-->>App: new access_token + refresh_token B (A is now used) App->>API: POST /v1/auth/refresh { refresh_token: A } (reuse) API-->>App: 401 + the whole chain is revoked (possible theft) ``` ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/auth/refresh \ -H "Content-Type: application/json" \ -d '{ "refresh_token": "rt_6a1e6c22-….XXXXXXXX…" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "access_token": "eyJ…", "expires_at": "2026-07-14T15:20:50Z", "refresh_token": "rt_9f2b1c30-….YYYYYYYY…", "refresh_expires_at": "2026-08-12T15:20:50Z", "account_id": "…", "role": "owner" } ``` Security rules your front end must know: * **Strict rotation**: on exchange, the previous access token of that device is revoked immediately (only one live access token per chain). Always replace BOTH tokens with the ones in the response. * **Reuse = theft**: if an already-exchanged refresh token is presented again, the device's entire chain is revoked (tokens and sessions) and a `refresh_token_reuse` event is recorded in `GET /v1/me/security/events`. The user must sign in again. * **Dies with the session**: signing out (`DELETE /v1/me/sessions/{id}`), `POST /v1/me/sessions/revoke-all` or a password change/reset also invalidate the associated refresh tokens. * Every rejection returns `401 invalid_refresh_token` — on that error, send the user to the login screen. * Store the refresh token in secure storage (Keychain/Keystore on mobile; on web, prefer memory + re-login or an httpOnly cookie from your backend). `pk_` API keys do not use refresh: they never expire. When the access token is about to expire (use `expires_at`) or upon a `401` on a normal call. Avoid refreshing in parallel from several places: if two exchanges of the same token race, one wins and the other gets a `401` without penalty — but exchanging a token that was ALREADY rotated revokes the chain. The chain's absolute cap is 90 days from the original login: even refreshing daily, once the limit is reached the refresh returns `401` and the user must authenticate again (password, passkey or social login). Not applicable: `pk_` API keys never expire and have no session. Refresh is only for JWT sessions of human users. ## Access level Your credential (session JWT or API key) operates **your own account**: balances, payouts, payins, transfers, crypto, KYC/KYB and your own webhooks. If an endpoint responds `403 account_required` or `403 org_admin_required`, that operation belongs to a different credential level — contact the CBPay team. ## Your account profile ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Read the profile (includes kyc_status and type) curl https://api.qbank.cl/platform/v1/me \ -H "Authorization: Bearer " # Update profile fields (all optional) curl -X PATCH https://api.qbank.cl/platform/v1/me \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "display_name": "Comercial Andina SpA", "tax_id": "76.543.210-8", "phone": "+56 9 1234 5678", "country": "CL" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "type": "company", "display_name": "Comercial Andina SpA", "email": "legal@andina.cl", "tax_id": "76.543.210-8", "phone": "+56 9 1234 5678", "country": "CL", "status": "active", "kyc_status": "approved", "created_at": "2026-06-01T12:00:00Z" } ``` `PATCH /v1/me` accepts `display_name`, `tax_id`, `phone` and `country` (send only the ones that change). `email`, `status` and `kyc_status` are not self-managed: the administrator resolves them. ## Company members **Company** accounts can have multiple users with their own login and different permission levels: | Role | Permissions | | ---------- | ----------------------------------------------------- | | `owner` | Everything: operates, manages members and credentials | | `operator` | Day-to-day operation (default when creating a member) | | `viewer` | Read-only | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Add a member (company accounts only) curl -X POST https://api.qbank.cl/platform/v1/members \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "email": "finance@andina.cl", "password": "a-strong-password", "role": "viewer" }' # List members curl "https://api.qbank.cl/platform/v1/members?page_size=50" \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "page": 1, "page_size": 50, "members": [ { "id": "…", "email": "legal@andina.cl", "role": "owner", "status": "active" }, { "id": "…", "email": "finance@andina.cl", "role": "viewer", "status": "active" } ] } ``` On a person account, `POST /v1/members` responds `403 company_only`. ## Best practices * Store API keys in a secrets manager; never in code or in the browser. * Use one key per environment/service (descriptive `label`) so you can rotate without downtime. Issue the new key with `POST /v1/api-keys` (new label). Update your service to use the new key. Ask the CBPay team to revoke the previous key once traffic migrated. * JWT sessions are for front-ends; automated processes should always use API keys. # Fees Source: https://docs.cbpayapp.com/en/concepts/fees How each service is charged and where to check your terms Fees are configured by CBPay per **service, country and asset**. When nothing is configured for a combination, the fee is **0**. ## How payouts and payins are charged FX pricing lives in **your exchange rate**: the rates you see in `GET /v1/rates` are your rates, and they are exactly the ones used at execution — no separate percentages. Each country carries both sides: * `rate` — the rate for your **payouts** (dispersals). If you disperse the equivalent of 100 USDT, you are debited **100 USDT plus the fixed fee** (when configured for your account). * `payin_rate` — the rate for your **payins** (fiat collections/deposits). The credit is the local amount converted at that rate, **minus the fixed fee** (when configured for your account). ``` payout: usdt_amount = local_amount / rate total_debit = usdt_amount + fixed_amount payin: usdt_gross = local_amount / payin_rate usdt_credited = usdt_gross − fixed_amount ``` What the beneficiary receives (payout) or what you are credited (payin) depends on your account's rates for that country. Quoted = charged, always. ## Services with fixed or percent fees | Service | How it is charged | When | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `payout` | Fixed per operation (FX pricing is already in your rate) | At payout creation (included in `total_debit`) | | `payin` | Fixed per operation (FX pricing is already in your `payin_rate`) | On credit (you receive `usdt_gross − fee`) | | `payin_card` | `%` over the collection + fixed, with its own `%` per processed currency (e.g. BOB vs USD). If your account has none configured, the generic `payin` fee applies. Can carry a settlement delay (see [Card payin settlement delay](#card-payin-settlement-delay)) | When a card-paid collection is credited (direct `method: "card"` payin, checkout link paid with card, or subscription charge); with a delay configured, when the scheduled time is reached | | `funding` | `%` over the deposit + fixed | When the on-chain deposit is credited | | `withdrawal` | `%` over the withdrawal + fixed | At creation (included in `total_debit`) | | `wallet_creation` | Fixed per wallet | On every wallet creation (persons: 1 per network; companies: unlimited). Fetching existing wallets is always free | | `wallet_import` | Fixed per import | When importing an external wallet into a [segregated wallet](/en/guides/segregated-wallets) (`POST /v1/segregated-wallets/import`) | | `wallet_export` | Fixed per export | When exporting a segregated wallet's private key (`POST /v1/segregated-wallets/{id}/export`) | | `wallet_send` | Fixed per send | When sending on-chain from a segregated wallet (`POST /v1/segregated-wallets/{id}/sends`); network gas is on the client | | `compliance_person` | Fixed per call | When AML-screening a person (`POST /v1/aml/screenings`) | | `compliance_company` | Fixed per call | When AML-screening a company | | `compliance_rescreen` | Fixed per call | When re-running an AML screening | | `compliance_monitoring` | Fixed per activation | When enabling continuous AML monitoring (disabling is free) | | `kyc_verification` | Fixed per verification | When creating a third-party KYC link or submission ([verification](/en/guides/kyc)); your own onboarding is free | | `kyb_verification` | Fixed per verification | When creating a third-party KYB link or submission | | `address_screening` | Fixed per scan | When assessing a blockchain address' risk ([wallet screening](/en/guides/screenings)); the automatic withdrawal/deposit protection is free | | `banking_customer` | Fixed per profile | When creating your banking profile ([banking](/en/guides/banking)) | | `banking_account` | Fixed per account | When opening each bank account | | `banking_deposit` | `%` over the deposit + fixed, in the operation currency, capped at the deposit amount | When an incoming bank deposit is credited (see [Banking rail fees](#banking-rail-fees)) | | `banking_transfer_ach` | `%` + fixed over the amount, in the operation currency | When an ACH transfer is dispatched | | `banking_transfer_swift` | `%` + fixed over the amount, in the operation currency | When a SWIFT transfer is dispatched | | `banking_transfer_wire` | `%` + fixed over the amount, in the operation currency | When a wire (Fedwire) transfer is dispatched | | `banking_transfer_sepa` | `%` + fixed over the amount, in the operation currency | When a SEPA transfer is dispatched | | `banking_operation` | Fixed per payment — legacy fallback, charged only when the rail has no specific configuration | When sending each bank payment (quoting with `prepare` is free) | | `card_creation_virtual` | Fixed per card | When issuing a virtual card ([cards](/en/guides/cards)) | | `card_creation_physical` | Fixed per card | When issuing a physical card | | `card_monthly` | Fixed monthly | Monthly fee per active card (with no balance the card is frozen — no debt) | | `card_cancellation` | Fixed per card | When cancelling a card | | `card_purchase_virtual` | `%` + fixed over the USD purchase amount | Per purchase with a virtual card (estimated at authorization, final at settlement; reversals refund it proportionally) | | `card_purchase_physical` | `%` + fixed over the USD purchase amount | Per purchase with a physical card (same cycle as virtual) | | `risk_report_person` | Fixed per report | When buying a Qscore credit report on a person ([Qscore](/en/guides/qscore)); refunded automatically if the report fails (`risk_report_refund`) | | `risk_report_company` | Fixed per report | When buying a Qscore credit report on a company | For `%`-based services the formula is `fee = ceil(amount × percent / 100) + fixed_amount` (rounded up to the micro-USDT). Standalone fixed charges (compliance, KYC/KYB verification, wallet creation and banking) are refunded automatically if the upstream operation fails (`compliance_refund` / `verification_fee_refund` / `wallet_creation_refund` / `wallet_service_refund` / `banking_fee_refund`). ## Card payin settlement delay Card collections can carry a **settlement delay** (`settlement_hours`, an integer number of hours; `0` = no delay — the default). With a delay configured, a confirmed card payment **confirms the payin right away**: the status becomes `credited`, the `payin_credited` webhook fires immediately, and a checkout link paid with card closes as paid. What waits is the **balance**: it lands in your ledger when the settlement runs — at `settle_at` (a worker settles due payins every minute) or earlier if your org admin releases it manually from the panel. While the balance is pending, the payin response (create, get and list) carries `settle_at` (RFC 3339) and `settlement_pending: true`; once the balance lands it carries `settled_at` instead. At confirmation the `payin_settlement_scheduled` webhook also fires exactly once (idempotent) with `status: "credited"`, the scheduled amounts and `settle_at`, so your integration can tell "paid, balance scheduled" apart from "paid, balance already available". ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "id": "pay_…", "status": "credited", "settle_at": "2026-08-10T15:04:05Z", "settlement_pending": true } ``` `settle_at = created_at + settlement_hours` — the delay counts from the payin creation (≈ when the charge is confirmed at the processor). A payin whose deadline has already passed by the time it is approved or assigned settles immediately (no `settlement_pending`). The automatic conversion (when configured) runs when the balance settles, not before. A payin whose settlement is still pending **cannot be refunded**: the refund debits your balance and those funds are not released yet, so the request is declined with `422 settlement_pending`. Wait for `settle_at` or ask your org admin to release the settlement first — then the refund works as usual. The settlement delay is configured by CBPay on the `payin_card` fee of your account. Your confirmation signals are unchanged — `payin_settlement_scheduled` and `payin_credited` arrive at payment time; only the balance availability waits for `settle_at`. ## Banking rail fees Banking operations carry **transactional fees in the operation currency** (the `BANK_USD` / `BANK_EUR` balance the operation moves): * **Deposits** (`banking_deposit`): charged when the incoming deposit is credited, capped at the deposit amount (`min(fee, amount)`) — a small deposit never leaves a negative balance. * **Transfers** (`banking_transfer_ach`, `banking_transfer_swift`, `banking_transfer_wire`, `banking_transfer_sepa`): charged **at dispatch**; your available balance must cover `amount + fee` in the operation currency or the request is declined with `402 insufficient_funds`. If the transfer is definitively rejected afterwards, the fee is refunded. * **Fallback**: a rail without its own configuration (neither on your account nor as a default) uses the legacy `banking_operation` fixed fee in USDT. A rail configured with `0%` + `0` fixed is **explicitly free** — it does not fall back. When a rail fee applies, the dispatch response exposes `banking_fee` and `banking_fee_asset` (the charged amount and its `BANK_*` currency), so each charge is attributable to its rail. ## Internal transfers: always free Transfers between CBPay accounts (`POST /v1/transfers`) carry **no fee**, regardless of the combination: person↔person, person↔company or company↔company. The money moves inside the ecosystem. ## Your exchange rate `GET /v1/rates` returns **your account's own exchange rate** for each country — the same rates your operations execute at, no surprises: `rate` for payouts and `payin_rate` for payins (`local_amount / rate = USDT`). ## Check your terms `GET /v1/rates` returns, along with your rates, the fee configuration currently applied to your account: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "base": "USD", "rates": { "chile": { "currency": "CLP", "rate": "950.25", "payin_rate": "955.10" } }, "asset_prices": { "USDT": { "currency": "USD", "unit": "usdt", "price": "1" }, "USDC": { "currency": "USD", "unit": "usdc", "price": "1" }, "BTC": { "currency": "USD", "unit": "btc", "price": "109853.24" }, "GOLD": { "currency": "USD", "unit": "gram", "price": "107.5341" } }, "fees": [ { "service": "payout", "country": "CL", "asset": "USDT", "percent": "0", "fixed_amount": "0.30" } ] } ``` `asset_prices` is the **reference** USD price of each virtual balance (to value them on screen) — it implies no conversion and no spread. The response also includes a `settlement` block with the **effective price** per asset if you pay operations from a balance other than USDT ([money model](/en/concepts/money-model#choose-which-balance-pays)): that price already includes the conversion margin, so what you see is what applies. The charged fee is always explicit in each operation's response (`fee` field) and in the ledger. ## Full example A payout equivalent to 100 USDT with `fixed_amount: "0.30"`: ``` usdt_amount = 100 USDT (local_amount / rate) fee = 0.30 USDT (fixed) total_debit = 100.30 USDT ``` The beneficiary receives the full local amount you specified; you are debited the equivalent at your rate plus the fixed fee. A payin equivalent to 100 USDT with `fixed_amount: "0.30"`: ``` usdt_gross = 100 USDT (local_amount / payin_rate) fee = 0.30 USDT (fixed) usdt_credited = 99.70 USDT ``` The payer pays the exact local amount you specified; you are credited the equivalent at your `payin_rate` minus the fixed fee. # Idempotency Source: https://docs.cbpayapp.com/en/concepts/idempotency Retry safely without duplicating operations Every money-moving operation requires an **idempotency key**. The complete table: | Endpoint | Key | Why | | ----------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------- | | `POST /v1/payouts` | **Required** | Debits your balance and disperses | | `POST /v1/payouts/qr/confirm` | **Required** | Debits and pays the QR (the `scan` is free and does not need one) | | `POST /v1/payins/collect` | **Required** | Executes a real debit against the payer | | `POST /v1/transfers` | **Required** | Moves balance between accounts | | `POST /v1/crypto/withdrawals` | **Required** | Debits and broadcasts on-chain | | `POST /v1/banking/operations` | **Required** | Sends a bank payment (the `prepare` is free and does not need one) | | `POST /v1/cards` | **Required** | May charge the issuance fee | | `POST /v1/payins` with `method: "fintoc"` | Optional, **recommended** | A retry with the same key returns the same payment session (never opens a second one) | | `POST /v1/payins` (qr / bank\_transfer) | Not applicable | The charge moves no money until someone pays; an unpaid duplicate simply expires | ## How to send it Two equivalent ways (if you send both, the body wins): ```bash Body theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST …/v1/payouts \ -d '{ "idempotency_key": "payroll-2026-07-001", … }' ``` ```bash Header theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST …/v1/payouts \ -H "Idempotency-Key: payroll-2026-07-001" \ -d '{ … }' ``` Omitting it returns `400 idempotency_key_required`. ## What happens on retry The key is unique per **source account**. If you repeat a call with the same key: * No new operation is created and no money moves again. * You receive `200 OK` (instead of `201`/`202`) with the original object plus `idempotency_hit: true`. ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payout_id": "same-as-the-first-time", "status": "processing", "idempotency_hit": true } ``` ## Which key do I retry with? The full decision rule, so you never have to guess: ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR call["You call the API"] --> result{"What did you get?"} result -->|"2xx"| ok["Done — store the ID"] result -->|"Timeout / network error / 5xx"| same["Retry with the
SAME key"] same --> replay["200 idempotency_hit: true
if it was already created"] result -->|"Validation 4xx"| fix["Fix the request"] fix --> newKey["Use a NEW key
(it is a new operation)"] result -->|"422 with status failed"| decide{"Do you want
to try again?"} decide -->|"Yes"| newKey ``` ## Recommendations * Use an identifier from **your** system (order ID, payroll ID, etc.), not a timestamp or a UUID regenerated on every retry. * Persist the key before calling the API; that way you can retry after a timeout with a no-duplication guarantee. * On network errors or `5xx`, **retry with the same key**. On validation `4xx`, fix the request and use a new key. # Money model Source: https://docs.cbpayapp.com/en/concepts/money-model Per-currency virtual balances, FX conversion, holds and the immutable ledger ## Four independent virtual balances Every account holds **four virtual balances, one per currency**. They are fully independent: they never mix and are never converted automatically. | Currency | What it is | Decimals | How it is funded | | -------- | -------------------------------------------- | ------------ | --------------------------------------------------------- | | `USDT` | USD stablecoin — **the operating currency** | 6 | Fiat payins, on-chain deposits (TRON/Ethereum), transfers | | `USDC` | USD stablecoin | 6 | On-chain deposits (Ethereum), transfers | | `BTC` | Bitcoin | 8 (satoshis) | Operator credits and internal transfers | | `GOLD` | **Grams of fine gold** backed by a custodian | 6 | Operator credits and internal transfers | `GET /v1/balances` always returns all four (zeros if you have not used that currency yet), as **decimal strings**: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "…", "balances": [ { "asset": "USDT", "available": "125.430000", "held": "10.000000" }, { "asset": "USDC", "available": "50.000000", "held": "0.000000" }, { "asset": "BTC", "available": "0.00060000", "held": "0.00000000" }, { "asset": "GOLD", "available": "12.500000", "held": "0.000000" } ] } ``` Internally each amount is stored as an integer in its currency's minimal unit (micro-USDT, satoshis, micro-grams) and computed with exact rational arithmetic. There are no floats and no accumulated rounding errors. **USDT is the operating currency**: payouts, fiat payins and service fees are always priced in USDT. But the **payment** can come from any of the four balances — see [Choose which balance pays](#choose-which-balance-pays). Payins credit in USDT and, if you configure `default_payin_asset`, the net amount auto-converts into the balance you choose — see [Choose which balance receives your payins](#choose-which-balance-receives-your-payins). The other balances are also funded via [internal transfers](/en/guides/transfers) (always between balances of the same currency), on-chain deposits (USDC and BTC) or credits from your operator (GOLD). ## Choose which balance pays **Payouts** and **service fees** (KYC, wallet creation, banking) can be debited from any of your four balances. The pricing pipeline does not change: the operation is quoted in USDT as always, and at the end the total translates to the chosen asset at the **effective settlement price** of the moment. * **Per-account default**: `PUT /v1/settlement` with `{"default_settlement_asset": "BTC"}`. From then on, every payout and service fee comes out of the BTC balance (if it covers the amount; there are no cascades into other balances). * **Per-operation override**: send `settlement_asset` in `POST /v1/payouts` (or in the QR confirm) to pay that single operation from another balance without touching the default. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Set BTC as the default paying balance curl -X PUT "https://api.qbank.cl/platform/v1/settlement" \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"default_settlement_asset": "BTC"}' ``` Multi-asset settlement rules: | Rule | Detail | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Execution price | BTC and GOLD use an on-chain execution feed (not the reference price). If the feed is stale or unavailable, the operation returns `503 pricing_unavailable` — it never executes on a doubtful price. | | Debit, hold and refund | All three live in the chosen asset. If the payout fails, the **exact** `settlement_amount` is refunded — never re-quoted. | | Idempotency | Replaying with the same key returns the original amount; the price is never recomputed. | | Per-operation limit | Volatile assets (BTC/GOLD) have a per-operation limit (USDT equivalent, visible in `GET /v1/settlement`); exceeding it returns `422 settlement_limit_exceeded`. | | Per-account daily limit | Volatile assets also have a rolling 24h volume cap (`volatile_daily_limit_usdt` in `GET /v1/settlement`); exceeding it returns `422 settlement_daily_limit_exceeded`. Settle in USDT/USDC or retry later. | | USDT | Remains the default path and changes nothing for anyone who never touches this setting. | The `settlement` block of `GET /v1/rates` shows the effective per-asset price (spread included) so you can estimate before operating, and the payout response records `settlement_asset`, `settlement_amount` and `settlement_rate` for auditability. ## Choose which balance receives your payins By default **payins** (QR, bank transfer, collect, card) credit the USDT balance. If you would rather hold another asset, configure `default_payin_asset`: the credit still lands in USDT (pricing, FX spread and fees untouched) and the **net credited amount** auto-converts into your asset through the swap engine **at the real price, with no extra spread** — the payin already paid its fee and rate; the automatic conversion never charges twice. The same limits as a regular swap apply. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Credit my payins in USDC curl -X PUT "https://api.qbank.cl/platform/v1/settlement" \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"default_payin_asset": "USDC"}' ``` | Rule | Detail | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Post-credit conversion | The payin credits in USDT and the conversion runs right after, as a swap (you will see `swap_out`/`swap_in` in your statement). | | Price and limits | The conversion executes **at the real price, with no swap spread** (no double cost: the payin already paid its fee and rate). The per-operation/24h limits of volatile assets (BTC/GOLD) apply. | | If the conversion fails | The payin stays credited in USDT with `conversion_status: pending_retry` and the system retries automatically — funds are never lost or double-converted. | | Checkout and POS | Each link keeps the `settlement_asset` chosen at creation; this setting never re-converts them. A link created **without** `settlement_asset` uses your `default_payin_asset`. | | Surfaces | `GET /v1/payins`, the detail and the `payin_credited` webhook expose `settlement_asset` and `conversion_status` when a conversion applies. | ## `available` and `held` Each balance has its own two counters: | Field | Meaning | | ----------- | ------------------------------------------------------------------ | | `available` | Balance available to operate | | `held` | Reserved by in-flight operations (pending payouts and withdrawals) | When you create a payout or withdrawal, the debit (`amount + fee`) leaves `available` and sits in `held` until the operation reaches a final state: * **`completed`** → the hold is consumed; the money left. * **`failed`** → the full debit (amount + fee) is refunded to `available`. ## FX conversion (fiat ↔ USDT) Fiat operations convert to USDT at **your account's rates** at execution time (the same ones returned by `GET /v1/rates`, USD base): `rate` for payouts and `payin_rate` for payins. Conversion rounds **up** on debits and **down** on credits, with at most 1 micro-USDT of difference. Example — a 50,000 CLP payout at a 950.25 rate: ``` usdt_amount = ceil(50000 / 950.25 × 10^6) / 10^6 = 52.618258 USDT total_debit = usdt_amount + fee ``` Example — a 50,000 CLP payin at a 955.10 `payin_rate`: ``` usdt_gross = floor(50000 / 955.10 × 10^6) / 10^6 = 52.350539 USDT usdt_credited = usdt_gross − fee ``` The rate used is recorded on the object (`fx_rate`) for auditability. ## Reference and settlement prices `GET /v1/rates` includes an `asset_prices` block with the **USD reference price** of each currency (BTC per unit, GOLD per gram; USDT and USDC are 1 by convention) to value your balances on screen, plus a `settlement` block with the **effective price** your balance would be valued at if you pay an operation from that asset (spread included): ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "asset_prices": { "USDT": { "currency": "USD", "unit": "usdt", "price": "1" }, "USDC": { "currency": "USD", "unit": "usdc", "price": "1" }, "BTC": { "currency": "USD", "unit": "btc", "price": "109853.24", "updated_at": "2026-07-07T11:59:41Z", "settlement_grade": true }, "GOLD": { "currency": "USD", "unit": "gram", "price": "107.5341", "updated_at": "2026-07-07T09:12:05Z", "settlement_grade": true } }, "settlement": { "default_asset": "USDT", "assets": [ { "asset": "USDT", "available": true, "settlement_rate": "1" }, { "asset": "USDC", "available": true, "settlement_rate": "0.99900000" }, { "asset": "BTC", "available": true, "settlement_rate": "109029.34070000" }, { "asset": "GOLD", "available": true, "settlement_rate": "106.99642950" } ] } } ``` `settlement_grade: true` means the price is fresh enough to execute operations; if it drops to `false`, payments from that asset answer `503 pricing_unavailable` until the price recovers. ## Immutable ledger Every movement produces an immutable entry with the resulting balance (`balance_after`) **in the movement's currency**. Your full history lives at `GET /v1/movements` (filter by currency with `?asset=`): | `type` | What it represents | | ------------------------------------------------ | ----------------------------------------------------------------- | | `payin_credit` | Credit from a fiat collection | | `payout_debit` / `payout_refund` | Payout debit / refund on failure | | `transfer_in` / `transfer_out` | Internal transfer received / sent | | `funding` | On-chain deposit credited (USDT or USDC, each on its own balance) | | `withdrawal_debit` / `withdrawal_refund` | On-chain withdrawal / refund on failure | | `compliance_fee` / `compliance_refund` | KYC/KYB service charge / refund | | `wallet_creation_fee` / `wallet_creation_refund` | Wallet creation charge / refund | | `adjustment` | Manual adjustment by CBPay (audited) | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/movements?type=payout_debit&from=2026-07-01&to=2026-07-07&page_size=20" \ -H "Authorization: Bearer " # Only the GOLD balance movements curl "https://api.qbank.cl/platform/v1/movements?asset=GOLD&from=2026-07-01&to=2026-07-07" \ -H "Authorization: Bearer " ``` Every list endpoint (`/v1/movements`, `/v1/payouts`, `/v1/payins`, `/v1/crypto/transactions`, `/v1/banking/operations`) accepts pagination (`page`, `page_size` up to 200) and `from`/`to` date filters (YYYY-MM-DD, organization timezone, inclusive). ## Operation states Payouts and crypto withdrawals follow the same lifecycle: ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR pending --> processing processing --> completed processing --> failed pending --> failed ``` Final states (`completed`/`failed`) arrive via [webhook](/en/webhooks); no polling required. # Movements and reconciliation Source: https://docs.cbpayapp.com/en/concepts/movements-reconciliation The immutable ledger (GET /v1/movements), every entry type and how to reconcile against the statement and webhooks Every time your balance changes, CBPay writes an **immutable entry** in the ledger with the resulting balance. `GET /v1/movements` is your source of truth for reconciliation: nothing moves money without leaving an entry. ## Querying movements ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/movements?from=2026-07-01&to=2026-07-08&page_size=100" \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "page": 1, "page_size": 100, "movements": [ { "id": "a3f1…", "asset": "USDT", "amount": "-101.602460", "type": "payout_debit", "reference_type": "payout", "reference_id": "8e2a…", "description": "Payout 700.00 BOB", "balance_after": "3898.397540", "created_at": "2026-07-07T15:22:10Z" } ] } ``` Filters: `from`/`to` (`YYYY-MM-DD`, organization timezone), `type`, `asset`, `page`, `page_size` (max 200). Every entry carries `reference_type` + `reference_id`: the business resource that originated it. ### Export to CSV / Excel Add `format=csv` or `format=xlsx` to download the same rows as an accounting-ready file (up to 10,000 rows per download). Also available on the `payouts`, `payins` and `transfers` listings: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -o movements.xlsx "https://api.qbank.cl/platform/v1/movements?from=2026-07-01&to=2026-07-13&format=xlsx" \ -H "Authorization: Bearer " ``` The payouts export (`GET /v1/payouts?format=csv`) includes a `bank_reference` column — right after `status_code` — with the transaction id assigned by the destination bank, populated once the payout is `completed`. ## Complete type catalog | `type` | Sign | Origin (`reference_type`) | | ------------------------------------------------ | ----- | ------------------------------------------------------ | | `payin_credit` | + | Fiat collection credited (`payin`) | | `payout_debit` / `payout_refund` | − / + | Payout created / refund on failure (`payout`) | | `transfer_in` / `transfer_out` | + / − | Internal transfer received / sent (`transfer`) | | `funding` | + | On-chain USDT deposit credited (`deposit`) | | `withdrawal_debit` / `withdrawal_refund` | − / + | On-chain withdrawal / refund on failure (`withdrawal`) | | `card_debit` / `card_refund` | − / + | Card purchase / reversal (`card_transaction`) | | `card_fee` / `card_fee_refund` | − / + | Card fee (issuance, monthly, cancellation) | | `compliance_fee` / `compliance_refund` | − / + | KYC screening charge / refund on failure | | `wallet_creation_fee` / `wallet_creation_refund` | − / + | Wallet creation fee | | `banking_fee` / `banking_fee_refund` | − / + | Banking operation fee | | `adjustment` | ± | Audited manual adjustment by the administrator | Banking balances live in your bank accounts (not in the USDT ledger): only their **fees** appear here. Transactional fees for payouts/payins/ withdrawals have no entry of their own — they travel inside their operation's amount (`total_debit`, `usdt_credited`). ## Reconciliation in three layers Your integration has three views of the same money. They map like this: | Layer | What it is | Join key | | ------------- | -------------------------------------------------------- | ---------------------------------------------------------- | | **Webhooks** | Push notification of each event | `payout_id` / `payin_id` / `transfer_id` / `withdrawal_id` | | **Movements** | Immutable accounting entry with `balance_after` | `reference_id` = the same resource id | | **Statement** | Period snapshot (JSON/PDF/Excel) with guaranteed balance | Per-product sections with the same ids | ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR evento["Webhook
payin_credited (payin_id)"] --> negocio["Business resource
GET /v1/payins/{id}"] negocio --> asiento["Movement
type=payin_credit
reference_id=payin_id"] asiento --> cartola["Period statement
payins section"] cartola --> cuadre["Balance check:
opening + credits − debits = closing"] ``` ### Daily reconciliation recipe `GET /v1/movements?from=YESTERDAY&to=YESTERDAY` paginating to the end. Your `idempotency_key` derived from your internal id lets you join each of your operations with its CBPay `reference_id`. Sort by date: each entry's `balance_after` must be the previous one ± `amount`. Any gap means you are missing an entry (not a ledger error — it is immutable). The [statement](/en/guides/statement) guarantees the accounting identity `opening + credits − debits = closing` and serves as your formal backup (PDF/Excel). ## Movements vs statement: when to use which? * **`GET /v1/movements`** — programmatic, paginated, live: for your automatic reconciliation and your history UI. * **Statement** — period snapshot with totals, per-product/country/currency breakdowns and guaranteed balance: for accounting closes, audits and sharing with your finance team. Both read the same ledger: they will never disagree. # Persons and companies Source: https://docs.cbpayapp.com/en/concepts/persons-companies The differences between the two account types, product by product, on a single page CBPay has two account types — **person** (`type: "person"`) and **company** (`type: "company"`) — that use **the same API** with the same endpoints. This page gathers ALL the differences in one place, so you never have to guess which one applies. The type is set at account creation and does not change. You see it in `GET /v1/me` → `type`. ## Complete differences table | Capability | Person | Company | | ------------------------------------------------------------------------------ | ------------------------------------------------------------------- | --------------------------------------------------------------------------- | | USDT balance, payouts, payins, transfers, banking, statement | Same | Same | | **Deposit wallets** ([crypto](/en/guides/crypto)) per network+asset | **1** (born with the account; receive only) | **1** (born with the account; receive only) | | **Segregated wallets** ([own on-chain balance](/en/guides/segregated-wallets)) | **1 per network+asset pair** | **Unlimited** (use `label` to tell them apart) | | **Cards** | **1 virtual + 1 physical**, for itself only | **Unlimited**, for the company or for **designated persons** (employees) | | **Members with login** (`POST /v1/members`) | No (`403 company_only`) | Yes — `owner` / `operator` / `viewer` roles | | **Identity verification** (`/v1/me/verification`) | **KYC** onboarding (wizard with documents + liveness) | **KYB** onboarding (wizard with corporate documents) | | **Verify third parties** (`/v1/{kyc,kyb}/links` and submissions) | No (`403 company_account_required`) | Yes — hosted links or API data, bills `kyc_verification`/`kyb_verification` | | **AML screening** (`POST /v1/aml/screenings`) | **Person** screening (`customer.person`), bills `compliance_person` | **Company** screening (`customer.company`), bills `compliance_company` | | Card holder (first issuance) | Personal data + identity documents | Corporate data + corporate documents (or the designated person's) | | Registration | `type: "person"` | `type: "company"` (+ `tax_id` recommended) | Everything else — authentication, idempotency, webhooks, statuses, errors, per-card spending limits, enabled services — works identically. ## What it looks like in practice * Registration: `POST /v1/auth/register` with `type: "person"` (or your operator creates it). * Verification: request your KYC link with `POST /v1/me/verification/link` and complete the wizard — until approved you can only fund ([guide](/en/guides/kyc)). * Crypto: your **deposit wallets are born with the account** (one per network+asset pair; receive only). Need a wallet with its own balance? You can hold **1 segregated wallet per network+asset pair**. * Cards: up to **1 virtual + 1 physical**; the first issuance carries your data and documents — [guide](/en/guides/cards). * No members: your login and your API keys operate the account. * Registration: `type: "company"`, ideally with `tax_id`. * Verification: request your KYB link with `POST /v1/me/verification/link` and complete the wizard with the corporate data; once approved you can also verify your own customers ([guide](/en/guides/kyc)). * Crypto: your **deposit wallets are born with the account** (one per network+asset pair; receive only). For separate on-chain balances create **unlimited segregated wallets** (one per branch, per product, per vendor…), with a descriptive `label`. * Cards: **unlimited** — corporate (holder = the company, with corporate documents on the first one) or for **employees** (designated person with their data on every designation) — [guide](/en/guides/cards). * Members: add users with their own login and permissions (`owner`/`operator`/`viewer`) — [guide](/en/authentication#company-members). ## Errors that reveal the account type | `error` | What it means | | -------------------------- | ------------------------------------------------------------------------------------------- | | `403 company_only` | You tried a company feature (members) from a person account | | `422 wallet_limit_reached` | The account already holds its wallet for that pair (deposit: everyone; segregated: persons) | | `409 card_limit_reached` | A person tried their second card of the same type | Did your operation outgrow a person account? The account type cannot be changed through the API: ask your CBPay administrator to create the company account and migrate the balance with an internal transfer (free and instant). # Enabled services Source: https://docs.cbpayapp.com/en/concepts/services Which products your account has enabled and how to react to service_disabled Every account has a **set of enabled services** according to its commercial agreement with CBPay. Before showing a product in your UI (or trying to use it), query the effective map: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/services \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "services": { "payouts": true, "payins": true, "transfers": true, "crypto": true, "banking": false, "kyc": true, "cards": false } } ``` ## Service catalog | Service | What it enables | | ----------- | ---------------------------------------------------------------------------------------------------- | | `payouts` | Fiat dispersals (`POST /v1/payouts`, QR scan/confirm) | | `payins` | Fiat collections (QR, transfer, payment page, collect, CLABE) | | `transfers` | Internal transfers between CBPay accounts | | `crypto` | On-chain wallets and USDT withdrawals | | `banking` | International bank accounts (profile, accounts, payments) | | `kyc` | Third-party KYC/KYB identity verification (links, submissions, documents, liveness) | | `aml` | AML list screening, rescreening and monitoring | | `cards` | Card issuing and operation | | `swaps` | Conversion between balances (USDT/USDC/BTC/GOLD) | | `wallets` | [Segregated wallets](/en/guides/segregated-wallets) with their own on-chain balance (companies only) | ## What happens when a service is off The product's **actions** respond `403 service_disabled`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "error": "service_disabled", "message": "this service is not enabled for your account" } ``` Important rules: * **Reads are never blocked**: you can always list and query your historical operations, balances and movements. * **In-flight money finishes its cycle**: a `processing` payout completes (or refunds) even if the service is disabled afterwards. * With `cards` off, card purchases stop authorizing instantly and no monthly fees are generated. ## Recommended integration pattern ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR inicio["On login /
dashboard load"] --> get["GET /v1/services"] get --> ui{"services.X"} ui -->|"true"| muestra["Show the product"] ui -->|"false"| oculta["Hide or disable
the product"] muestra --> accion["The user operates"] accion --> err{"403 service_disabled?"} err -->|"yes"| refresca["Refresh GET /v1/services
and update the UI"] ``` 1. Query `GET /v1/services` when your app loads (and cache for a few minutes). 2. Show only the products set to `true`. 3. Still handle `403 service_disabled` on any action: configuration can change between your cache and the operation. Services are enabled by your organization according to the commercial agreement. If you need a product activated (for example `banking` or `cards`), contact your CBPay administrator — the change is immediate, no redeploy needed. # Statuses and lifecycle Source: https://docs.cbpayapp.com/en/concepts/statuses Every status of every product, which ones are final and what to do in each Every CBPay operation follows an explicit lifecycle. This page gathers **all statuses of all products** in one place, with the golden rule: never assume success until you see a **final state**. ## Unified table | Product | Statuses | Final | Webhook event | | ----------------- | -------------------------------------------------------------- | ------------------------------- | ------------------------------------- | | Payout | `pending` → `processing` → `completed` / `failed` | `completed`, `failed` | `payout_status_changed` | | Payin | `pending` → `credited` / `expired` / `failed` (+ `unassigned`) | `credited`, `expired`, `failed` | `payin_credited` | | Transfer | `completed` (synchronous) | `completed` | `transfer_received` (to the receiver) | | Crypto deposit | detection → `credited` on network confirmation | `credited` | `crypto_deposit_credited` | | Crypto withdrawal | `pending` → `processing` → `completed` / `failed` | `completed`, `failed` | `crypto_withdrawal_status_changed` | | Banking (payment) | per rail: `pending` → `processing` → `completed` / `failed` | `completed`, `failed` | `banking_operation_status_changed` | | Card | `pending_activation` → `active` ⇄ `frozen` → `cancelled` | `cancelled` | `card_status_changed` | | Account KYC | `none` → `pending` → `approved` / `rejected` | `approved`, `rejected` | — (query `GET /v1/me`) | ## Payouts: the cycle with money on hold ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} stateDiagram-v2 [*] --> pending: POST /v1/payouts
(debit to held) pending --> processing: the corridor accepts processing --> completed: paid to the beneficiary
(hold consumed) processing --> failed: corridor rejection
(FULL refund to available) pending --> failed: immediate rejection
(FULL refund) completed --> [*] failed --> [*] ``` * The full debit (`total_debit`) leaves `available` and sits in `held` while the operation is in flight. * `failed` **always refunds the full debit** (amount + fee) to `available`, automatically. * A `processing` payout cannot be cancelled through the API: wait for the final state (webhook or `GET /v1/payouts/{id}`). ### `status_code` catalog on failed payouts When a payout fails, `status_code` and `status_message` explain the cause in neutral terms: | `status_code` | Meaning | What to do | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | `core_rejected` | The processor rejected the operation at creation (invalid beneficiary data, non-existent destination account, corridor unavailable) | Read `status_message`, fix the data and create a new payout (new key) | | *corridor code* | Later rejection by the banking rail (e.g. closed account) | Same: fix and retry as a new operation | | *(empty)* with `failed` | Generic failure reported by the corridor | Check `status_message`; if unclear, contact support with the `payout_id` | The refund already happened in every case: verify it in `GET /v1/movements` (a `payout_refund` entry). ## Payins: collection statuses * `pending` — the charge exists and awaits payment. QRs and payment pages expire (`expired` if nobody pays). * `credited` — payment received, converted at your `payin_rate` and credited. With a card settlement delay configured, the payin reaches `credited` at payment confirmation but the **balance** lands later: while `settle_at` is in the future the response carries `settlement_pending: true` and `settled_at: null` (see [Card payin settlement delay](/en/concepts/fees#card-payin-settlement-delay)). * `unassigned` — a deposit arrived that could not be matched to any account; the administrator routes it manually and it is then credited with the destination account's rate and fees. * `failed` — the collection failed (e.g. a collect declined by the payer). No money moved. ## Crypto withdrawals: on-chain confirmation A withdrawal reaches `completed` when the transaction confirms on the network. Typical times: **TRON \~1 minute** (19 confirmations), **Ethereum a few minutes** depending on congestion. The `tx_id` comes in the response and the webhook so you can verify it on the explorer. If the withdrawal fails before broadcasting, the full debit is refunded (a `withdrawal_refund` entry). ## Cards * `pending_activation` — a physical card was issued and ships inactive; it activates with `POST /v1/cards/{id}/activate`. * `active` — authorizes purchases in real time against the card's spending asset balance (`spending_asset`: USDT, USDC, BTC or GOLD). * `frozen` — frozen (manually or for an unpaid monthly fee); purchases are declined with `unfunded_card_frozen`. It unfreezes by settling the pending charge. * `cancelled` — final; cannot be reverted. ## Cross-cutting rules Final state via webhook **or** via the resource's `GET` — both are equivalent sources of truth. The webhook is push (recommended); the `GET` is your fallback if a webhook is lost. Do NOT retry with a new key. Repeat the same request with the **same** `idempotency_key` (it returns the original with `idempotency_hit: true`) or query the resource listing. Details in [idempotency](/en/concepts/idempotency). No. Lifecycles are monotonic: `completed` and `failed` are definitive, and an operation never returns to a previous state. In `GET /v1/movements`: every transition with an economic effect leaves an immutable entry (`payout_debit`, `payout_refund`, `payin_credit`…). See [movements and reconciliation](/en/concepts/movements-reconciliation). # Environments and testing Source: https://docs.cbpayapp.com/en/environment-testing Test mode and live mode: test base URL, pk_test_ keys, magic values to force every outcome, local webhooks and the go-live checklist CBPay runs on **two environments**: **test** (sandbox, simulated money) and **live** (production, real money). They are fully isolated — separate URLs, separate API keys, separate data — and expose exactly the same API, so an integration built against test works in live by swapping the base URL and the key. | | Test (sandbox) | Live (production) | | --------------- | ---------------------------------------------------- | ------------------------------- | | Base URL | `https://cryptobank.qbank.cl/platform` | `https://api.qbank.cl/platform` | | API keys | `pk_test_...` | `pk_...` | | Money | Simulated (nothing real moves) | **Real and irreversible** | | Providers | Internal simulator — always available, deterministic | Real banking rails | | Response header | `CBPay-Environment: test` | `CBPay-Environment: live` | Keys never cross environments: a `pk_test_` key is rejected by live and a live `pk_` key is rejected by test. There is no flag to flip — the environment is defined by where you point your requests. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR You[Your integration] -->|"pk_test_..."| TestEnv["cryptobank.qbank.cl
simulated rails"] You -->|"pk_..."| LiveEnv["api.qbank.cl
real rails"] ``` ## How the test environment behaves The test environment is **fully self-contained**: every corridor (payouts, payins, transfers, crypto, banking, cards, identity verification) is served by an internal simulator, so it never depends on any third party being up. Operations resolve **deterministically**: * Any operation you create is accepted and reaches `completed` after a few seconds (default \~10s), firing the same webhooks as live. * Specific **magic values** force every other outcome, so you can test your failure handling without guessing. ### Magic values | Product | Value | Outcome | | ------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Payouts | Amount ending in `.99` (e.g. `100.99`) | Fails after the settle delay (`failed`, balance refunded) | | Payouts | Amount ending in `.77` | Stays `processing` forever (test your timeout handling) | | Payouts | Beneficiary name containing `REJECT` | Rejected immediately | | QR payout (BO and BR/PIX) | Amount (or the QR's fixed amount) ending in `.99` | The confirm fails (`failed`, automatic refund) | | Brazil QR payout (PIX) | A `qr_payload` that is not a valid BR Code | `400` on scan (invalid CRC/format, same as production) | | Payins (QR / payment page) | Amount ending in `.99` | The charge expires unpaid | | Payins (QR / payment page) | Amount ending in `.77` | Stays `pending` forever | | Payins (QR / payment page) | Any other amount | Pays itself after the settle delay and credits your balance | | Card checkout (`card`) | Amount ending in `.99` | The issuer declines the charge (the page lets the payer retry with another card) | | Card checkout (`card`) | Amount ending in `.77` | Ambiguous failure after the charge was sent — the session is held for review (never retried automatically) | | Card checkout (`card`) | Amount ending in `.55` | 3-D Secure authentication with a challenge (simulated step-up on the page) | | Card checkout (`card`) | Amount ending in `.44` | 3-D Secure authentication ends without an eligible ECI — the pre-capture filter rejects the attempt | | Card checkout (`card`) | Cardholder name containing `REJECT` | 3-D Secure authentication fails | | Collect (pull charges) | OTP `000000` | Approves the charge; any other OTP fails | | Login / 2FA codes | `000000` | Valid on every channel (SMS, WhatsApp, email) — no message is actually sent | | Identity verification (KYC/KYB) | Name or external id containing `REJECT` | Verification ends `rejected` | | Identity verification (KYC/KYB) | Name or external id containing `HOLDREVIEW` | The risk band comes back `medium` — a grey zone the auto-decision engine routes to the human review queue instead of auto-approving | | Identity verification (KYC/KYB) | Name or external id containing `MANUALREVIEW` | Every signal stays clean, but the verification never settles on its own — it stays `pending_review` until the auto-decision engine (or a human) decides. Use it to exercise the engine's automatic approve/reject paths | | Identity verification (KYC/KYB) | Anything else | Auto-approves after the settle delay (documents always pass OCR) | | AML screening | Name containing `SANCTION` | Screens with hits, risk `prohibited` | | AML screening | Name containing `PEP` | Screens with hits, risk `high` | | Crypto withdrawal address | Ending in `SANC` | Blocked by the sanctions gate | | Crypto withdrawal address | Ending in `HIGH` / `MED` | Screens as high / medium risk | | Crypto withdrawals | Any address (not magic) | Confirms with a `SIMTX...` transaction id after the delay | Crypto **deposits** in test are credited from the dashboard (or by your platform administrator) — there is no real chain to send from. Withdrawals, balances, holds and webhooks behave exactly like live. ### Sample PIX QRs (Brazil QR payout) The test scan validates the BR Code exactly like production, so you need **real** PIX payloads. Use these (or generate your own with any static PIX QR generator): ```text Fixed amount 75.00 BRL (happy path) theme={"theme":{"light":"github-light","dark":"github-dark"}} 00020126360014br.gov.bcb.pix0114+5511998765432520400005303986540575.005802BR5913LOJA DA MARIA6009SAO PAULO62110507PED423163040BF9 ``` ```text Open amount (you choose the amount on confirm) theme={"theme":{"light":"github-light","dark":"github-dark"}} 00020126380014br.gov.bcb.pix0116loja@example.com5204000053039865802BR5913LOJA DA MARIA6009SAO PAULO62070503***63045EFE ``` ```text Fixed amount 80.99 BRL (the confirm fails — magic value .99) theme={"theme":{"light":"github-light","dark":"github-dark"}} 00020126360014br.gov.bcb.pix0114+5511998765432520400005303986540580.995802BR5913LOJA DA MARIA6009SAO PAULO62110507PEDFAIL63045211 ``` ### What differs from live * No real money, cards, emails or SMS ever leave the test environment. * **Accounts are born verified**: every new test account starts with `kyc_status: approved`, so you can exercise every product immediately — no onboarding gate. In live, accounts are born unverified and must complete KYC/KYB before money can leave. * **Accounts are born populated**: every new test account starts with \~6 months of realistic demo history across all products (payouts, payins, transfers, crypto, swaps, cards, banking, contacts...), with balances, a reconciled statement and analytics ready to explore — you can build dashboards and reports before creating a single operation yourself. * Bank catalogs are fictitious (`Simulated National Bank`, ...). * FX rates are real (same source as live) so amounts look realistic. * Test data is fully independent from live: nothing is copied from production. Treat the test dataset as disposable. ## Test mode from the dashboard The dashboard's **test/live switch** moves your session between environments with one click — no separate registration and no second login. If your account does not exist in test yet, it is created automatically the first time you switch — born verified and populated with demo history, like every test account. API keys are managed per environment: create your `pk_test_` keys while in test mode. ## Testing webhooks in local development Callback URLs must be **public HTTPS**: `localhost`, private IPs and `.local` domains are rejected when creating the subscription. To develop on your machine use an HTTPS tunnel: ```bash Cloudflare Tunnel (free) theme={"theme":{"light":"github-light","dark":"github-dark"}} # Install cloudflared and expose your local port cloudflared tunnel --url http://localhost:3000 # → https://.trycloudflare.com ← use it as callback_url ``` ```bash ngrok theme={"theme":{"light":"github-light","dark":"github-dark"}} ngrok http 3000 # → https://.ngrok-free.app ← use it as callback_url ``` Then create the subscription with that public URL (note the test base URL): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://cryptobank.qbank.cl/platform/v1/webhooks/subscriptions \ -H "X-API-Key: pk_test_..." \ -H "Content-Type: application/json" \ -d '{ "event_type": "payin_credited", "callback_url": "https://your-tunnel.trycloudflare.com/webhooks/cbpay", "secret": "a-long-random-secret" }' ``` Failed deliveries retry up to **5 times with incremental backoff**, so if your tunnel drops for a few minutes you will not lose the event. Always verify the HMAC signature — full recipe in [webhooks](/en/webhooks#signature-verification). ## Exercising every flow in test | Product | How to test it | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Payout | Create it with any beneficiary; it completes in seconds. Use the magic amounts to force failures | | Payin | Create a QR charge or payment page; it pays itself after the delay and credits your balance | | Transfer | Create a second test account and transfer between both (free) | | Crypto | Credit a test deposit from the dashboard, then withdraw to any address | | Identity (KYC/KYB) | Your own account is already approved at birth. To test the verification flow itself, run third-party KYC/KYB verifications — they auto-approve in seconds (`REJECT` in the name forces a rejection, `HOLDREVIEW` sends it to the human review queue, `MANUALREVIEW` holds it undecided so the auto-decision engine can act) | | AML | Screen `John SANCTION` and `Maria PEP` to exercise your hit handling | | Cards | Issue a card and simulate purchases from the dashboard | | 2FA | Enable it and use code `000000` everywhere | ## Go-live checklist Before pointing your integration at the live environment: * [ ] Swap the base URL to `https://api.qbank.cl/platform` and the key to your live `pk_...` (issued in live mode). * [ ] API keys live in a secrets manager (never in the frontend or the repo). * [ ] Every money operation sends an `idempotency_key` derived from YOUR internal id (not a random UUID per attempt). * [ ] On timeout or `5xx` you **do not retry with a new key**: repeat with the same key or query the state with the `GET`. * [ ] You verify the HMAC signature of every webhook and answer `2xx` fast (process async). * [ ] You handle non-final states (`pending`, `processing`) without assuming success — in live, settlement takes longer than the 10 simulated seconds. * [ ] You re-created your webhook subscriptions in live (test subscriptions do not carry over). * [ ] You query `GET /v1/services` to show only enabled products — see [services](/en/concepts/services). * [ ] You reconcile daily with `GET /v1/movements` or the [statement](/en/guides/statement). * [ ] You have a channel with the CBPay team for `unassigned` deposits or incidents. In **live** every operation is real and irreversible once completed. A `completed` payout is already in the beneficiary's account; the only reversal path is outside the API (contact the CBPay team). No. Fees are charged against simulated balances, so you can exercise the full pricing logic without spending real money. No. Each environment only accepts its own keys (`pk_test_` in test, `pk_` in live). A key from the other environment returns `401`. Every response carries the `CBPay-Environment` header (`test` or `live`), and `GET /healthz` returns `livemode`. It is generated at account creation: \~6 months of deterministic, accounting-consistent demo operations across every product. It is not real data and it is not copied from production — the environments share nothing. Treat test data as disposable. Yes — the exact same events, signed with your test subscription's secret. Point them at your development tunnel. # Integration flows Source: https://docs.cbpayapp.com/en/flows The end-to-end flows of a typical integration, with step-by-step sequence diagrams This page connects the products into **complete business flows**: what to call, what to expect and which webhook closes each cycle. Every flow links to its product's detailed guide. ## 1. Funding the account Three paths for money to come in; all end with a USDT credit and a webhook: ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} sequenceDiagram participant App as Your app participant CB as CBPay participant Pagador as Payer / Network App->>CB: POST /v1/payins (or crypto wallet) CB-->>App: pending + payment data (QR, URL, reference, address) App->>Pagador: share the payment method Pagador->>CB: pays (transfer, QR, on-chain USDT) CB->>CB: converts at your payin_rate − fee (fiat) CB-->>App: webhook payin_credited / crypto_deposit_credited App->>CB: GET /v1/balances (verify) ``` | Path | Endpoint | Closing webhook | | -------------------------------------------------- | ----------------------------------------- | ------------------------- | | Fiat collection (QR, transfer, payment page, pull) | `POST /v1/payins` / `/collect` | `payin_credited` | | On-chain USDT deposit | `POST /v1/crypto/wallets` (fixed address) | `crypto_deposit_credited` | | Internal transfer from another account | — (the sender initiates it) | `transfer_received` | Details: [payins](/en/guides/payins) · [crypto](/en/guides/crypto) · [transfers](/en/guides/transfers). ## 2. Dispersing (payout) ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} sequenceDiagram participant App as Your app participant CB as CBPay participant Banco as Local rail App->>CB: POST /v1/payouts (idempotency_key) CB-->>App: 202 processing (fx_rate, total_debit; debit sits in held) CB->>Banco: executes the dispersal alt paid Banco-->>CB: confirmation CB-->>App: webhook payout_status_changed (completed) else rejection Banco-->>CB: rejection CB->>CB: refunds the FULL debit to available CB-->>App: webhook payout_status_changed (failed + status_code) end App->>CB: GET /v1/payouts/{id} (verify final state) ``` **QR** variant (Bolivia, Brazil PIX): `POST /v1/payouts/qr/scan` (free, decodes) → show the data → `POST /v1/payouts/qr/confirm` (charged like a normal payout). Details: [payouts](/en/guides/payouts) · [QR payout](/en/guides/qr-payout). ## 3. Collecting from a customer Pick the mode based on the country and the experience you want: | Mode | Countries | Payer experience | Confirmation | | ----------------------------- | ----------------------------------- | ----------------------------------------------- | ---------------------------------- | | Hosted payment page | CL | Opens a URL and pays from their bank | Automatic | | QR | BO, BR (PIX) | Scans with their banking app | Automatic | | Announced transfer | CL, PE, MX, BR | Transfers including the reference | Automatic by reference (or amount) | | Dedicated CLABE / CVU | MX, AR | Transfers to a fixed account of yours | Automatic, no references | | Pull collection (c2p / debit) | VE | Authorizes with OTP and you execute the charge | **Synchronous** in the same call | | Card payment | BO (BOB/USD) | Enters their card on a secure hosted page (3DS) | Automatic | | Universal checkout link | Every live country + crypto + cards | Opens one link and picks how to pay | Automatic | All close with `payin_credited` and the net credit in your balance. Details: [payins](/en/guides/payins) · [checkout](/en/guides/checkout). ## 4. Checkout end-to-end One link, every rail, settled in the balance you choose: ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} sequenceDiagram participant App as Your app participant CB as CBPay participant Pagador as Payer App->>CB: POST /v1/payins (method: checkout, amount, settlement_asset) CB-->>App: checkout_url (branded public page) App->>Pagador: share the link Pagador->>CB: picks fiat / crypto / card / CBPay app and pays CB->>CB: credits and auto-converts to your settlement_asset CB-->>App: webhook payin_credited (settled_via, conversion_status) ``` Details: [checkout](/en/guides/checkout). ## 5. Saved cards and subscriptions Save the card once (with the payer's consent) and charge it later — one-click, merchant-initiated (MIT) or on a recurring schedule: ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} sequenceDiagram participant App as Your app participant CB as CBPay participant Pagador as Payer App->>CB: POST /v1/payins (method: card, save_card: true) Pagador->>CB: pays with 3DS and ticks "save my card" CB-->>App: webhook card_stored (stored_card_id) App->>CB: POST /v1/stored-cards/{id}/charges (MIT, payer absent) CB-->>App: webhook payin_credited App->>CB: POST /v1/subscriptions (interval + amount) CB-->>App: payin_credited per period + subscription_status_changed ``` Details: [stored cards & subscriptions](/en/guides/stored-cards-subscriptions). ## 6. QR POS charge (processors) For companies operating physical points of sale: ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} sequenceDiagram participant POS as Your POS participant CB as CBPay participant Cliente as Customer POS->>CB: POST /v1/pos/merchants (verified merchant, once) POS->>CB: POST /v1/pos/charges (amount, idempotency_key) CB-->>POS: exclusive crypto address + QR + quoted due Cliente->>CB: pays in crypto (partial payments accumulate) CB-->>POS: webhook payin_credited (pos_merchant attribution) ``` Details: [QR POS](/en/guides/qr-pos). ## 7. Converting balances (swaps) ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR Q["GET /v1/swaps/quote
(indicative, free)"] --> S["POST /v1/swaps
(idempotency_key)"] S --> B["Instant credit in the
target balance"] ``` One call converts between USDT, USDC, BTC and GOLD at your account's rate — no money leaves the account, so no OTP is required. Details: [swaps](/en/guides/swaps). ## 8. Reconciling ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR webhooks["Webhooks
(push, per event)"] --> interno["Your internal records
(by idempotency_key)"] movements["GET /v1/movements
(immutable ledger)"] --> interno cartola["Period statement
(JSON/PDF/Excel)"] --> cierre["Accounting close
with balance check"] interno --> cierre ``` Full recipe in [movements and reconciliation](/en/concepts/movements-reconciliation) and [statement](/en/guides/statement). ## 9. End-to-end international banking ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} sequenceDiagram participant App as Your app participant CB as CBPay App->>CB: POST /v1/banking/customers (profile, once) CB-->>App: webhook banking_customer_status_changed (approved) App->>CB: POST /v1/banking/accounts (USD/EUR account) App->>CB: POST /v1/banking/operations/prepare (quote, free) App->>CB: POST /v1/banking/operations (idempotency_key) CB-->>App: webhook banking_operation_status_changed (completed/failed) ``` Banking balances live in your bank accounts (separate from USDT); CBPay only charges the configured fixed fees. Details: [banking](/en/guides/banking). First integration? Follow the [quickstart](/en/quickstart) (fund → payout → webhook) and come back here as you add products. # AML screening Source: https://docs.cbpayapp.com/en/guides/aml Screen persons and companies against sanctions, PEP and adverse media lists, with rescreening and continuous monitoring **AML screening** checks a person's or company's identity against global lists — sanctions, PEP, adverse media — and returns the analysis result with its risk level. It is a pure compliance product: it does **not** verify identity with documents or a liveness check (that is [KYC/KYB verification](/en/guides/kyc)); it analyzes whether the identity carries list risk. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR submit["POST /v1/aml/screenings
(bills fee)"] --> result{"Result"} result -->|"no_match"| ok["No hits"] result -->|"potential_match / has_hits"| review["Matches + risk_level
(review the hits)"] ok -.->|"POST /v1/aml/rescreen
(data change, policy)"| submit ok -.->|"PATCH /v1/aml/monitoring
(continuous watch)"| monitor["Alerts via webhook
aml_screening_updated"] ``` **Breaking change (v1.34)**: this product used to live at `POST /v1/kyc`, `/v1/kyc/rescreen` and `PATCH /v1/kyc/monitoring`. Those routes were **removed** and are now `POST /v1/aml/screenings`, `POST /v1/aml/rescreen` and `PATCH /v1/aml/monitoring` (same semantics, same fees). The `/v1/kyc/...` routes now belong to [identity verification](/en/guides/kyc), a different product. If CBPay configured a compliance fee, it is debited **before** the call (you will see `compliance_fee` in the response) and **automatically refunded** if the screening fails. With a fee of 0 the service is free for you. Requires your own [approved identity verification](/en/guides/kyc#your-own-verification-onboarding). ## Catalogs to build your form Before building the screening (or verification) form, fetch the official catalogs with `GET /v1/aml/catalogs`: genders, company statuses, address types, legal entity forms (global list plus per-country cascade), income/wealth sources, industry standards with their per-country default, and the full ISO-3166 country and subdivision lists. Every entry carries `value` (what you send to the API) and `label` (what you display). Static data — safe to cache for hours. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/aml/catalogs \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "genders": [ { "value": "male", "label": "Male" } ], "company_types_by_country": { "CL": [ { "value": "Sociedad por Acciones", "label": "Sociedad por Acciones" } ] }, "industry_code_type_by_country": { "CL": "ISIC" }, "countries": [ { "value": "CL", "label": "Chile" } ], "meta": { "note": "value = send to the API; label = display in the UI." } } ``` Cascades: the company's country fixes its legal forms (`company_types_by_country[country]`, falling back to `company_types`) and its industry standard (`industry_code_type_by_country[country]`, default ISIC); with that standard you take the codes from `industries_by_code_type[standard]`. ### Cities catalog (per country) When the form asks for a city, fetch `GET /v1/aml/catalogs/cities?country=US` once the country is picked — one call per country, then filter by state on the client: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/aml/catalogs/cities?country=US" \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "country": "US", "states": { "US-FL": ["Miami", "Orlando"] }, "country_cities": [], "meta": { "state_count": 51, "city_count": 3200 } } ``` * `states` keys are the same ISO 3166-2 subdivisions as `country_subdivisions` in the main catalog; `country_cities` lists the cities whose region could not be mapped to a subdivision — offer them too. Neither field is ever `null`. * Names come in their local spelling (accents included — "Alhué", "São Paulo", "Ciudad de México") and urban divisions are covered (comunas, districts, boroughs), so the selector can offer the city exactly as your user knows it. * A country without coverage answers `200` with empty lists — fall back to a free-text city field. A malformed code gets `400 invalid_country`; an unknown one, `404 country_not_found`. * Static data served with `Cache-Control: public, max-age=86400` — safe to cache for a day. ### Postal code lookup (US) When the form asks for an address, resolve the ZIP first with `GET /v1/aml/catalogs/postal-code?country=US&code=33130` and prefill city and state: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/aml/catalogs/postal-code?country=US&code=33130" \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "country": "US", "postal_code": "33130", "city": "Miami", "state": "FL" } ``` * Only US ZIP codes (5 digits) have a dataset today. A `404 postal_code_not_found` means the ZIP is unknown — or the country has no dataset — so keep the address fields manual. * A malformed request gets `400 invalid_payload`. * Same caching as the other catalogs: `Cache-Control: public, max-age=86400`. ## Submit a screening One endpoint for person and company; the type is detected from the payload (other differences between account types are summarized in [persons and companies](/en/concepts/persons-companies)): ```bash Person theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/aml/screenings \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "customer": { "person": { "full_name": "Ana Pérez Rojas", "date_of_birth": { "year": 1990, "month": 4, "day": 12 }, "personal_identification": [ { "issuing_country": "CL", "number": "12.345.678-5" } ] }, "email": "ana@example.com", "country": "CL" }, "monitor": false }' ``` ```bash Company theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/aml/screenings \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "customer": { "company": { "legal_name": "Comercial Andina SpA", "registration_authority_identification": "76.543.210-8" }, "email": "legal@andina.cl", "country": "CL" }, "monitor": true }' ``` ```bash Minimal (autofilled from your account) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/aml/screenings \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "customer": {}, "monitor": false }' ``` If you omit `person`/`company`, it is filled from your account data (the person/company type comes from your account type). ## Send every identity field you have (recommended) The `customer` object accepts **many more fields, all optional**, forwarded verbatim to the screening engine: **the more identity data you send, the more precise the analysis** — date of birth, countries and strong documents rule out namesakes and reduce false positives. | Field (person) | What it is | | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `full_name` — or `first_name` / `middle_name` / `last_name` | Full or split name | | `date_of_birth` | ONLY as an object `{ "year": 1990, "month": 4, "day": 12 }` (a plain `"YYYY-MM-DD"` string is rejected with `422`) | | `nationality` | Nationalities as an **array** of ISO-3166 codes, e.g. `["CL"]` (a plain string is rejected with `422`) | | `country_of_birth` | Country of birth | | `residential_information[]` | Residences, each with `country_of_residence` | | `personal_identification[]` | Strong documents: `{ "issuing_country", "number" }` (national id, passport…) — **no** `type` field (the engine rejects it) | | `alias` / `aliases` | Other known names | | Field (company) | What it is | | --------------------------------------- | ------------------------------------------------------------------------ | | `legal_name` | Legal name | | `alias[]` | Trade names | | `registration_authority_identification` | Tax/mercantile identifier (tax number, registry number) | | `place_of_registration` | Registration/incorporation country (ISO-3166) | | `incorporation_date` | Incorporation date as an object `{ "year": 2015, "month": 8, "day": 1 }` | | `address[]` | Addresses, each with `country` | The screening engine is strict about shapes and rejects mismatches with `422`: inside `company`, do not send flat fields like `tax_id`, `registration_number` or `country_of_incorporation` (the identifier goes in `registration_authority_identification` and the country in `place_of_registration`); inside `person`, `date_of_birth` goes ONLY as a `{year, month, day}` object, `nationality` as an array, and `personal_identification[]` without a `type` field (verified live 2026-07-18). A query with **exactly the same identity data** reuses the previous screening (no new charge). Adding or changing identity fields (name, date, country, document, alias) makes the search more specific and runs — and bills — a new screening. Cosmetic fields (email, phone, textual address) do not change the matching. `201` response — person and company share the same shape; only `compliance_service` changes (`compliance_person` vs `compliance_company`, each with its own fee): ```json Person theme={"theme":{"light":"github-light","dark":"github-dark"}} { "customer_id": "cus_8f2e1a…", "status": "screened", "risk_level": "low", "screening_result": "no_match", "compliance_service": "compliance_person", "compliance_fee": "0.500000" } ``` ```json Company theme={"theme":{"light":"github-light","dark":"github-dark"}} { "customer_id": "cus_5b7c33…", "status": "screened", "risk_level": "medium", "screening_result": "potential_match", "compliance_service": "compliance_company", "compliance_fee": "1.000000" } ``` ## Rescreening Re-runs the analysis of the same identity (e.g. after a data change or on a periodic policy). No body — it uses the `customer_id` of your previous screening: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/aml/rescreen \ -H "Authorization: Bearer " ``` `200` response (bills `compliance_rescreen`, when configured): ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "customer_id": "cus_8f2e1a…", "status": "screened", "risk_level": "low", "screening_result": "no_match", "compliance_service": "compliance_rescreen", "compliance_fee": "0.250000" } ``` Requires a previous screening; otherwise `409 no_screening`. ## Continuous monitoring Enables (or disables) permanent watch over the identity — list changes, PEP, adverse media. Updates arrive via the `aml_screening_updated` webhook: ```bash Enable (bills compliance_monitoring) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X PATCH https://api.qbank.cl/platform/v1/aml/monitoring \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "enabled": true }' ``` ```bash Disable (always free) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X PATCH https://api.qbank.cl/platform/v1/aml/monitoring \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "enabled": false }' ``` `200` response: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "customer_id": "cus_8f2e1a…", "monitoring": true, "compliance_service": "compliance_monitoring", "compliance_fee": "0.100000" } ``` When disabling, `compliance_fee` returns `"0.000000"` — disabling is always free. ## Screening PDF report Every screening in your history can be downloaded as an **executive PDF report** with your branding: a cover page with the decision and its risk traffic light, indicators (sanctions, watchlists, PEP, terrorism, narcotics, adverse media, fraud, corruption, arms), the consolidated matches with their lists and links, aliases, a signal glossary and a final backing section with the international data sources consulted. It is the document you hand to an auditor or a counterparty as evidence of the analysis. First locate the `screening_id` in your history: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/aml/screenings?from=2026-07-01&to=2026-07-14&page=1&page_size=50" \ -H "Authorization: Bearer " ``` Then download the report (pure read — no fee, no idempotency key): ```bash English (default) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/aml/screenings/a1b2c3d4-e5f6-7890-abcd-ef1234567890/report" \ -H "Authorization: Bearer " \ -o aml_report.pdf ``` ```bash Spanish theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/aml/screenings/a1b2c3d4-e5f6-7890-abcd-ef1234567890/report?lang=es" \ -H "Authorization: Bearer " \ -o aml_report.pdf ``` ```bash Chinese theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/aml/screenings/a1b2c3d4-e5f6-7890-abcd-ef1234567890/report?lang=zh" \ -H "Authorization: Bearer " \ -o aml_report.pdf ``` The response is `application/pdf` with a descriptive `Content-Disposition` filename. `lang` accepts `en` (default), `es` and `zh`; any other value returns `400 invalid_language`. A `screening_id` that belongs to another account returns `404`. The report is generated from the screening's persisted evidence, so it is always available even if the compliance engine is down. The analysis data (list names, media headlines) stays in its original language; only the report labels are translated. ## Webhook | Event | When | | ----------------------- | ------------------------------------------------------------------------------------------------ | | `aml_screening_updated` | The screening finished, a case changed, the risk changed or a monitored transaction was reviewed | Example payload: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "screening_event": "compliance_risk_changed", "customer_id": "cus_8f2e1a…", "data": { "risk_level": "high" } } ``` Subscribe the same way as every other event (see [Webhooks](/en/webhooks)). ## Errors | HTTP | `error` | Cause | Solution | | ---- | ------------------------ | ----------------------------------------------------------- | --------------------------------------------------------------------------- | | 400 | `invalid_language` | The PDF report `lang` is not `en`, `es` or `zh` | Use one of the three supported languages | | 402 | `insufficient_funds` | Balance cannot cover the compliance fee | Fund the account and retry | | 403 | `verification_required` | Your account has not approved its identity verification yet | Complete your [onboarding](/en/guides/kyc#your-own-verification-onboarding) | | 403 | `service_disabled` | The `aml` service is disabled for your account | Contact your operator | | 409 | `no_screening` | Rescreen/monitoring without a previous screening | Send `POST /v1/aml/screenings` first | | 502 | `compliance_unavailable` | Service temporarily unavailable (the fee was refunded) | Retry later | ## FAQ Screening checks an identity against lists (sanctions, PEP, adverse media) — no documents involved. [KYC/KYB verification](/en/guides/kyc) proves the person/company is who they claim to be, with a form, documents and a video liveness check. They complement each other: verify identity with KYC/KYB and watch its risk with AML. Yes: the `customer` object accepts any identity, not only your account's. Each screening bills its fee (person or company depending on the payload). No. Since v1.34 your account's `kyc_status` is managed exclusively by the KYC/KYB identity verification (your onboarding). Screening only evaluates list risk. The history and the PDF report are generated from each screening's persisted evidence, available for operations executed since the history exists (v1.55). Screenings older than that version have no persisted evidence, so they do not appear in `GET /v1/aml/screenings` and cannot download a report. If you need the document, run a new screening of the same identity (identical data reuses the previous result without a new charge) and download its report. # Your account summary (analytics) Source: https://docs.cbpayapp.com/en/guides/analytics One endpoint with every series and statistic of your account to build your dashboard: volume, transactions, users, per-service sections, countries, spending and balances `GET /v1/analytics/summary` returns in **a single call** everything the summary page of your account (person or company) needs: chart-ready time series, the detail of every service with all its dimensions (country, currency, method, status, chain, merchant), what you spent on services and your balances valued in USD. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR front["Your dashboard"] --> ep["GET /v1/analytics/summary"] ep --> g1["Gross volume
(in/out per period)"] ep --> g2["Transactions"] ep --> g3["New users
(banking)"] ep --> g4["Per-service sections
payouts, payins, cards, crypto..."] ep --> g5["Service spending
+ valued balances"] ``` ## Request ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/analytics/summary?from=2026-07-01&to=2026-07-10&granularity=day" \ -H "Authorization: Bearer " ``` | Parameter | Required | Description | | ------------- | -------- | -------------------------------------------------------------------------------- | | `from` / `to` | Yes | `YYYY-MM-DD` range in your organization's timezone, both inclusive; 366 days max | | `granularity` | No | `day` (default), `week` (Monday-Sunday) or `month` | You only see **your own account's** data. All amounts are USD decimal strings; buckets without activity come **zero-filled** so you can chart directly. ## Global blocks (header KPIs and the 3 main charts) ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "gross_volume": { "in": "636936.87", "out": "270118.87", "total": "907055.74", "series": [ { "date": "2026-07-01", "in": "51023.10", "out": "31210.44", "total": "82233.54" } ], "previous_period": { "in": "512300.00", "out": "241000.10", "total": "753300.10" }, "change_pct": "20.41", "unpriced_assets": [] }, "transactions": { "total": 92, "series": [ { "date": "2026-07-01", "in": 3, "out": 5, "total": 8 } ], "previous_period": { "total": 71 }, "change_pct": "29.58" }, "new_users": { "total": 11, "series": [ { "date": "2026-07-01", "count": 1 } ], "previous_period": { "total": 6 }, "change_pct": "83.33" } } ``` * **`gross_volume`**: USD value of everything that came IN (payins, crypto deposits, received transfers) and went OUT (payouts, withdrawals, sent transfers, card purchases). Refunds are netted against their service — they never inflate volume. Swaps are internal conversion and have their own section. * **`transactions`**: operation count (fees and refunds excluded). * **`new_users`**: third-party banking users your company registered (zero series for person accounts). * **`change_pct`**: variation vs the immediately previous period of the same length — for the ▲▼ deltas (`null` when the previous period was 0). * `BTC`/`GOLD` are valued at the current reference price; if a price is unavailable the asset is listed in `unpriced_assets` and its amounts stay out of the USD totals (we never invent a price). ## `by_country` — global view per country Payouts and payins combined, ordered by volume — for the map or per-country bars: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} "by_country": [ { "country": "BR", "payouts": { "count": 18, "volume_usd": "3410.20" }, "payins": { "count": 4, "volume_usd": "820.00" }, "total_usd": "4230.20" } ] ``` ## `sections` — the detail of EVERY service Each section carries totals, its per-bucket series and its own dimensions: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} "payouts": { "count": 24, "volume_usd": "5120.40", "fees_usd": "7.20", "series": [ { "date": "2026-07-01", "count": 3 } ], "by_status": [ { "key": "completed", "count": 21, "volume_usd": "4980.10" } ], "by_country": [ { "country": "BR", "count": 18, "volume_usd": "3410.20", "local_volume": { "BRL": "17550" } } ], "by_method": [ { "key": "pix", "count": 18, "volume_usd": "3410.20" } ] } ``` `by_status` gives you the success rate; `by_country` includes local currency volume per currency; `by_method` splits pix, bank\_transfer, yape, etc. Failed payouts are excluded from volume (they were refunded). ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} "payins": { "count": 9, "volume_usd": "2210.00", "fees_usd": "0.00", "series": [ { "date": "2026-07-02", "count": 2 } ], "by_country": [ { "key": "BO", "count": 5, "volume_usd": "1400.00" } ], "by_method": [ { "key": "qr", "count": 5, "volume_usd": "1400.00" } ], "by_kind": [ { "key": "qr", "count": 5, "volume_usd": "1400.00" } ] } ``` Only **credited** payins count. ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} "deposits": { "count": 3, "volume_usd": "1500.00", "series": [ { "date": "2026-07-03", "count": 1 } ], "by_chain": [ { "chain": "tron", "asset": "USDT", "count": 2, "amount": "1000.000000" } ] }, "withdrawals": { "count": 2, "volume_usd": "600.00", "series": [ { "date": "2026-07-04", "count": 1 } ], "by_chain": [ { "chain": "eth", "asset": "USDC", "count": 1, "status": "completed", "amount": "500.000000", "fees": "1.000000" } ] } ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} "transfers": { "in": { "count": 4, "volume_usd": "300.00" }, "out": { "count": 2, "volume_usd": "120.00" }, "series": [ { "date": "2026-07-01", "count": 1 } ] }, "swaps": { "count": 5, "volume_usd": "890.00", "series": [ { "date": "2026-07-05", "count": 2 } ], "by_pair": [ { "pair": "USDT/BTC", "count": 3, "volume_usd": "600.00" } ] }, "cards": { "count": 12, "volume_usd": "230.50", "fees_usd": "5.00", "active_cards": 1, "series": [ { "date": "2026-07-06", "count": 4 } ], "by_status": [ { "status": "settled", "count": 10, "volume_usd": "205.00" } ], "top_merchants": [ { "merchant": "AMAZON", "count": 4, "volume_usd": "98.20" } ] } ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} "banking": { "new_third_parties": 11, "third_parties_series": [ { "date": "2026-07-01", "count": 1 } ], "new_accounts": 14, "accounts_series": [ { "date": "2026-07-01", "count": 2 } ], "operations": 6, "volume": { "in": { "count": 4, "volume_usd": "1200.00" }, "out": { "count": 9, "volume_usd": "3450.00" }, "series": [ { "date": "2026-07-01", "count": 2 } ], "volume_usd": "4650.00" }, "fees_usd": "18.00", "fees_by_service": { "banking_customer": { "count": 11, "fees_usd": "11.00" } } }, "verifications": { "submissions": [ { "kind": "kyc", "status": "approved", "count": 3 } ], "links": [ { "kind": "kyb", "status": "pending", "count": 1 } ], "fees_usd": "9.00", "fees_by_kind": { "kyc_verification": { "count": 3, "fees_usd": "6.00" }, "kyb_verification": { "count": 1, "fees_usd": "3.00" } } }, "aml": { "screenings": 4, "fees_usd": "2.00", "by_service": { "compliance_screening": { "count": 4, "fees_usd": "2.00" } } }, "contacts": { "new_contacts": 7, "series": [ { "date": "2026-07-02", "count": 2 } ] }, "adjustments": { "count": 2, "volume_usd": "2000.00", "series": [ { "date": "2026-03-14", "count": 1 } ] } ``` The `deposits` section also includes `wallet_fees_usd` (the wallet creation fees of the crypto product), and inside `balances.items` the banking mirror balances carry `custody: "banking"` (the authoritative balance lives at the bank). `new_third_parties` is the same metric as the "new users" chart: the banking users your company registered. `banking.volume` is the money moved through your bank accounts (inbound and outbound, valued in USD): it also adds to the account's global `gross_volume`, and its detail reconciles in the `BANK_USD`/`BANK_EUR` sections of the [statement](/en/guides/statement). ## `spending` — what you consumed in services Every explicit fee you paid in the period, with how many times each service was billed: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} "spending": { "total_usd": "34.50", "by_service": { "banking_customer": { "count": 11, "fees_usd": "11.00" }, "wallet_creation": { "count": 2, "fees_usd": "1.00" }, "verification_kyc": { "count": 3, "fees_usd": "9.00" } } } ``` ## `balances` — your balances valued ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} "balances": { "items": [ { "asset": "USDT", "available": "1520.250000", "held": "0.000000", "usd_estimate": "1520.25" }, { "asset": "BTC", "available": "0.00500000", "held": "0.00000000", "usd_estimate": "313.68" } ], "net_worth_usd_estimate": "1833.93" } ``` ## Balance evolution — `GET /v1/balances/history` For the balance card with a chart (the "last 30 days balance" with its ▲▼): a **daily** series per asset with each day's closing balance, plus the aggregated USD series and the period's inflows/outflows. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/balances/history?from=2026-06-12&to=2026-07-11" \ -H "Authorization: Bearer " ``` | Parameter | Required | Description | | ------------- | -------- | -------------------------------------------------------------------------------- | | `from` / `to` | Yes | `YYYY-MM-DD` range in your organization's timezone, both inclusive; max 366 days | ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "from": "2026-06-12", "to": "2026-07-11", "granularity": "day", "timezone": "America/New_York", "assets": { "USDT": { "series": [ { "date": "2026-06-12", "balance": "100.000000" }, { "date": "2026-06-13", "balance": "100.000000" }, { "date": "2026-07-11", "balance": "125.430000" } ], "first": "100.000000", "last": "125.430000", "change_pct": "25.43" }, "BTC": { "series": [ { "date": "2026-06-12", "balance": "0.00060000" }, { "date": "2026-07-11", "balance": "0.00060000" } ], "first": "0.00060000", "last": "0.00060000", "change_pct": "0.00" }, "BANK_USD": { "series": [ { "date": "2026-06-12", "balance": "1500.00" }, { "date": "2026-07-11", "balance": "1725.50" } ], "first": "1500.00", "last": "1725.50", "change_pct": "15.03" } }, "total_usd": { "series": [ { "date": "2026-06-12", "balance_usd": "163.73" }, { "date": "2026-07-11", "balance_usd": "191.34" } ], "first": "163.73", "last": "191.34", "change_pct": "16.86", "spot_priced_dates": [], "unpriced_assets": [] }, "period": { "in_usd": "280.20", "out_usd": "254.77", "net_usd": "25.43" }, "current": { "items": [ { "asset": "USDT", "available": "125.430000", "held": "10.000000", "usd_estimate": "135.43" }, { "asset": "BTC", "available": "0.00060000", "held": "0.00000000", "usd_estimate": "65.91" } ], "net_worth_usd_estimate": "201.34" } } ``` * Each point is the **available balance at that day's close** (in your organization's timezone); days without movements carry the previous day's balance forward, so the series has no gaps and charts directly. * `assets` also includes the banking account mirrors (`BANK_USD`, `BANK_EUR`) as their own series in their own currency (2 decimals) — handy for a "Bank USD"/"Bank EUR" chip on the chart. They are **not** part of the `total_usd` aggregate, which only covers the operational balances. * `total_usd` values BTC/GOLD at **each day's historical price**. When a day has no historical price yet, today's spot is used and that day is disclosed in `spot_priced_dates` (values are never invented). * `period.in_usd`/`out_usd` are the range's total inflows and outflows (same classification as `gross_volume`) — the "↗ $280.2K ↘ −$254.8K" of the card. * `current` is today's snapshot with both `available` **and** `held` (the historical series only tracks the available balance: holds have no history). ## Your rates over time — `GET /v1/rates/history` The time series of **your account's** FX rates (the same rates as `GET /v1/rates`, with your configuration already applied), for the rate evolution chart with its "+3.4% / −3.0%" badge: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/rates/history?from=2026-06-12&to=2026-07-11&granularity=day" \ -H "Authorization: Bearer " ``` | Parameter | Required | Description | | ------------- | -------- | ------------------------------------------------------------------ | | `from` / `to` | Yes | `YYYY-MM-DD` range in your organization's timezone, both inclusive | | `granularity` | No | `day` (default, max 366 days) or `hour` (max 31 days) | | `currency` | No | Filter one currency (e.g. `CLP`) | ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "base": "USD", "from": "2026-06-12", "to": "2026-07-11", "granularity": "day", "rates": { "chile": { "currency": "CLP", "series": [ { "date": "2026-06-12", "rate": "939.068965", "payin_rate": "967.452325" }, { "date": "2026-07-11", "rate": "910.896551", "payin_rate": "938.428400" } ], "first": "939.068965", "last": "910.896551", "change_pct": "-3.00" } }, "asset_prices": { "BTC": { "currency": "USD", "unit": "btc", "series": [ { "date": "2026-06-12", "price": "106214.55" }, { "date": "2026-07-11", "price": "109853.24" } ], "first": "106214.55", "last": "109853.24", "change_pct": "3.43" } }, "retrieved_at": "2026-07-11T15:00:00Z" } ``` * `rate` is the payout side and `payin_rate` the deposit side — the same two rates as the current snapshot, point by point. * `change_pct` comes signed (`"3.43"` up, `"-3.00"` down): use it directly to color the badge green/red. * Buckets without data carry the last known value forward; days before history started are simply absent. ## Errors | HTTP | `error` | What to do | | ---- | --------------------- | --------------------------------------------------------------------- | | 400 | `invalid_range` | `from`/`to` are required (`YYYY-MM-DD`) and the range max is 366 days | | 400 | `invalid_granularity` | Use `day`, `week` or `month` (histories: `day` or `hour`) | | 403 | `account_required` | The endpoint requires an account credential | | 502 | `rates_unavailable` | Rate history temporarily unavailable; retry in a few seconds | ## FAQ Your organization's timezone (IANA name, default `America/New_York`), same as the `from`/`to` filters across the whole platform API. The response echoes it in the `timezone` field. A platform admin changes it with `PUT /v1/admin/orgs/{orgID}/settings` (key `timezone`). Everything that moved money to/from your account: payins, crypto deposits and received transfers (in); payouts, withdrawals, sent transfers and card purchases (out). Refunds are netted, fees are reported separately in `spending`, and swaps (conversion between your own balances) have their own section. At the reference price in force at query time (the same as `GET /v1/rates`). It is a display valuation: if a price is unavailable, the asset appears in `unpriced_assets` and is not added to the USD totals. Yes: both come from the same ledger. The statement (`GET /v1/reports/statement`) is the line-by-line accounting document; the analytics endpoint is the aggregated view for charts. Real time: every credited operation appears in the next call. Rate history is recorded continuously (every time the rate changes) and includes an initial backfill of \~90 days of daily rates. If you request a range before history started, those days are simply absent from the series — values are never invented. No: the series tracks the available balance at each day's close. Holds (in-flight payouts, card holds) have no history; today's `held` comes in the `current` block. It doesn't: your rate derives from the market rate through your commercial configuration, which is a constant factor — the percentage change is the same. What you see charted is exactly what you would have gotten operating each day. # Banking Source: https://docs.cbpayapp.com/en/guides/banking Real bank accounts for your account: receive, hold and send money over international banking rails Banking gives you **real bank accounts** in the name of your verified profile: you receive funds over international rails (SEPA, SWIFT, ACH depending on the currency), hold fiat balances and send payments to third parties. It is a separate product from your USDT balance: **banking money lives in your bank accounts**, not in the CBPay balance. | Concept | Where it lives | Queried with | | ------------------ | ------------------ | --------------------------------------- | | CBPay USDT balance | CBPay ledger | `GET /v1/balances` | | Bank balances | Your bank accounts | `GET /v1/banking/accounts/{id}/balance` | Banking fees come in two shapes. **Standalone fixed fees** (`banking_customer`, `banking_account`, `banking_operation`) are debited from your **USDT balance** when each operation executes and **refunded automatically** if it fails. **Transactional rail fees** (`banking_deposit`, `banking_transfer_ach`, `banking_transfer_swift`, `banking_transfer_wire`, `banking_transfer_sepa`) are a percentage plus a fixed amount charged **in the operation currency** (your `BANK_USD` / `BANK_EUR` balance) — see [rail fees](#rail-fees-deposits-and-transfers). With a fee of 0 (the default) the service is free. The `banking_fee` and `banking_fee_asset` fields on each response show what was charged and in which currency. ## The full flow ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR profile["1. Create profile
POST customer"] --> docs["2. Documents
+ submit"] docs --> review{"Verification"} review -->|"approved"| accounts["3. Open accounts
per currency"] review -->|"rejected"| fix["Fix the data
and resubmit"] fix --> docs accounts --> receive["Receive funds
(IBAN / account)"] accounts --> benef["4. Register
beneficiaries"] benef --> payments["5. Send payments
prepare → operations"] payments --> whOp["Webhook
operation_status_changed"] ``` 1. **Create your banking profile** (`POST /v1/banking/customer`) — once. 2. **Upload verification documents** and **submit for review**. 3. Once `approved`, **open accounts** per currency. 4. **Register beneficiaries** (counterparties) for third-party payments. 5. **Send payments**: quote with `prepare`, execute with `operations`. State changes arrive through the `banking_customer_status_changed` and `banking_operation_status_changed` webhooks ([webhooks](/en/webhooks)). ## 1. Create your banking profile Once per account. If you omit `type`, `name` or `email`, they are filled from your CBPay account: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/banking/customer \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "currency": "USD", "address": { "countryIso": "CL", "city": "Santiago" } }' ``` Response `201`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "customer_id": "9f2b…", "provider_id": "…", "status": "draft", "data": { "item": { "…": "…" } }, "created_at": "2026-07-07T12:00:00Z", "banking_fee": "5.000000" } ``` If your account already has a banking profile — `409 banking_customer_exists`. **Application review.** If your organization enabled banking application review, this request can answer **`202 Accepted`** with `{"status":"in_review","kind":"banking_application","review_id":"…"}` instead of `201` — the profile is created only when compliance approves the review. The banking profile fee is charged when the application is held and **refunded automatically if it is rejected**. Track the result with the webhook `txn_review_status_changed` or in [Transaction reviews](/en/guides/transaction-reviews). Check the state at any time: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/banking/customer \ -H "Authorization: Bearer " ``` ## 2. Documents and verification Upload each document as base64 (free): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/banking/customer/documents \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "type": "PASSPORT", "filename": "passport.pdf", "attach": "" }' ``` Then submit the profile for review (free): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/banking/customer/submit \ -H "Authorization: Bearer " ``` Profile states: `draft` → `submitted` → `under_review` → **`approved`** or `rejected`. The `banking_customer_status_changed` webhook notifies each change — for your own profile (`customer_kind: self`) and for the third parties you register (`customer_kind: third_party`, with their `third_party_id`): ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "…", "customer_id": "9f2b…", "customer_kind": "self", "kyc_status": "approved" } ``` ## 3. Open bank accounts With the profile `approved`, create one account per currency. Available currencies: **USD** (ACH/Fedwire/SWIFT rails) and **EUR** (SEPA/SWIFT): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/banking/accounts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "currency": "USD", "name": "Operating USD" }' ``` Response `201` — `data` carries the details to **receive** funds (account number/IBAN, routing, bank): ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "c4d1…", "provider_id": "…", "status": "active", "data": { "…": "…" }, "banking_fee": "1.000000" } ``` List your accounts, fetch the detail of a specific account, and check balances: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/banking/accounts \ -H "Authorization: Bearer " curl https://api.qbank.cl/platform/v1/banking/accounts/c4d1… \ -H "Authorization: Bearer " curl https://api.qbank.cl/platform/v1/banking/accounts/c4d1…/balance \ -H "Authorization: Bearer " ``` The detail endpoint (`GET /v1/banking/accounts/{id}`) returns the account LIVE — name, currency, status, and under `data` the **requirements to receive funds** (wire and local rails: bank, account number/IBAN, routing). Use it to show the deposit instructions of a specific account without walking the list: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "c4d1…", "provider_id": "…", "source": "live", "data": { "name": "Operating USD", "currencyCode": "USD", "status": "ACCEPT", "requisites": [ { "type": "SWIFT", "…": "…" }, { "type": "LOCAL", "…": "…" } ] } } ``` `source` tells you where the detail came from: `live` (the bank answered in real time) or `mirror` (the bank could not serve the account at that moment and the last known snapshot is returned — deposit requirements remain available). The list exposes only the accounts **enabled for your operation** according to the corridor configuration. A non-enabled account does not appear in the list and its by-id queries respond `404 not_found`. **Limit for person accounts**: a person account can hold **at most 1 bank account**. Attempting a second one returns `409 banking_account_limit`. Company accounts have no limit. ## Third-party users (companies only) If your account is a **company**, besides your own accounts you can register **third-party banking users** — your end clients (persons or companies) — each with their own identity and verification and **bank accounts in their name**. No limit on third parties or accounts per third party. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR verif["1. Third party KYC/KYB
verification (approved)"] --> reg["2. POST third-parties
(verification_id)"] reg --> docs["Data + documents
auto-fill"] docs --> accts["3. POST /accounts
(accounts in their name)"] ``` ### Registering the third party Registration requires the `verification_id` of an [**approved** KYC/KYB verification](/en/guides/kyc) of the third party — their single identity inside CBPay. The type comes from the verification kind (KYC ⇒ `INDIVIDUAL`, KYB ⇒ `COMPANY`), the data (name, email, address) auto-fills from the verified profile (whatever you send explicitly wins), and the **already-validated documents are re-delivered automatically** to the banking provider. The banking profile fee is charged (refunded if the registration fails): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/banking/third-parties \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "verification_id": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f" }' ``` Response `201`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "third_party_id": "7f2a…", "customer_id": "…", "kind": "third_party", "status": "pending", "verification_id": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f", "documents_synced": 2, "registered_at": "2026-07-10T15:00:00Z", "banking_fee": "1.000000" } ``` `documents_synced` counts the verification documents that were loaded automatically into the third party's banking profile. If one could not be synced (or the bank requests additional categories), upload it through the manual document flow below and then `submit`. **Application review.** With banking application review enabled, registering a third party can also answer **`202 Accepted`** (`kind: banking_application`): the third party is registered only when the review is approved, and the registration fee is refunded automatically on rejection. Track it via `txn_review_status_changed` or [Transaction reviews](/en/guides/transaction-reviews). Save the `third_party_id`: every third-party route uses it. List and fetch (the GET carries the live verification status): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/banking/third-parties?page=1&page_size=50" \ -H "Authorization: Bearer " curl https://api.qbank.cl/platform/v1/banking/third-parties/7f2a… \ -H "Authorization: Bearer " ``` ### Third-party verification (free) Same as your own profile, but on the third party: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/banking/third-parties/7f2a…/documents \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "type": "PASSPORT", "file_base64": "…" }' curl -X POST https://api.qbank.cl/platform/v1/banking/third-parties/7f2a…/submit \ -H "Authorization: Bearer " ``` ### Third-party accounts Once the third party is approved, open accounts for them (same `banking_account` fee) and operate just like your own: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/banking/third-parties/7f2a…/accounts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "currency": "USD", "name": "Carlos account" }' curl https://api.qbank.cl/platform/v1/banking/third-parties/7f2a…/accounts \ -H "Authorization: Bearer " curl https://api.qbank.cl/platform/v1/banking/third-parties/7f2a…/accounts/{bankAccountID}/balance \ -H "Authorization: Bearer " ``` * Each third party belongs to you and only you: another CBPay account can never see or operate it (it gets `404`). * A **person** account attempting to create third parties receives `403 company_required`. * Without `verification_id` (or with a non-approved verification) the registration answers `422 verification_required` / `422 verification_not_approved`. If you send a `type` that does not match the verification kind, `422 verification_kind_mismatch`. Third parties created before this rule keep operating normally. * Registered third parties feed the "new users" metric of your [account summary](/en/guides/analytics). ## 4. Register beneficiaries To pay third parties, first register the beneficiary with their banking details (free; it goes through moderation before it can be used): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/banking/counterparties \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "description": "ACME supplier", "profile": { "name": "ACME LLC", "address": { "addressLine1": "1 Main St", "city": "New York", "stateIso": "NY", "countryIso": "US", "postalCode": "10001" }, "additionalInfo": { "type": "CORPORATION" } }, "accounts": [ { "currencyCode": "USD", "bank": { "name": "Test Bank", "number": "011000138" }, "fiat": { "number": "0532013000", "routingNumber": "011000138", "additionalInformation": { "type": "TYPE_FIAT_US", "accountType": "CHECKING", "supportedRails": ["ACH"] } } } ] }' ``` List yours with `GET /v1/banking/counterparties` and attach more accounts to an existing beneficiary with `POST /v1/banking/counterparties/{id}/accounts`. ## 5. Send payments Two operation types: | `type` | What it does | `paymentType` | | ---------- | ------------------------------ | ------------------------- | | `TRANSFER` | Between your own bank accounts | `EMPTY` | | `WITHDRAW` | To a registered beneficiary | Per rail (e.g. `SEPA_CT`) | Quote first (free, moves no money): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/banking/operations/prepare \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "currency": "USD", "type": "WITHDRAW", "paymentType": "SEPA_CT", "sourceRequisit": { "account": "c4d1…" }, "destinationRequisit": { "beneficiar": "" }, "amount": { "currencyCode": "USD", "units": "250", "nanos": 0 } }' ``` Execute with an idempotency key (the rail fee — or the legacy `banking_operation` fee when the rail has no configuration — is charged here): ```bash WITHDRAW (to a beneficiary) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/banking/operations \ -H "Authorization: Bearer " \ -H "Idempotency-Key: acme-payment-0071" \ -H "Content-Type: application/json" \ -d '{ "currency": "USD", "type": "WITHDRAW", "paymentType": "SEPA_CT", "sourceRequisit": { "account": "c4d1…" }, "destinationRequisit": { "beneficiar": "" }, "amount": { "currencyCode": "USD", "units": "250", "nanos": 0 }, "comment": "Invoice 8841" }' ``` ```bash TRANSFER (between your accounts) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/banking/operations \ -H "Authorization: Bearer " \ -H "Idempotency-Key: internal-move-0012" \ -H "Content-Type: application/json" \ -d '{ "currency": "USD", "type": "TRANSFER", "paymentType": "EMPTY", "sourceRequisit": { "account": "c4d1…" }, "destinationRequisit": { "account": "" }, "amount": { "currencyCode": "USD", "units": "100", "nanos": 0 } }' ``` Response `202`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "operation_id": "7e8a…", "provider_id": "…", "status": "pending", "idempotency_key": "platform:…:acme-payment-0071", "data": { "…": "…" }, "banking_fee": "2.000000", "banking_fee_asset": "BANK_USD" } ``` `banking_fee` and `banking_fee_asset` only appear when a fee was charged. With a per-rail fee the asset is the operation currency (`BANK_USD` / `BANK_EUR`); with the legacy fallback it is `USDT`. * The final state arrives through the `banking_operation_status_changed` webhook (`completed` / `failed`); you can also poll `GET /v1/banking/operations/{id}`. Once the operation reaches a final state, the webhook includes its `receipt_url` and you can download the PDF receipt with `GET /v1/banking/operations/{id}/receipt` ([receipts](/en/guides/receipts)). * Retries with the same `Idempotency-Key` return the original operation (`idempotency_hit: true`) **without charging the fee again**. **Complete traceability.** Every banking operation is recorded on your account: it shows up in the `banking_operations` section of the [statement](/en/guides/statement), its money reconciles in the `BANK_USD`/`BANK_EUR` mirror balances (`assets` section), and its volume adds to the `gross_volume` in [analytics](/en/guides/analytics). The authoritative balance remains the bank's: the mirror is reconciled periodically. The full history, with filters: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/banking/operations?from=2026-07-01&to=2026-07-08&status=completed&type=WITHDRAW&page_size=50" \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "items": [ { "id": "7e8a…", "type": "withdraw", "status": "completed" } ], "meta": { "page": 1, "page_size": 50, "retrieved": 1 } } ``` Every banking operation — including inbound deposits and bank fees discovered automatically from the bank — exposes its `direction` (`in` / `out`), net `amount`, `currency`, `counterparty` and `reference` whenever the bank reports them. These fields are optional and appear in `GET /v1/banking/operations` and `GET /v1/banking/operations/{id}`. The `banking_operation_status_changed` webhook stays lightweight by design: it carries the identifiers and the new status only, never the enriched fields. When it fires, fetch the operation detail to read the direction, amount, counterparty and reference. See [webhooks](/en/webhooks). ## Rail fees (deposits and transfers) On top of the standalone fixed fees, banking supports **transactional fees per rail** — a percentage plus a fixed amount, always charged **in the operation currency** (`BANK_USD` / `BANK_EUR`), never in USDT: | Service | Applies to | When it is charged | | ------------------------ | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `banking_deposit` | Incoming deposits (USD/EUR) | When the deposit is credited — **capped at the deposit amount** (`fee = min(fee, amount)`), so a small deposit never goes negative | | `banking_transfer_ach` | Outgoing ACH transfers | At dispatch, with a fail-closed balance check | | `banking_transfer_swift` | Outgoing SWIFT transfers | At dispatch, with a fail-closed balance check | | `banking_transfer_wire` | Outgoing wire transfers (FEDWIRE) | At dispatch, with a fail-closed balance check | | `banking_transfer_sepa` | Outgoing SEPA transfers | At dispatch, with a fail-closed balance check | For **transfers** the available balance must cover `amount + fee` — if it does not, the API answers `402 insufficient_funds` and the operation is **not created**. If the operation is definitively rejected right after dispatch, the fee is **refunded automatically** (same discipline as the legacy fee). **Fallback:** if the rail has no specific configuration (neither at account nor at platform level), the legacy `banking_operation` fee (fixed, in USDT) applies. A rail configured with 0% + 0 fixed is **explicitly free** — it does *not* fall back to the legacy fee. ## Operation statuses | Status | Meaning | | ------------ | ----------------------------------------------- | | `pending` | Accepted, awaiting processing | | `processing` | Executing on the banking rail | | `completed` | The money arrived — final | | `failed` | Failed; the operation fee (if any) was refunded | | `cancelled` | Cancelled before execution | ## Errors | HTTP | `error` | What to do | | ---- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `idempotency_key_required` | Send the key in body or header | | 402 | `insufficient_funds` | Not enough balance: with a per-rail fee the check is `balance ≥ amount + fee` in the **operation currency** (`BANK_USD`/`BANK_EUR`); with the legacy fallback it is your USDT balance | | 403 | `account_blocked` | The account is not active; contact the CBPay team | | 409 | `banking_customer_exists` | Your account already has a banking profile (`GET /v1/banking/customer`) | | 409 | `no_banking_customer` | Create your profile first (`POST /v1/banking/customer`) | | 409 | `banking_account_limit` | Person accounts can hold at most 1 bank account | | 403 | `company_required` | Third-party users are available for company accounts only | | 422 | `verification_required` | Third-party registration requires the `verification_id` of an approved verification ([verify first](/en/guides/kyc)) | | 422 | `verification_not_approved` | The referenced verification is not approved yet; wait for approval | | 422 | `verification_kind_mismatch` | The `type` sent does not match the verification kind (KYC ⇒ INDIVIDUAL, KYB ⇒ COMPANY) | | 422 | `verification_invalid` | You referenced your onboarding verification; the third party needs their own | | 404 | `not_found` | The third party (or the verification) does not exist or does not belong to your account | | 502 | `banking_request_failed` | Banking corridor error; the fee was refunded — retry | The general error catalog lives in [Errors](/en/errors). ## FAQ No. Banking money lives in your bank accounts and is queried with `GET /v1/banking/accounts/{id}/balance`. The authoritative balance is the bank's; your [statement](/en/guides/statement) reconciles it in the `BANK_USD`/`BANK_EUR` mirror balances. **Per-rail fees** are charged in the operation currency (your `BANK_USD`/`BANK_EUR` balance); only the legacy `banking_operation` fallback fee is debited from your USDT balance. It is refunded automatically — profile, account and operation fees alike, including per-rail fees (refunded on the definitive synchronous rejection). A retry with the same `Idempotency-Key` returns the original operation (`idempotency_hit: true`) and never charges twice. One per currency (USD, EUR). Additionally, **person** accounts can hold at most 1 bank account in total (`409 banking_account_limit`); company accounts have no limit. The list only exposes accounts **enabled for your operation** per the corridor configuration. A non-enabled account does not appear and its by-id queries answer `404 not_found` — contact your CBPay team if you need it enabled. No — third parties are a company feature (`403 company_required`). Registration also requires the `verification_id` of an **approved** KYC/KYB verification of the third party. Subscribe to `banking_operation_status_changed`: it fires on `completed` / `failed` and includes the `receipt_url` once final. You can also poll `GET /v1/banking/operations/{id}`. # Cards: virtual and physical Source: https://docs.cbpayapp.com/en/guides/cards Issue cards that spend straight from any of the account's balances (USDT, USDC, BTC or GOLD), with per-card limits CBPay cards spend **Just-In-Time from the account's central balance**: no prefunding, no moving balance around. Each card picks which balance it spends from (`spending_asset`: **USDT, USDC, BTC or GOLD**). USDT/USDC are 1:1 with the USD; BTC and GOLD convert **at the price of the moment of each event**. Every purchase is authorized in real time against that asset's available balance and the card's own limits, and the debit shows up immediately in the movement history. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR purchase["Purchase at a merchant
(POS / e-commerce / ATM)"] --> network["Card network"] network --> jit{"Real-time JIT
authorization"} jit -->|"balance & limits OK"| debit["Debit on the chosen balance
+ hold"] jit -->|"insufficient / limit /
frozen / no price"| declined["Purchase declined
(audited reason)"] debit --> clearing{"Settlement
(1-2 days)"} clearing -->|"confirmed"| settle["Hold consumed
(BTC/GOLD: re-quoted at capture)"] clearing -->|"annulled"| refund["Funds returned
to the same balance"] ``` ## How many cards you can hold | Account type | Virtual | Physical | For third parties? | | ------------ | ------------- | ------------- | ---------------------------------------- | | Person | **1** | **1** | No | | Company | **Unlimited** | **Unlimited** | Yes: designated persons (e.g. employees) | Each card spends from the **account's central balance in its configured asset** (`spending_asset`, USDT by default). Fine-grained control is per-card spending limits (per transaction, daily, monthly), always measured in **USD**, which you can change at any time. ## Choosing the spending balance (USDT, USDC, BTC or GOLD) Set `spending_asset` when creating the card or change it later with `PATCH`. It only affects future purchases: in-flight authorizations keep the asset they were debited in (and their reversal returns that same asset). ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X PATCH https://api.qbank.cl/platform/v1/cards/{card_id} \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "spending_asset": "BTC" }' ``` **USDT and USDC** are both worth 1 USD, so the conversion is exact 1:1 with no exchange fee: a 25.00 USD purchase debits 25.000000 of the chosen asset. **BTC and GOLD** convert at the effective price of the moment of each event (the same settlement price you see in `GET /v1/rates`, `settlement` block): * **Authorization**: the purchase's equivalent in your asset is reserved **plus a small cushion** (not a charge: it covers price drift until clearing and is returned at capture). If the execution price is not available at that moment, the purchase is **declined** (`pricing_unavailable`) — your balance is never converted with an untrustworthy price. * **Settlement (capture)**: the final amount is re-converted at the price of the capture moment; the cushion's excess returns to your balance (or the difference is debited if the price moved beyond the cushion). * **Reversal of an authorization**: the EXACT reserved amount is returned, with no conversion. * **Refunds and adjustments after capture**: re-converted at the price of the moment of the event. The price may move between the purchase and the refund — you receive the equivalent in your asset at that moment's price, not the original quantity. * BTC/GOLD purchases share your account's **volatile-asset limits** (per operation and 24h volume, visible in `GET /v1/settlement`). | Error / decline | Where | Cause | Solution | | --------------------------------- | ------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------- | | `spending_asset_unavailable` | 400 on PATCH / purchase decline | The asset does not exist or is not enabled for purchases | Use `USDT`, `USDC`, `BTC` or `GOLD` | | `settlement_asset_disabled` | 400 on PATCH | Your operator disabled that asset | Check `GET /v1/settlement` (`enabled_assets`) | | `pricing_unavailable` | Purchase decline (BTC/GOLD) | Execution price unavailable at authorization | Retry the purchase; if it persists, switch to USDT/USDC | | `settlement_limit_exceeded` | Purchase decline (BTC/GOLD) | The purchase exceeds the per-operation limit for volatile assets | Smaller purchase, or spend from USDT/USDC | | `settlement_daily_limit_exceeded` | Purchase decline (BTC/GOLD) | The account reached its 24h volatile-asset volume | Wait, or spend from USDT/USDC | If the chosen asset's balance is short, the purchase is declined with `insufficient_funds` — there is no automatic fallback to another balance. With BTC/GOLD your balance is exposed to price moves between a purchase's events (authorization, capture, refund). Every conversion uses the effective price of its moment — CBPay never re-quotes amounts backwards nor deducts "just in case": the authorization cushion is always returned at settlement. ## Costs (configured by your operator, can be 0) | Service | When it is billed | | ------------------------ | --------------------------------------------------------------------------- | | `card_creation_virtual` | When issuing a virtual card | | `card_creation_physical` | When issuing a physical card | | `card_purchase_virtual` | **Per purchase** with a virtual card (percent + fixed over the USD amount) | | `card_purchase_physical` | **Per purchase** with a physical card (percent + fixed over the USD amount) | | `card_monthly` | Monthly fee per active card (with no balance, the card is frozen — no debt) | | `card_cancellation` | When cancelling a card | Exact amounts come from `GET /v1/rates` (`fees` field). Every issuance charge is **automatically refunded** if issuance fails. ### Per-purchase fee (lifecycle) The per-transaction fee follows the same lifecycle as the purchase: * **Authorization**: the estimated fee is reserved **inside the hold** together with the purchase amount, in the card's `spending_asset` (the percent applies over the purchase's USD amount; in BTC/GOLD it converts with the same event price). * **Settlement**: the fee is **recalculated** with the configuration in force at that moment and the definitive one is charged (`card_fee` in your movements); the difference against the estimate is released or charged together with the cushion adjustment. * **Reversals and downward adjustments**: the fee is **prorated back** to the refunded fraction of the purchase (`card_fee_refund`). If your operator changes the percent between authorization and settlement, the settlement fee is charged — the same criterion as the BTC/GOLD price per event. Declined purchases **charge no fee**. ## Create a card The flow depends on whether your account is a **person** or a **company** — pick your tab. The common rule: the **cardholder is verified ONCE per account**, on the first issuance; subsequent cards reuse it with no data. The `idempotency_key` is always required (a retry with the same key returns the original card and never double-charges). A person account issues cards **for itself** (at most 1 virtual + 1 physical). **Your first card** creates and verifies your holder at the issuer. Since your account already approved its [identity verification](/en/guides/kyc), your data and documents **auto-fill from your verification** — you only add the issuer-specific fields (`occupation`, `salary_usd`); any field you send explicitly wins over the autofill: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/cards \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "physical": false, "idempotency_key": "card-v-1", "cardholder": { "occupation": "52201", "salary_usd": 1800 } }' ``` `occupation` is a **catalog code** ([see below](#occupation-and-business-activity-catalog-codes)) and `salary_usd` is whole US dollars. If your verification was done through the wizard without some data or document the issuer requires, add it explicitly to the `cardholder` (`first_name`, `email`, `address`, `id_front_url`…, same format as always). **Your second card** (the physical one, for example) asks for no data — your holder is already verified: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/cards \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "physical": true, "idempotency_key": "card-f-1" }' ``` A third card of the same type responds `409 card_limit_reached` (cancel the existing one first). A company account issues **unlimited cards**, in two modes: **A. For the company itself** (corporate cards). The first issuance creates the company holder with corporate data; later ones need nothing: ```bash First card (creates the company holder) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/cards \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "physical": false, "idempotency_key": "card-corp-1", "cardholder": { "kind_of_business": "J63", "legal_representation": "Carlos Soto, General Manager", "email": "finance@andina.cl", "certificate_of_good_standing_url": "https://files.example.com/kyb/standing.pdf", "business_license_url": "https://files.example.com/kyb/license.pdf", "register_shareholder_url": "https://files.example.com/kyb/shareholders.pdf", "id_shareholders_url": "https://files.example.com/kyb/shareholder-ids.pdf", "address_verification_shareholders_url": "https://files.example.com/kyb/addresses.pdf", "address": { "line1": "Av. Apoquindo 4500", "city": "Santiago", "region": "RM", "postal_code": "7550000", "country": "CL" } } }' ``` ```bash Subsequent cards (no data, with limits) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/cards \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "physical": true, "idempotency_key": "card-f-ops-1", "limits": { "per_transaction": "500.00", "monthly": "5000.00" } }' ``` **B. For a designated person** (e.g. an employee): add `cardholder.kind: "person"` with the `verification_id` of THAT person's [**approved** KYC](/en/guides/kyc) — their identity and documents come from the verification; you only add the issuer fields: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/cards \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "physical": false, "idempotency_key": "card-emp-77", "limits": { "monthly": "1500.00" }, "cardholder": { "kind": "person", "verification_id": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f", "occupation": "52201", "salary_usd": 1800 } }' ``` * Without `verification_id` (or with a non-approved verification): `422 verification_required` / `422 verification_not_approved`. The verification must be KYC (person); a KYB answers `422 verification_kind_mismatch`. * Explicit `cardholder` fields win over the autofill (useful when the issuer requires a document the verification does not have). * The printed name uses `first_name` + `last_name` (22 characters combined max) and the response carries `cardholder_kind: "person"` plus the `verification_id` used. Response (same shape in every case): ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "card_id": "3c2b1a09-8d7e-6f5a-4b3c-2d1e0f9a8b7c", "account_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "physical": false, "cardholder_kind": "account", "status": "active", "spending_asset": "USDT", "limits": { "monthly": "5000.000000" }, "created_at": "2026-07-08T12:00:00Z", "updated_at": "2026-07-08T12:00:00Z", "creation_fee": "3.000000" } ``` **Application review.** If your organization enabled card application review, `POST /v1/cards` can answer **`202 Accepted`** with `{"status":"in_review","kind":"card_application","review_id":"…"}` instead of `201` — the card is issued only when compliance approves the review. The creation fee is charged when the application is held and **refunded automatically if it is rejected**. Track the result with the webhook `txn_review_status_changed` or in [Transaction reviews](/en/guides/transaction-reviews). You can pin the spending balance from the start by adding `"spending_asset": "USDC"` to the creation body (USDT if omitted). Documents are **actually validated** by the issuer: URLs must point to legitimate, reachable documents. If they are missing or insufficient, issuance fails (`422 core_rejected` or `409 cardholder_kyc_pending`), **the fee is refunded automatically** and you can retry with corrected data. Person or company? The differences between both account types across ALL products are summarized in [persons and companies](/en/concepts/persons-companies). ### Occupation and business activity (catalog codes) When designating a **person**, `occupation` must be a **code** from the official catalog (not free text); for a **company**, so must `kind_of_business`. Look them up (searchable with `?q=`): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Occupations (persons) curl "https://api.qbank.cl/platform/v1/cards/catalog/occupations?q=director" \ -H "Authorization: Bearer " # Business activities (companies) curl "https://api.qbank.cl/platform/v1/cards/catalog/business-activities?q=software" \ -H "Authorization: Bearer " ``` Each item is `{ "code": "...", "label": "..." }`. Use the `code` in `occupation` / `kind_of_business`. An out-of-catalog value returns `400 invalid_occupation` or `400 invalid_kind_of_business` before reaching the issuer. `salary_usd` is in **dollars** (integer). ## Physical cards: activation A physical card is born `pending_activation` and travels **inactive** for security. Once the holder has it in hand: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/cards/{card_id}/activate \ -H "Authorization: Bearer " ``` ## Reveal PAN and CVV (sensitive data) Only the **owning account** can reveal them (never the org admin). The response is one-shot: display it to the holder and discard it. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/cards/{card_id}/reveal \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "card_id": "3c2b1a09-8d7e-6f5a-4b3c-2d1e0f9a8b7c", "pan": "5339880000001234", "cvv": "123", "exp_date": "202907", "note": "sensitive data: display once, never store" } ``` **Never store or log the PAN/CVV.** CBPay does not persist it either: the response comes straight from the issuer (PCI standard). ## Limits and freeze/unfreeze ```bash Update limits theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X PATCH https://api.qbank.cl/platform/v1/cards/{card_id} \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "limits": { "per_transaction": "200.00", "daily": "0" } }' ``` `"0"` removes a limit. To freeze (declines every purchase instantly): ```bash Freeze theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X PATCH https://api.qbank.cl/platform/v1/cards/{card_id} \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "frozen": true }' ``` ## Transactions and their lifecycle ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/cards/{card_id}/transactions?from=2026-07-01&to=2026-07-08&page=1&page_size=50" \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "page": 1, "page_size": 50, "transactions": [ { "transaction_id": "7a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", "card_id": "3c2b1a09-8d7e-6f5a-4b3c-2d1e0f9a8b7c", "kind": "purchase", "merchant": "MERPAGO*SUPERMERCADO", "mcc": "5411", "amount_usd": "25.00", "amount_usdt": "25.000000", "spend_asset": "USDC", "spend_amount": "25.000000", "fee_asset": "USDC", "fee_amount": "0.300000", "status": "settled", "decline_reason": "", "auth_number": "123456", "created_at": "2026-07-09T15:04:05Z", "updated_at": "2026-07-10T09:00:00Z" } ] } ``` `spend_asset` / `spend_amount` show which balance the purchase actually debited and how much in that asset (`amount_usd` / `amount_usdt` remain the USD reference value). For BTC/GOLD, an authorized transaction's `spend_amount` includes the reserve cushion; after settlement it shows the final amount. `fee_asset` / `fee_amount` are the asset and amount of the per-purchase fee (definitive once `settled`; estimated while `authorized`). `fee_refunded_amount` appears once a refund applies from reversals or downward adjustments. When your operator does not configure a per-purchase fee, the `fee_*` fields are **not included** in the transaction — the historical behavior is unchanged. | Status | Meaning | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `authorized` | Approved in real time: the amount left the chosen balance's available and sits in a hold | | `settled` | Confirmed at network settlement (the hold is consumed; BTC/GOLD re-quoted at the capture moment) | | `reversed` | Annulled: funds returned to the same balance (exact amount if not settled; re-converted at the moment's price if it had settled) | | `declined` | Rejected, with the reason: `insufficient_funds`, `card_limit_exceeded`, `card_frozen`, `account_blocked`, `spending_asset_unavailable`, `spending_asset_disabled`, `pricing_unavailable`, `settlement_limit_exceeded`, `settlement_daily_limit_exceeded` | If settlement arrives for a different amount than authorized (tips, merchant conversion), the adjustment is applied automatically: positive debits the difference, negative returns it. ## Cancel a card Irreversible. Bills `card_cancellation` when configured. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/cards/{card_id}/cancel \ -H "Authorization: Bearer " ``` ## Webhooks | Event | When | | --------------------- | -------------------------------------------------------------------------- | | `card_transaction` | A purchase was authorized, annulled or adjusted | | `card_status_changed` | The card changed state (including automatic freeze for unpaid monthly fee) | Subscribe the same way as every other event (see [Webhooks](/en/webhooks)). ## FAQ No. Cards hold no balance of their own: every purchase is authorized in real time against the account's balance (in the card's spending asset). If the balance covers it and the purchase respects the limits, it is approved. They all spend from the account's central balances. Each authorization debits atomically: you can never spend more than the asset's available balance, no matter how many cards operate in parallel. Purchases are processed in USD and debited from the card's configured balance (`spending_asset`). USDT/USDC are 1:1 with the dollar, with no conversion fee; BTC and GOLD convert at the effective price of the moment of each event (the same one in the `settlement` block of `GET /v1/rates`). Yes: `spending_asset` is per card. A company can have, for example, corporate cards spending USDT, employee cards spending USDC and a personal one spending BTC. Changing it with `PATCH` only applies to future purchases. Network settlement arrives 1-2 days after the authorization, and the BTC/gold price can move in between. That is why the authorization reserves the purchase's equivalent plus a small percentage. It is not a charge: at settlement, the purchase is re-converted at that moment's price and everything reserved in excess automatically returns to your balance. The purchase is declined (`pricing_unavailable`) — CBPay never converts your balance with an untrustworthy price. It is a transient condition (degraded price feed): retry in a few minutes or switch the card to USDT/USDC. Events that cannot be declined (the settlement of an already-approved purchase, a refund) are never blocked: they are processed with the last known price plus a prudential margin, audited in the movement. Refunds convert at the price of the refund moment, not the purchase's: you receive the equivalent in your asset of the refunded USD amount. If BTC went up since the purchase you receive less BTC (same USD value); if it went down, more. Your BTC/GOLD balance is always exposed to the price — that is the nature of spending from a volatile asset. The authorization reserves an estimated fee inside the hold. At settlement the fee is recalculated with the configuration in force at that moment: if your operator changed the percent between both events, the settlement fee applies. The difference against the estimate is released or charged together with the purchase adjustment — never as a separate movement. The card is frozen automatically (`card_status_changed` event with `reason: monthly_fee_unpaid`). No debt accrues; once the balance is topped up, unfreeze it with `PATCH { "frozen": false }`. Company accounts can issue for any designated person. That person must have an [approved KYC verification](/en/guides/kyc) — you pass their `verification_id` in the `cardholder` and their data and documents auto-fill. The card always spends from the balance of the issuing company account. The general error catalog lives in [Errors](/en/errors). # Checkout Source: https://docs.cbpayapp.com/en/guides/checkout One universal payment link: fiat in every live country, crypto, cards and the CBPay app - settled in the balance you choose Create a **universal checkout link**: a single `POST /v1/payins` with `method: "checkout"` returns a branded public URL where the payer chooses how to pay. The charge is denominated in the **virtual balance you choose** (`settlement_asset`: `USDT`, `USDC`, `BTC` or `GOLD`, default `USDT`) and every payment is converted **automatically** to that balance when it credits — unless the payer pays in the same asset, in which case there is no conversion. The page organizes the payment into **four tabs**: * **CBPay** — direct payment with the app: the merchant's alias and QR; scanning with the app pays instantly through an internal transfer, in any of the 4 balances. * **Crypto** — the available coins grouped by network (today USDT on TRON and Ethereum, USDC on Ethereum and BTC; new networks show up on their own once enabled), each with a deposit address exclusive to that charge and a **scannable QR** compatible with external wallets (Trust Wallet, MetaMask, Binance and similar apps). * **Fiat** — the payer picks their country among **every country with a live payin corridor** and sees the available methods (QR, bank transfer, hosted payment) with the local amount quoted on the spot. * **Card** — credit or debit card payment on a secure hosted page, listed **by charge currency** (today BOB and USD; currencies from future acquirers show up on their own). Each currency is an independent payment option with its own quoted amount. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR M[You create the link: 50 USDT or 0.001 BTC] --> P[Public page] P --> C1[Crypto: address + QR with the quoted due] P --> C2[Country + fiat method: quoted local amount] P --> C3[CBPay app: merchant alias + QR] P --> C4[Card: hosted page in the chosen currency] C1 --> S{same asset as settlement?} C2 --> S C3 --> S C4 --> S S -->|yes| FIN[Stays in the chosen balance] S -->|no| SW[Automatic conversion to the settlement_asset] SW --> FIN ``` ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "method": "checkout", "amount": "50", "settlement_asset": "USDT", "description": "Order 8841", "country": "CL", "success_url": "https://your-app.com/payment/ok", "failure_url": "https://your-app.com/payment/error", "expires_in": 86400, "idempotency_key": "order-8841" }' ``` * `amount` is denominated **in the `settlement_asset`**: `"50"` with `USDT` means 50 USDT; `"0.001"` with `BTC` means 0.001 BTC; `"2"` with `GOLD` means 2 grams of gold. Do not send `currency` — that is the old contract and responds `400` (the charge is no longer tied to a local currency). * `country` is **optional** and only preselects the country on the page; the payer can change it. * `GOLD` has no payment rail of its own: the charge is always reached through automatic conversion from whatever the customer pays with. Response `201`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "d0135ed5-8e9c-4f8b-a522-8ec100470426", "kind": "checkout", "status": "pending", "settlement_asset": "USDT", "asset_amount": "50", "country": "CL", "description": "Order 8841", "reference": "CB68JZCT46QE", "checkout_url": "https://api.qbank.cl/platform/pay/fc4981b8e7c7…", "expires_at": "2026-07-17T17:57:44Z", "receipt_url": "https://api.qbank.cl/platform/v1/payins/d0135ed5-…/receipt" } ``` Share the `checkout_url` (link, email, WhatsApp, printed QR). The page requires no login, carries your organization's branding and updates by itself: once the payment is confirmed through any method it shows "paid" and redirects to your `success_url` if you set one. ## How each rail pays * **Multi-country fiat**: the payer picks a country and a method; the local amount is quoted on the spot (target → USD → local currency using your corridor's `payin_rate`, rounded up) and is **frozen** when the method is chosen. The credit arrives with the normal payin conversion and fees, and is then converted to the `settlement_asset`. An announced payment SMALLER than the frozen quote still credits (the money is real) but does **not** mark the link as paid. When a country offers the same method in **several currencies** (e.g. Bolivia with QR in BOB and in USD), the page lists each currency as an independent option. On bank transfers in Mexico the link issues a **dedicated CLABE exclusive to that charge**: the payer transfers the exact amount **with no reference needed** — the deposit is detected and settled automatically because the account itself identifies the link. If a dedicated account cannot be issued at that moment, the page degrades to the classic path (the merchant's general account plus a mandatory reference in the transfer description). * **Pull collections (Venezuela)**: `c2p` and `debito_inmediato` charge the payer's account directly. The page asks for bank, document, phone (C2P) or account (immediate debit) and the OTP — generated in their banking app for C2P, or sent on demand for immediate debit ("Request key" button). The amount is ALWAYS the one frozen at quote time; if the rail confirms synchronously the link is paid instantly. A rejection does not kill the link: the payer fixes the data or picks another method. * **Card (multi-currency)**: the tab lists every available charge currency with its quoted amount; picking one opens the hosted payment page in that currency. Each currency is an independent materialization (you can quote BOB and USD on the same link; the first one to complete pays it). On the payment page the payer can use a **saved card**: they type their email, verify it with a code and pick one — with "Remember this device" they skip the code for 30 days (see [stored cards](/en/guides/stored-cards-subscriptions#the-payer-discovers-their-cards-on-the-payment-page)). * **Crypto (wallet per charge)**: choosing a currency generates an exclusive address with its `qr_payload` and `qr_png_base64` — the QR always carries the raw address (BTC bech32, TRON base58, ETH hex) for maximum wallet and exchange compatibility (Binance and similar apps reject BIP-21/EIP-681 URIs); the exact amount is shown next to it with a copy button. If the paid asset differs from the `settlement_asset`, the quoted due **already includes the conversion** (the payer covers it; you receive your exact target). Partial payments accumulate and the page shows what's missing. Quotes involving BTC/GOLD refresh every 15 minutes. * **CBPay app**: the merchant QR embeds the link (`cbpay:pay?to=…&checkout=…`). The app pays through an internal transfer in any of the 4 balances: same asset ⇒ exact target; different ⇒ due with the conversion included. The amount is validated server-side against a fresh quote — if it does not cover the charge it responds `422 checkout_amount_mismatch` with the current due. Integrators: `POST /v1/transfers` accepts the optional `checkout_token` field (or the extended QR in `to_qr_token`); the destination is forced to the link's account. ## Automatic conversion to the chosen balance Every credit in an asset different from the `settlement_asset` is converted with your account's conversion engine (same spreads and limits as `POST /v1/swaps`). The aggregate state travels in `conversion_status`: | `conversion_status` | Meaning | | ------------------- | --------------------------------------------------------------------------------------------------------------------------- | | *(absent)* | No conversion happened (you were paid in the same asset) | | `done` | Every conversion of the link executed | | `pending_retry` | A conversion failed temporarily (price unavailable or limit); funds stay in the received asset and it retries automatically | ## Public link endpoints (no auth, rate-limited) * `GET {checkout_url}/state` — link state: `status`, `paid_method`, `settlement_asset`, `asset_amount`, frozen fiat materializations (`fiat_methods`), crypto progress (`crypto` with `due`/`received`) and `conversion_status`. * `GET {checkout_url}/quote` — quotes BEFORE choosing: `countries` (catalog per country; each country lists its corridors in `options[]` — one row per method+currency, with `collect: true` on pull methods), `cards` (card options per country and currency with their `local_amount`), `crypto` (indicative due per pair) and `cbpay` (alias + dues per asset). With `?country=XX` it adds `country_quote` with that country's local amount per option. * `POST {checkout_url}/methods/{method}` — materializes the chosen option. Fiat methods require `?country=XX`; when the country offers the method in more than one currency (cards, QR BOB/USD in Bolivia) it also requires `¤cy=YYY`; crypto uses `crypto::` (e.g. `crypto:tron:usdt`) without a country. Pull methods return the payer form (`banks[]`, `requires_otp_request`) with the frozen quote. Re-POSTing the same combination returns the SAME materialization. * `POST {checkout_url}/collect/otp` — requests the OTP of a pull collection when the rail sends it on demand (`requires_otp_request: true`, e.g. VE immediate debit). Returns the `otp_reference` that accompanies the final charge. Strictly rate limited (each call is a real SMS/push). * `POST {checkout_url}/collect` — runs the pull charge with the payer's data (bank, document, phone or account, OTP). The amount is always the frozen one; if the rail confirms synchronously it responds `paid: true` and the link is settled in the same call. Useful if you prefer to render your own payment page on top of the same link. ## Link rules * **One link = one charge**: the first method that completes the payment wins; a later payment through another rail is not credited (picking a method does NOT lock the others while nobody has paid). * `expires_in` accepts 600 to 604800 seconds (10 minutes to 7 days; default 24 hours). If it expires unpaid the payin flips to `expired` and you receive the `payin_expired` webhook. * A retry with the same `idempotency_key` returns the **same link** (the URL never changes); a second charge is never opened. * The `settlement_asset` must be enabled for your organization; if it is disabled the creation responds `422 settlement_asset_disabled`. When the charge is paid you receive the `payin_credited` with `settled_via` (e.g. `crypto:tron:usdt`, `qr`, `cbpay`), `settlement_asset` and `asset_amount`; crypto payments add `crypto_amount` and CBPay app payments add `transfer_id`, `asset` and `amount`. In `GET /v1/payins` and `GET /v1/payins/{payin_id}` checkout payins always carry their denomination — `settlement_asset` + `asset_amount` — in every status (pending, expired and credited); `currency`/`local_amount` stay empty until a local payment method is used. A charge settled in crypto or via the CBPay app exposes its `usdt_credited` without an `fx_rate` (no FX quote applies). Link-specific errors (seen by whoever opens the page): | HTTP | `error` | Meaning | | ---- | -------------------------- | ------------------------------------------------------------------------------------- | | 404 | `not_found` | Invalid token or non-existent link | | 400 | `country_required` | Fiat method without `?country=XX` | | 400 | `currency_required` | The country offers the method in several currencies; `?currency=YYY` is missing | | 409 | `already_paid` | The link was already paid through another method | | 410 | `checkout_expired` | The link expired unpaid | | 422 | `method_unavailable` | That method is not available for this link or country | | 422 | `country_unavailable` | That country has no available payment methods | | 422 | `checkout_amount_mismatch` | The CBPay transfer does not cover the charge's current due | | 422 | `collect_otp_failed` | The rail rejected the OTP delivery (check the data) | | 422 | `collect_rejected` | The rail rejected the pull charge (invalid OTP or wrong data); the link stays pending | | 429 | `too_many_attempts` | Per-IP rate limit of the public page | | 503 | `pricing_unavailable` | Pricing temporarily unavailable; retry in a moment | ## FAQ No. One link = one charge: the first rail that pays wins (`already_paid`, 409\). A crypto deposit that arrives after the link was settled by another rail is **not** credited — it is held for reconciliation. Partial crypto payments **accumulate**: the page shows how much is missing until the quoted amount is covered. Late payments that arrive after the link expired still credit your account. `expires_in` between 600 s and 7 days (default 24 h). When it expires you receive the `payin_expired` webhook and the public page answers `checkout_expired` (410). The funds stay safe in USDT and the payin reports `conversion_status: pending_retry`; the platform retries automatically until the swap succeeds — you never lose money nor get converted twice. Yes. Each method materializes independently; re-requesting the same method returns the same materialization. Whichever rail pays first settles the link. Yes — retry `POST /v1/payins` with the **same** `idempotency_key` and you get the same link back. A new key creates a new, independent link. # Crypto: wallets, deposits and withdrawals Source: https://docs.cbpayapp.com/en/guides/crypto Create on-chain wallets, deposit, transfer and check movements Your crypto balances are connected to the blockchain. Supported combinations: | Network | Asset | Balance credited | | ------- | ------ | ---------------- | | `tron` | `usdt` | USDT | | `eth` | `usdt` | USDT | | `eth` | `usdc` | USDC | | `btc` | `btc` | BTC | Every deposit credits the **balance of its own asset** (a USDC wallet credits your USDC balance; the Bitcoin wallet credits your BTC balance). `GOLD` is the only balance with no on-chain rail: it moves only via internal transfers and operator credits. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR subgraph depositFlow [Deposit] wallet["Your CBPay wallet
(stable address)"] --> confirmed["On-chain
confirmation"] confirmed --> credit["Automatic credit
− funding fee"] end credit --> balance(("Asset balance
(USDT, USDC or BTC)")) subgraph withdrawFlow [Withdraw] balance --> withdrawal["POST /v1/crypto/withdrawals
debits amount + fee"] withdrawal --> onchain{"On-chain
result"} onchain -->|"completed"| txid["tx_id = your receipt"] onchain -->|"failed"| refund["Full automatic
refund"] end ``` ## Your account is born with its wallets Every account — person and company — is created with **one deposit wallet per supported combination** (`tron`/`usdt`, `eth`/`usdt`, `eth`/`usdc` and `btc`/`btc`), **free of charge** and automatically: as soon as you register, your four addresses are ready to receive funds. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Right after creating the account, your addresses already exist: curl https://api.qbank.cl/platform/v1/crypto/wallets \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "page": 1, "page_size": 50, "wallets": [ { "wallet_id": "9d68…", "chain": "tron", "asset": "USDT", "address": "TXMD…", "label": "", "type": "deposit", "receive_only": true, "created_at": "2026-07-11T23:33:20Z" }, { "wallet_id": "a83d…", "chain": "eth", "asset": "USDT", "address": "0xefe0…", "label": "", "type": "deposit", "receive_only": true, "created_at": "2026-07-11T23:33:20Z" }, { "wallet_id": "fb88…", "chain": "eth", "asset": "USDC", "address": "0xa072…", "label": "", "type": "deposit", "receive_only": true, "created_at": "2026-07-11T23:33:20Z" }, { "wallet_id": "c1d4…", "chain": "btc", "asset": "BTC", "address": "bc1qf66…", "label": "", "type": "deposit", "receive_only": true, "created_at": "2026-07-11T23:33:20Z" } ] } ``` Provisioning runs in the background when the account is created: if you query at the very second of registration an address may still be missing — retry a few seconds later. Deposit wallets are **entry doors**, not operating wallets: they only **receive** crypto that credits your virtual balance. They cannot send funds, and cannot be exported or imported (that is what [segregated wallets](/en/guides/segregated-wallets) are for). Two products, two routes: deposit wallets live under `/v1/crypto/wallets` and segregated wallets under `/v1/segregated-wallets`. Every wallet response carries a `type` discriminator (`deposit` / `segregated`) so you can always tell them apart. | Account type | Deposit wallets per network+asset pair | | ------------ | ----------------------------------------------- | | Person | **1** (the birth wallets already take the slot) | | Company | **1** (the birth wallets already take the slot) | ## Can I create more deposit wallets? No. Every account — person and company — holds exactly **one deposit wallet per network+asset pair**, and all of them are born with the account. `POST /v1/crypto/wallets` exists only to restore a missing pair (an exceptional case): with the four wallets already provisioned it responds `422 wallet_limit_reached`. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/crypto/wallets \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "chain": "eth", "asset": "usdc" }' ``` With the pair already provisioned — `422`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "error": "wallet_limit_reached", "message": "accounts hold one deposit wallet per network/asset pair (created automatically with the account); use segregated wallets for additional wallets" } ``` * Birth wallets are **always free**; the `wallet_creation` fee would only apply to a manual restoration (with a fee of 0, the default, it is free; if creation fails, the charge is refunded automatically). * Need **several wallets** with their own balance (per client, per project, per business unit)? That is the [segregated wallets](/en/guides/segregated-wallets) product: companies have no limit, persons hold 1 per network+asset pair. ## View my wallets ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/crypto/wallets \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "wallets": [ { "wallet_id": "b7e3…", "chain": "tron", "asset": "USDT", "address": "TQmZ…", "label": "", "type": "deposit", "receive_only": true, "created_at": "2026-07-07T12:00:00Z" }, { "wallet_id": "a1c9…", "chain": "eth", "asset": "USDT", "address": "0x8f3B…", "label": "", "type": "deposit", "receive_only": true, "created_at": "2026-07-07T12:00:00Z" }, { "wallet_id": "fb88…", "chain": "eth", "asset": "USDC", "address": "0xa072…", "label": "", "type": "deposit", "receive_only": true, "created_at": "2026-07-07T12:00:00Z" }, { "wallet_id": "c1d4…", "chain": "btc", "asset": "BTC", "address": "bc1qf66…", "label": "", "type": "deposit", "receive_only": true, "created_at": "2026-07-07T12:00:00Z" } ] } ``` Bitcoin addresses are **native bech32** (`bc1q…`): any modern wallet or exchange can send funds to them. BTC amounts use 8 decimals (`"0.00050000"`). ## Deposit Send the wallet's asset to its address, **over the correct network**. When the deposit confirms on-chain, that asset's balance is credited automatically (net of the `funding` fee if CBPay configured one) and the `crypto_deposit_credited` webhook fires: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "…", "chain": "tron", "asset": "USDT", "tx_id": "b1946ac9…", "amount": "499.000000", "fee": "1.000000" } ``` Send **only the wallet's asset over its network** (USDT to a USDT wallet, USDC to a USDC wallet, BTC to the Bitcoin wallet). Addresses are yours and stable: you can reuse them for every deposit. ### Confirmation times | Network | Detection | Credit (network confirmation) | | -------- | ----------------------------- | ----------------------------------------- | | TRON | Near-instant | **\~1 minute** (19 confirmations) | | Ethereum | Near-instant | **A few minutes** depending on congestion | | Bitcoin | At the first block (\~10 min) | **\~30 minutes** (3 confirmations) | The credit always arrives with the webhook and the `tx_id` so you can verify it on the network explorer. ## Transfer (on-chain withdrawals) Send USDT, USDC or BTC from its balance to any external address (`asset` is optional: default `USDT` on `tron`/`eth` and `BTC` on `btc`; USDC only over `eth`): ```bash USDT over TRON theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/crypto/withdrawals \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "chain": "tron", "to_address": "TVJ6…", "amount": "100.000000", "idempotency_key": "withdrawal-2026-07-07-b" }' ``` ```bash USDC over Ethereum theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/crypto/withdrawals \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "chain": "eth", "asset": "USDC", "to_address": "0x8f3B…", "amount": "50.000000", "idempotency_key": "withdrawal-usdc-2026-07-09-a" }' ``` ```bash BTC over Bitcoin theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/crypto/withdrawals \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "chain": "btc", "to_address": "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", "amount": "0.00050000", "idempotency_key": "withdrawal-btc-2026-07-15-a" }' ``` On Bitcoin the destination can be a bech32 (`bc1q…`), taproot (`bc1p…`) or legacy (`1…` / `3…`) address. The Bitcoin **network fee** is covered by the operation itself — you receive the final status via webhook like any other withdrawal. Response `202` — `amount + fee` is debited and the transaction broadcasts: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "withdrawal_id": "5e8c…", "chain": "tron", "asset": "USDT", "to_address": "TVJ6…", "amount": "100.000000", "fee": "1.000000", "total_debit": "101.000000", "status": "processing", "tx_id": "…" } ``` Every withdrawal saves the address as a [contact](/en/guides/contacts) automatically — name it with `"contact_name"` in the body, or disable it with `"save_contact": false`. To repeat a send, use `"to_contact_id"` instead of `to_address` (the contact's saved address for that `chain` is used). The final state arrives via the `crypto_withdrawal_status_changed` webhook: **`completed`** (the `tx_id` is your receipt) or **`failed`** (the full debit is refunded). You can also query the withdrawal at any time: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/crypto/withdrawals/5e8c… \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "withdrawal_id": "5e8c…", "chain": "tron", "asset": "USDT", "to_address": "TVJ6…", "amount": "100.000000", "fee": "1.000000", "total_debit": "101.000000", "status": "completed", "status_code": "confirmed", "status_message": "confirmed on-chain", "tx_id": "7d1f…" } ``` To move balance to **another CBPay account**, skip the blockchain: [internal transfers](/en/guides/transfers) are instant and free. ### Travel Rule (withdrawals above the threshold) International regulation (FATF R.16, the "Travel Rule") requires on-chain withdrawals **from 1,000 USD** to declare who receives the funds before moving the money. Below the threshold nothing changes. There are two paths: If the destination is a wallet owned by the account holder (not an exchange), declare `wallet_type` and the beneficiary name: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/crypto/withdrawals \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "chain": "tron", "to_address": "TVJ6…", "amount": "1500.000000", "wallet_type": "self_hosted", "beneficiary_name": "Maria Perez", "idempotency_key": "withdrawal-2026-07-12-a" }' ``` The response includes `"travel_rule_status": "self_hosted_attested"`. If the destination is an account at another compatible institution, ask the beneficiary for their **travel address** (a code starting with `ta…`) and send it along with their name — the payment address is provided by the receiving institution, so `to_address` can be omitted: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/crypto/withdrawals \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "chain": "tron", "amount": "1500.000000", "travel_address": "ta2AQSjBotWQf38c8sxYYK2Kfis…", "beneficiary_name": "Maria Perez", "idempotency_key": "withdrawal-2026-07-12-b" }' ``` The exchange with the receiving institution happens inline. If it approves, the withdrawal goes to the address it provided and the response includes `"travel_rule_status": "approved"`. If the institution rejects (`travel_rule_rejected`) or has not resolved yet (`travel_rule_pending`), the withdrawal is not executed and nothing is debited — retry later with the **same** `idempotency_key`. | Error | What it means | What to do | | ---------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | `travel_rule_required` | Withdrawal above the threshold without beneficiary data | Add `travel_address` or `wallet_type: "self_hosted"` + `beneficiary_name` | | `travel_rule_beneficiary_required` | `beneficiary_name` is missing | Send the destination holder's name | | `travel_rule_address_mismatch` | Your `to_address` does not match the address approved by the receiving institution | Omit `to_address` or use the address from the approved exchange | | `travel_rule_rejected` | The receiving institution rejected the transfer | Verify the beneficiary data with the recipient | | `travel_rule_pending` | The receiving institution has not resolved yet | Retry later with the same `idempotency_key` | | `travel_rule_unavailable` | Exchange temporarily unavailable | Retry with the same `idempotency_key` | ## Movements ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # On-chain activity: deposits + withdrawals, with tx_id and date filters curl "https://api.qbank.cl/platform/v1/crypto/transactions?from=2026-07-01&to=2026-07-08" \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "page": 1, "page_size": 50, "deposits": [ { "chain": "tron", "asset": "USDT", "tx_id": "b1946ac9…", "from_address": "TX9a…", "amount": "499.000000", "reference": "dep_8813…", "created_at": "2026-07-07T12:10:00Z" } ], "withdrawals": [ { "withdrawal_id": "5e8c…", "chain": "tron", "asset": "USDT", "to_address": "TVJ6…", "amount": "100.000000", "fee": "1.000000", "total_debit": "101.000000", "status": "completed", "tx_id": "7d1f…", "created_at": "2026-07-07T15:00:00Z" } ] } ``` ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Current balance (available + held) curl https://api.qbank.cl/platform/v1/balances \ -H "Authorization: Bearer " # Full accounting history (funding, withdrawals, wallet fees…) curl "https://api.qbank.cl/platform/v1/movements?type=funding&from=2026-07-01&to=2026-07-08" \ -H "Authorization: Bearer " ``` Every deposit credits the **balance of its wallet's asset** (USDT, USDC or BTC); wallets are entry points, each currency has a single balance. ## Errors | HTTP | `error` | Cause | | ---- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `invalid_chain` | Unsupported network (use `tron`, `eth` or `btc`) | | 400 | `invalid_asset` | Network/asset pair without an on-chain rail (supported: `tron`/`usdt`, `eth`/`usdt`, `eth`/`usdc`, `btc`/`btc` — `GOLD` does not operate on-chain) | | 400 | `to_address_required` | Missing withdrawal destination address | | 402 | `insufficient_funds` | Not enough balance in that asset (for the withdrawal or the creation fee) | | 422 | `wallet_limit_reached` | The account already holds its deposit wallet for that network+asset pair (applies to persons and companies) | | 422 | (withdrawal with `status: failed`) | Rejected at broadcast; debit refunded | | 503 | `withdrawals_unavailable` | Withdrawals not enabled for this corridor yet | ## FAQ No. Every account is born with its deposit wallets for all supported pairs (`tron:usdt`, `eth:usdt`, `eth:usdc`, `btc:btc`), free of charge. The create endpoint only self-heals a missing pair — a second wallet for the same pair answers `wallet_limit_reached` (422). Detection is near real-time (`pending`); the credit happens once the network reaches the required confirmations for that chain. Track it with `GET /v1/crypto/transactions` or the `crypto_deposit` webhooks. The debited amount (including the fee) is refunded to your balance automatically. Retry with the **same** `idempotency_key` — the platform never re-broadcasts on its own. Withdrawals above your organization's Travel Rule USD threshold require `wallet_type: self_hosted` plus `beneficiary_name`, or a `travel_address` (the `travel_rule_*` 422 errors guide you field by field). No — GOLD is a ledger-only balance without an on-chain rail. Convert it with [Swaps](/en/guides/swaps) to a withdrawable asset first. The destination address failed the compliance screening. The operation is recorded as failed and your funds are refunded; contact your CBPay team if you believe it is a false positive. # KYC and KYB verification Source: https://docs.cbpayapp.com/en/guides/kyc Identity verification with a hosted wizard: form, OCR-validated documents and video liveness — for your account and for your customers **Identity verification** proves a person (KYC) or company (KYB) is who they claim to be, with real evidence: a complete form, document uploads validated by OCR and a **video liveness check**. It has two sides: 1. **Your own verification (onboarding)** — mandatory: until approved, your account can only **fund** (payins, crypto deposits, incoming transfers) and read. Person ⇒ KYC; company ⇒ KYB. 2. **Verifying your customers (company accounts only)** — generate hosted links or send data through the API to verify your own end customers, with a fixed fee per verification. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR create["POST /v1/kyc/links or /v1/kyb/links
(fixed fee)"] --> link["Hosted link
status: pending"] link -->|"your customer opens it"| opened["opened"] opened -->|"form + documents
+ liveness check"| completed["completed
(link_completed webhook)"] completed --> review["Submission
pending_review → in_review"] review -->|"approved"| ok["approved (webhook)"] review -->|"missing data"| changes["changes_requested /
more_info_required"] review -->|"rejected"| rejectedNode["rejected (webhook)"] ``` ## Your own verification (onboarding) When you register, your account starts unverified (`kyc_status: none`) and **can only fund and read**. Any outgoing-money action (payouts, transfers, withdrawals, banking, cards) answers `403 verification_required` until you are approved. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/me/verification/link \ -H "Authorization: Bearer " ``` `201` response (if you already have an open link, the same one is returned with `200`): ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "link_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "kind": "kyc", "url": "https://…/on/usd/individual/new?invite=abc123…", "status": "pending", "label": "Ana Pérez", "created_at": "2026-07-10T12:00:00Z", "updated_at": "2026-07-10T12:00:00Z" } ``` The `kind` derives from your account type: person ⇒ `kyc`, company ⇒ `kyb`. Onboarding is **free** for you. Open the `url`: the hosted wizard guides you through the form, document uploads (identity, proof of residence; corporate documents for companies) and — for KYC — the camera liveness check. Check your state any time: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/me/verification \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "kyc_status": "pending", "required_kind": "kyc", "verified": false, "link": { "link_id": "a1b2c3d4-…", "kind": "kyc", "url": "https://…", "status": "completed" }, "submission": { "submission_id": "f0e1d2c3-…", "kind": "kyc", "status": "in_review", "liveness_pending": false } } ``` When compliance approves, your `kyc_status` becomes `approved` **automatically** and every service unlocks (you receive the `kyc_verification_status_changed` webhook with `self_onboarding: true`). **Automatic decision engine:** a fully clean application (documents read correctly, liveness passed, no sanctions or PEP matches, no risk signals) is approved **in seconds without human intervention**. Applications with grey areas (homonym AML matches, PEP, medium risk band, high-risk country, an unreadable document…) go to the operator's human review queue, and severe cases are rejected directly. The `decision_source` field of the status webhook (`"auto"` / `"admin"`) tells you who decided. The approval also **backfills your account profile with the verified identity**: `display_name` (person = first + last name; company = legal name), `tax_id` and `country` are taken from the verification and from then on are **immutable** via `PATCH /v1/me` (`409 identity_locked`) — the verified identity is the source of truth. While you wait you can fund normally: payins on every method, crypto deposits and incoming transfers work from day one. If your verification is rejected (`kyc_status: rejected`), contact your operator — they may ask you to retry with a new link. ## Verifying your customers (company accounts only) A verified **company** account can verify its own end customers. Each created verification bills the configured fixed fee (`kyc_verification` / `kyb_verification`; 0 = free), **automatically refunded** if creation fails. Person accounts receive `403 company_account_required`. ### Option A — Hosted links (recommended) Your customer completes EVERYTHING in the white-label wizard: form, documents and liveness. You only generate the link and wait for the webhook. ```bash KYC link (person) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/kyc/links \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "external_customer_id": "cust_123", "label": "Ana Pérez", "expires_in_days": 14, "idempotency_key": "kyc-link-cust-123-1" }' ``` ```bash KYB link (company, with country) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/kyb/links \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "external_customer_id": "cust_456", "country": "cl", "label": "Comercial Andina SpA", "expires_in_days": 14, "idempotency_key": "kyb-link-cust-456-1" }' ``` * `external_customer_id` (required): YOUR reference for the verified customer — echoed back on every webhook and query. Values equal to `self` or ending in `:self` are reserved for account onboarding and are rejected with `400 invalid_payload`. * `idempotency_key` (required): a retry with the same key returns the original link and **never double-charges**. * `country` (KYB only): `us`, `cl`, `ve`, `br`, `mx`, `co`, `pe`, `bo`, `py`, `ar` or `generic` (with `generic_country` ISO alpha-2, e.g. `"ES"`). Individual KYC takes no country. * `expires_in_days` (optional, 1–30): omitted, the link never expires. `201` response: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "link_id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "kind": "kyb", "external_customer_id": "cust_456", "url": "https://…/on/cl/business/new?invite=abc123…", "status": "pending", "country": "cl", "label": "Comercial Andina SpA", "expires_at": 1721209600, "verification_fee": "2.000000", "created_at": "2026-07-10T12:00:00Z", "updated_at": "2026-07-10T12:00:00Z" } ``` Query and history (every POST has its GET): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Filtered listing curl "https://api.qbank.cl/platform/v1/kyb/links?from=2026-07-01&to=2026-07-10&status=completed&page=1&page_size=50" \ -H "Authorization: Bearer " # Detail (live link state) curl https://api.qbank.cl/platform/v1/kyb/links/{link_id} \ -H "Authorization: Bearer " ``` | Link status | Meaning | | ----------- | --------------------------------------------------------------------------------------------- | | `pending` | Created, your customer has not opened it | | `opened` | Your customer opened the wizard | | `completed` | Form submitted — the submission is born (`kyb_link_completed` / `kyc_link_completed` webhook) | | `expired` | Expired without completion | ### Option B — Data through the API If you already hold the customer's data, create the verification directly (no wizard). The submission enters the same review queue: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/kyc/submissions \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "external_customer_id": "cust_789", "idempotency_key": "kyc-sub-cust-789-1", "person": { "first_name": "Ana", "last_name": "Pérez", "email": "ana@example.com", "phone": "+56912345678", "nationality": "CHL", "date_of_birth": "1990-04-12", "tax_id": "12.345.678-5", "id_type": "id_card", "id_number": "12345678", "address": { "line1": "Av. Siempre Viva 123", "city": "Santiago", "state": "RM", "postal_code": "8320000", "country": "CHL" }, "primary_purpose": "personal_or_living_expenses", "most_recent_occupation": "Engineer", "source_of_funds": "salary" } }' ``` Data-mode notes: * Countries in **ISO alpha-3** (`CHL`, `USA`, `VEN`…); dates `YYYY-MM-DD`; `id_type`: `passport | id_card | drivers_license`. * KYB: body `{ external_customer_id, country?, business: {…}, ubos?, directors?, signers?, bank_info?, metadata? }` on `POST /v1/kyb/submissions`. * **No liveness is required at creation**: the KYC submission carries `liveness_pending: true`; close it with a [liveness link](#liveness-check-liveness-link). * Re-sending with the same `external_customer_id` while the submission is open (`pending_review`, `changes_requested`, `more_info_required`) **updates** the same submission and does not charge again. `201` response: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "submission_id": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f", "kind": "kyc", "external_customer_id": "cust_789", "status": "pending_review", "liveness_pending": true, "verification_fee": "1.500000", "created_at": "2026-07-10T12:05:00Z", "updated_at": "2026-07-10T12:05:00Z" } ``` Query and history: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/kyc/submissions?from=2026-07-01&to=2026-07-10&status=approved&page=1&page_size=50" \ -H "Authorization: Bearer " curl https://api.qbank.cl/platform/v1/kyc/submissions/{submission_id} \ -H "Authorization: Bearer " ``` The detail adds what compliance requested: `pending_documents`, `rejection_reason`, `changes_requested_comments`; on KYC also `liveness_pending` and `documents_received`; on KYB `aml_decision`. | Submission status | Meaning | | ------------------------------- | --------------------------------------------------------------------- | | `pending_review` | Received, in the compliance queue | | `in_review` | Compliance took the case | | `changes_requested` | Data must be fixed and re-sent | | `more_info_required` | Documents missing ([upload them via API](#documents-through-the-api)) | | `escalated` | Escalated to senior review | | `approved` / `approved_partial` | Approved (final) | | `rejected` | Rejected (final) | ### Documents through the API Documents are optional at creation (if missing, compliance will request them via `more_info_required`). 3-step flow: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/kyc/submissions/{submission_id}/documents \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "category": "identity", "filename": "cedula.jpg", "content_type": "image/jpeg", "file_size": 482133 }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "upload_url": "https://storage…", "key": "public-api/…", "expires_in": 900 } ``` Categories — KYC: `identity`, `proofOfResidence`; KYB: `legalPresence`, `ownershipStructure`, `controlStructure`, `companyDetails`. Types: `application/pdf`, `image/png`, `image/jpeg`; 15 MB max; the upload URL expires in 15 minutes. `PUT` the binary straight to `upload_url` with the same `Content-Type`. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/kyc/submissions/{submission_id}/documents/confirm \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "key": "public-api/…", "category": "identity", "filename": "cedula.jpg", "content_type": "image/jpeg" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "status": "received", "ocr": "queued" } ``` Confirming queues the OCR validation; the result arrives via the `kyc_document_validated` / `kyb_document_validated` webhook and is queryable with GET: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/kyc/submissions/{submission_id}/documents \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "items": [ { "id": "9c9b0f1e-4b3c-4f6a-9f6d-2f0a1b2c3d4e", "category": "identity", "status": "completed", "outcome": "MATCH", "effective_outcome": "MATCH", "score": 0.97, "summary": "Document matches the submitted identity", "filename": "cedula.jpg" } ], "meta": { "retrieved": 1 } } ``` `outcome`: `MATCH`, `REVIEW` (manual review), `NO_MATCH`. Each item also exposes: * `id`: the validation identifier (used by the compliance team to review it). * `effective_outcome`: the outcome that currently governs — the admin's manual resolution from the admin panel if one exists, otherwise the OCR engine outcome. In the submission detail (`GET /v1/kyc/submissions/{id}`) the `documents_gate` block summarizes whether ALL documents are already resolved (`ok: true`), with `matched`/`total` and the `unresolved` list of pending ones. * `manual_review`: present only when an admin manually resolved the validation from the admin panel. The account view carries `outcome` and `reviewed_at` (without the internal note or the reviewer). Manually resolving a document validation is an admin-panel-only operation (CBPay Admin); it is not exposed through the public API. When the compliance team applies it, your account sees the updated result in `effective_outcome` and `manual_review`, plus the corresponding `kyc_document_validated` / `kyb_document_validated` webhook. ### Liveness check (liveness link) KYC submissions created through the API are born with `liveness_pending: true` (the liveness check is a browser camera flow). Generate a minimal hosted link for your customer to complete it: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/kyc/submissions/{submission_id}/liveness_link \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "expires_in_days": 7 }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "url": "https://…/on/liveness/", "status": "pending", "expires_at": 1751234567 } ``` * Free (the service was billed when the submission was created). If an open link exists, the POST returns the same one; if the check already passed, `400 liveness_already_completed`. * `GET .../liveness_link` returns the latest link and the current check state (`{ "liveness": { "status", "outcome", "passed" } }`). * On pass (outcome `PASS` or `REVIEW`): the submission clears `liveness_pending` and the `kyc_liveness_completed` webhook fires. ## One verification for everything (reusable identity) A customer's approved verification is their **single identity** inside CBPay: you never re-type their data or re-upload their documents in any other product. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR verif["Approved verification
(submission_id)"] -->|"verification_id"| banking["Third-party banking user
POST /v1/banking/third-parties"] verif -->|"cardholder.verification_id"| card["Card for a designated person
POST /v1/cards"] verif -.->|"same pattern"| future["Upcoming products"] ``` * **Third-party banking**: `POST /v1/banking/third-parties` requires the `verification_id` of an **approved** verification of the third party. The type (`INDIVIDUAL`/`COMPANY`) comes from the kind (KYC ⇒ person, KYB ⇒ company), the data (name, email, address) auto-fills from the verified profile, and the already-validated documents are re-delivered automatically to the banking provider (`documents_synced` in the response). Details in [Banking](/en/guides/banking#third-party-banking-users-companies-only). * **Cards for designated persons**: `POST /v1/cards` with a person `cardholder` requires `cardholder.verification_id` of that person's **approved KYC**. The cardholder's identity and documents come from the verification; you only add the issuer-specific fields (`occupation`, `salary_usd`). Details in [Cards](/en/guides/cards). * **Your own account**: your approved onboarding is reused too — when creating your banking customer or your first card, missing data and documents auto-fill from your verification. Explicit fields in your request **always win** over the autofill. Without an approved verification of the third party, the banking registration and designated card issuance answer `422 verification_required`. Verify first (hosted links or API data) and use the approved `submission_id` as `verification_id`. ## Compliance report (KYB only) For every KYB verification you can download the **signed compliance report** (PDF, evidence for your own auditors): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -o report.pdf https://api.qbank.cl/platform/v1/kyb/submissions/{submission_id}/report \ -H "Authorization: Bearer " ``` It is free (the service was billed when the verification was created). ## Verification report (PDF + JSON) Besides the processor's report, every decided KYC or KYB submission has its **verification report** generated by the platform. It is the full file, not a summary: verified identity (person or company), declared economic profile, risk attestations, masked bank account, decision lifecycle, documents with their document validation, liveness check, **related parties with their own screening** (KYB) and the AML screening with the detail of every match — all with an integrity hash and a public verification code. Two formats (`?format=pdf|json`, default `pdf`) and three languages (`?lang=en|es|zh`, default `en`). It is free: it is a read of a verification you already paid for. Report sections: | Section | Contents | | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Subject | Verified identity: person (document, nationality, tax residence, occupation) or company (registration, incorporation, jurisdiction, ISIC industry, website) | | Economic profile | Source of funds, purpose of the relationship, expected volumes and revenue, expected chains | | Attestations | Declared risk answers (money services, third-party funds, high-risk activities, prohibited countries) | | Bank account | Bank, holder and account number **masked at the source** (never in full) | | Documents | Category, file, status, validation outcome, score, validation timestamp and rejection reason. The PDF adds **identity document photos** when the provider supplies them (if none, the section is omitted) | | Liveness | Per role (holder, UBO N): outcome, gate, liveness / antispoofing / face similarity scores. **One entry per session** — a subject can have the onboarding `gate` check plus one or more later evidence `media_recapture`s, each with its own `session_id` and `purpose`. The PDF embeds selfie/frames when live media exists; JSON only declares metadata (`has_selfie`, `has_video`, `frame_gestures`, hashes) without URLs | | Related parties | KYB only: UBOs, control persons and signers, each with identity, ownership, their documents, **all their liveness sessions** (`liveness_sessions[]`) and **their own AML screening** | | AML screening | Risk level, indicators, matches with aliases, sanctions lists with source and validity, PEP positions, RCA links and adverse media. The PDF closing includes the full AML annex (attribution and sources) when a screening exists | **Liveness has one entry per session, not one per subject.** A `gate` session is the check that unlocked onboarding — it usually only carries the selfie. A `media_recapture` session is a later evidence capture and is the one that carries the full package (selfie + one frame per requested gesture * video). A `media_recapture` that ended `outcome: "FAIL"` still matters — it can be the only session with usable video — so **iterate the whole `liveness[]` array** instead of reading `liveness[0]`; the subject's current verdict is always the `gate` session's `outcome`. In a KYB, `parties[].liveness` (singular) stays for compatibility and always points to that party's `gate` session, while `parties[].liveness_sessions[]` carries every session for that party. **How to read the PDF.** The report opens with a **navigable cover**: an index of cards with icon, title and page number that are **clickable** and jump to their section. Every section carries its own icon and accent bar (the same visual language as the AML report), identity document and liveness photos keep their real aspect ratio, and no heading is ever left alone at the bottom of a page. Adverse media entries in the AML annex carry a **“view source”** chip and the public verification URL in the closing block is clickable — for safety **only `http` and `https` links are embedded**; any other scheme is dropped and the text stays unlinked. ### For your third parties (company accounts) ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # PDF in Spanish curl -o report.pdf "https://api.qbank.cl/platform/v1/kyb/submissions/{submission_id}/verification-report?lang=es" \ -H "Authorization: Bearer " # JSON (same content as the PDF) curl "https://api.qbank.cl/platform/v1/kyc/submissions/{submission_id}/verification-report?format=json" \ -H "Authorization: Bearer " ``` A third party's report is **complete**: the AML section includes the risk level, indicators and matches (names, sanctions lists, PEP, adverse media). You perform the due diligence on your customer and this report is your evidence. `format=json` response (shape summary — the PDF renders from the same model): ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "report_id": "IDR-C3D4E5F6A7B8", "kind": "kyb", "scope": "third_party", "generated_at": "2026-07-27T19:04:11Z", "generated_by": "CBPay", "language": "es", "aml_detail": true, "submission_id": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f", "external_customer_id": "cust_789", "status": "approved", "risk_band": "low", "aml_decision": "no_match", "subject": { "type": "company", "name": "Importadora Andina SpA", "company": { "legal_name": "Importadora Andina SpA", "registration_number": "77.123.456-7", "incorporation_date": "2019-04-12", "incorporation_country": "CHL", "website": "https://andina.example", "countries_of_operation": ["CL", "PE"] }, "address": { "city": "Santiago", "country": "CL" }, "registered_address": { "line1": "Av. Apoquindo 1234", "city": "Santiago", "country": "CL" }, "industry": { "code": "G4690", "label": "Wholesale trade" }, "economic_profile": { "source_of_funds": "business_revenue", "primary_purpose": "supplier_payments", "annual_revenue_usd": "250000", "monthly_payments_usd": "40000", "expected_chains": ["tron", "ethereum"] }, "attestations": [ { "key": "att_money_services", "value": false }, { "key": "att_high_risk_activities", "items": [] } ], "bank_account": { "bank_name": "Banco de Chile", "account_holder": "Importadora Andina SpA", "account_masked": "****4321", "country": "CL", "currency": "CLP" } }, "parties": [ { "source": "ubo", "index": 0, "kind": "ubo", "name": "Javier Villablanca", "person": { "first_name": "Javier", "last_name": "Villablanca", "date_of_birth": "1985-03-04", "nationality": "CL", "id_type": "national_id", "id_number": "12345678-9" }, "address": { "city": "Santiago", "country": "CL" }, "ownership_percent": "60", "has_ownership": true, "has_control": true, "documents": [ { "category": "uboIdentity:0", "status": "validated", "outcome": "MATCH", "party_index": 0 } ], "liveness": { "role": "ubo:0", "session_id": "lv_e763e3465bf34f1dab826a263c1eaaaa", "purpose": "gate", "status": "completed", "outcome": "PASS", "passed_gate": true, "liveness_score": "0.86", "media": { "has_selfie": true, "has_video": false, "expires_in_sec": 900 } }, "liveness_sessions": [ { "role": "ubo:0", "session_id": "lv_e763e3465bf34f1dab826a263c1eaaaa", "purpose": "gate", "status": "completed", "outcome": "PASS", "passed_gate": true, "liveness_score": "0.86", "media": { "has_selfie": true, "has_video": false, "expires_in_sec": 900 } }, { "role": "ubo:0", "session_id": "lv_4cdd3a82903940cebd8cc95a77cdacb3", "purpose": "media_recapture", "status": "completed", "outcome": "FAIL", "passed_gate": false, "liveness_score": "0.98", "reasons": ["Smile was not detected."], "media": { "has_selfie": true, "has_video": true, "frame_gestures": ["center", "turn_right", "smile"], "video_mime_type": "video/mp4", "expires_in_sec": 900 } } ], "aml": { "screening_id": "d5f6a7b8-9c0d-4e1f-2a3b-4c5d6e7f8a9b", "risk_level": "no_risk", "sanctions": "clear", "pep": "clear", "adverse_media": "clear", "monitor": true, "matches_total": 0 } } ], "documents": [ { "category": "registration", "filename": "deed.pdf", "status": "validated", "outcome": "MATCH", "score": "0.97", "validated_at": "2026-07-26T14:02:00Z" } ], "liveness": [ { "role": "ubo:0", "session_id": "lv_e763e3465bf34f1dab826a263c1eaaaa", "purpose": "gate", "status": "completed", "outcome": "PASS", "passed_gate": true, "liveness_score": "0.99", "media": { "has_selfie": true, "has_video": false, "expires_in_sec": 900 } }, { "role": "ubo:0", "session_id": "lv_4cdd3a82903940cebd8cc95a77cdacb3", "purpose": "media_recapture", "status": "completed", "outcome": "FAIL", "passed_gate": false, "liveness_score": "0.98", "reasons": ["Smile was not detected."], "media": { "has_selfie": true, "has_video": true, "frame_gestures": ["center", "turn_right", "smile"], "video_mime_type": "video/mp4", "expires_in_sec": 900 } } ], "aml": { "screening_id": "b7e1c2d3-4f5a-6b7c-8d9e-0f1a2b3c4d5e", "risk_level": "no_risk", "status": "no_hits", "monitor": true, "screened_at": "2026-07-26T14:05:12Z", "sanctions": "clear", "pep": "clear", "adverse_media": "clear", "screening_result": "no_hits", "indicators": [ { "key": "ind_sanctions", "hit": false }, { "key": "ind_pep", "hit": false }, { "key": "ind_adverse_media", "hit": false } ], "subject_rows": [ { "key": "legal_name", "value": "Importadora Andina SpA" } ], "matches_total": 0 }, "content_sha256": "9f2b4c…", "verification_code": "Bc3d4e5f6a7b84c9d0e1f2a3b4c5d6e7f9f2b4c6d8e0a1b3c5d7", "verification_url": "https://api.qbank.cl/platform/verify/reports/Bc3d4e5f6a7b84c9d0e1f2a3b4c5d6e7f9f2b4c6d8e0a1b3c5d7" } ``` If the verification does not yet have a linked AML screening (older verifications), the first download runs it automatically **at no cost**. If the screening is unavailable at that moment, the report is generated anyway with `"partial": ["aml_unavailable"]` — the section is never fabricated. ### Related parties and their screening (KYB) In a company verification, every UBO, control person and signer in the file comes out as a `parties[]` entry with their full identity, their ownership, the documents and liveness check that belong to them, and **their own AML screening with continuous monitoring enabled**. The `(source, index)` pair is the party's stable identity inside the file: it is what links their documents (`uboIdentity:0`) and what makes their screening always the same, no matter how many times you download the report. Screening the parties is **free** (it is a due-diligence duty, not a billable product) and stays monitored: if a UBO lands on a sanctions list after onboarding, the alert shows up on its own. If a party does not have its screening yet at download time, the report is generated with `"partial": ["party_aml_unavailable"]` and the missing screening runs in the background: the next download already carries it. ### For your own onboarding ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -o report.pdf "https://api.qbank.cl/platform/v1/me/verification/report?lang=en" \ -H "Authorization: Bearer " ``` In the report of your own verification the AML section is **aggregated** (`aml_detail: false`): you see the status per category — `sanctions`, `pep` and `adverse_media` as `clear` or `under_review` — without the match details. The same applies to the screening of your related parties: their AML sections are aggregated too. The rest of the file (identity, economic profile, documents, liveness, parties) is complete. ### Public report verification Every report carries a `verification_code` (printed on the PDF next to a QR). Anyone can confirm its authenticity without credentials: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/verify/reports/Bc3d4e5f6a7b84c9d0e1f2a3b4c5d6e7f9f2b4c6d8e0a1b3c5d7" ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "valid": true, "type": "verification_report", "kind": "kyb", "status": "approved", "decision": "approved", "date": "2026-07-27", "issued_by": "CBPay" } ``` The public page confirms only the type, the current decision status, the date and the issuing brand — never data about the subject. In a browser it responds with an HTML page carrying your brand. ## Webhooks | Event | When | | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `kyc_verification_status_changed` / `kyb_verification_status_changed` | The submission changed state (whole lifecycle: received, in review, changes requested, approved, rejected…) | | `kyc_link_completed` / `kyb_link_completed` | Your customer completed a hosted link | | `kyc_document_validated` / `kyb_document_validated` | OCR finished for a document uploaded through the API | | `kyc_liveness_completed` | The liveness check was completed from a liveness link | Example payload (`kyc_verification_status_changed`): ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "kind": "kyc", "event": "approved", "submission_id": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f", "external_customer_id": "cust_789", "status": "approved", "risk_band": "low", "decision": "approved" } ``` Your own onboarding arrives with `"self_onboarding": true` instead of `external_customer_id`. Subscribe like every other event (see [Webhooks](/en/webhooks)). ## Costs (configured by your operator, can be 0) | Service | When it is billed | | ------------------ | -------------------------------------------------- | | `kyc_verification` | When creating a third-party KYC link or submission | | `kyb_verification` | When creating a third-party KYB link or submission | The charge comes out of your default settlement balance, is refunded if creation fails, and **your own onboarding never bills**. Re-sends of an open submission and liveness links do not charge again. ## Errors | HTTP | `error` | Cause | Solution | | ---- | ---------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------- | | 400 | `idempotency_key_required` | Creation POST without a key | Send `idempotency_key` (body or header) | | 400 | `invalid_payload` | Missing `external_customer_id` or another required field | Check the body | | 400 | `liveness_already_completed` | The liveness check already passed | Nothing to do | | 400 | `invalid_format` | Invalid `format` when requesting a verification report | Use `pdf` or `json` | | 400 | `invalid_language` | Invalid `lang` when requesting a verification report | Use `en`, `es` or `zh` | | 402 | `insufficient_funds` | Balance cannot cover the fee | Fund the account and retry | | 403 | `verification_required` | Your account has not approved its own verification | Complete your [onboarding](#your-own-verification-onboarding) | | 403 | `company_account_required` | A person account tried to verify third parties | Company accounts only | | 403 | `service_disabled` | The `kyc` service is disabled for your account | Contact your operator | | 404 | `not_found` | The link/submission does not exist or is not yours | Check the id | | 404 | `verification_not_found` | You requested your self report without a registered verification | Complete your onboarding first | | 409 | `already_verified` | Onboarding link requested with an already-approved account | Nothing to do | | 503 | `verifications_unavailable` | Service temporarily unavailable (the fee was refunded) | Retry later | ## FAQ Every account must approve its identity verification before moving money out (a regulatory requirement). Meanwhile you can fund (payins, crypto deposits, incoming transfers) and explore the API. Request your link with `POST /v1/me/verification/link` and complete it — approval unlocks everything automatically. With links, your customer completes everything in the wizard (form + documents + liveness) and you never handle sensitive data. With API data you send the fields and upload documents via presign — useful if you have your own form — but the liveness check still needs a liveness link (it is a camera flow, impossible server-to-server). It bills when CREATING a third-party link or submission (live mode). Not billed: your own onboarding, re-sends of an open submission (same external\_customer\_id), liveness links, queries and documents. If creation fails, the fee is refunded automatically. Third-party verification is a B2B tool for integrators (company accounts). A person account only needs its own onboarding, which is free and lives at /v1/me/verification. You will receive `more_info_required` with `pending_documents` in the submission detail. Upload each document with this page's presign → upload → confirm flow; on confirmation the submission returns to the review queue. No: they complement each other. Verification proves identity with evidence (documents, video); [AML screening](/en/guides/aml) checks the identity against sanctions/PEP/adverse-media lists and can watch it continuously. Yes — that is the design: an approved verification works as the single identity. Pass its `submission_id` as `verification_id` when registering a third-party banking user or issuing a card for a designated person: data and documents auto-fill. See [reusable identity](#one-verification-for-everything-reusable-identity). If the verification is for your own account (self onboarding — this does not apply if you are verifying a third party from a company account), you get an automatic email to your address when the decision lands on approved, rejected, or changes requested. The email uses your organization's branding (or CBPay's by default), never includes the detailed reason for a rejection for security and privacy reasons, and the action button takes you to the organization's site. If you are verifying a third party (for example your company verifying a customer or vendor), the third party does NOT receive this email — the notification in that case is still the `kyc_status_changed`/`kyb_status_changed` webhook you already integrated. # Payins Source: https://docs.cbpayapp.com/en/guides/payins Collect in local currency and get credited in USDT A payin is a fiat collection: your customer pays in local currency and your account gets credited in USDT automatically, converted at **your payin rate** (`payin_rate` in `GET /v1/rates`) minus the fixed payin fee when configured for your account. Whatever the mode, every path ends the same way — automatic credit + webhook: ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR qr["Collection QR
(BO, BR·PIX)"] --> pay["Your customer pays
in local currency"] hosted["Hosted payment page
(CL: fintoc)"] --> pay card["3-D Secure card payment
(BO: card)"] --> pay announced["Announced transfer
(CL, PE, MX, PY, US)"] --> pay pull["Active pull collection
(VE: c2p, debit)"] --> pay clabe["Dedicated CLABE / CVU account
(MX, AR)"] --> pay pay --> conv["FX conversion at your
payin_rate − fixed fee"] conv --> credit(("USDT credit
to your balance")) credit --> wh["Webhook payin_credited"] ``` ## 1. Discover the available corridors The available countries, currencies and collection modes are defined by CBPay. Always check the catalog: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/payins/methods \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "items": [ { "country": "BO", "currency": "BOB", "method": "qr", "delivery": "push" }, { "country": "VE", "currency": "VES", "method": "c2p", "delivery": "push+polling" }, { "country": "MX", "currency": "MXN", "method": "bank_transfer", "delivery": "push" } ], "meta": { "retrieved": 3 } } ``` `delivery` describes how the payment is confirmed on CBPay's side (bank notification, polling or both) — it changes nothing in your integration: you always receive the `payin_credited` webhook. Collection corridors and modes: | Country | Currency | Modes | | ------------- | --------- | ------------------------------------------------------------------------------------------------------------------ | | Chile | CLP | Hosted payment page (`fintoc`), announced bank transfer | | Peru | PEN | Announced bank transfer | | Mexico | MXN | Dedicated CLABE account, announced bank transfer | | Venezuela | VES | Active collection `c2p` and `debito_inmediato` (pull) | | Bolivia | BOB / USD | Collection QR, card payment page (`card`) | | Paraguay | PYG | Announced bank transfer | | Brazil | BRL | Dynamic PIX QR | | Argentina | ARS | Dedicated CVU account | | United States | USD | International card payment page (`card`), announced bank transfer (two rails: domestic wire + international SWIFT) | Availability may vary; the catalog (`GET /v1/payins/methods`) is always the source of truth. In every case the credit works the same way: converted to USDT at your current `payin_rate` and credited net of the fixed payin fee. If you'd rather keep your collections in another balance (USDC, BTC or GOLD), configure `default_payin_asset` — see [the money model](/en/concepts/money-model#choose-which-balance-receives-your-payins). ## 2. Pick the mode and create the charge Each country has its own collection mode. The real request and response of each one: **Hosted payment page (`fintoc`)** — recommended: you get a `payment_url`; the payer opens it and transfers from **any Chilean bank or wallet** (Banco Estado, Santander, Mach, Tenpo, Mercado Pago…). The payment is detected and validated automatically — no manual references. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "CL", "currency": "CLP", "method": "fintoc", "amount": "150000", "description": "Top-up order 8841", "idempotency_key": "topup-8841" }' ``` Response `201`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "7a2b…", "status": "pending", "reference": "7a2b…", "payment_url": "https://pay.fintoc.com/plink_K2zwNNSxPyx8w3GZ", "expires_at": "2026-07-08T18:48:25Z", "note": "share the payment_url with the payer; the deposit is credited automatically once the transfer is detected" } ``` Share the `payment_url` with the payer (link, redirect or WebView). Once the payment is confirmed your account is credited in USDT and you receive the `payin_credited` webhook. The CLP amount must be an integer (the Chilean peso has no decimals) and the payment session expires after 24 hours by default. A retry with the same `idempotency_key` returns the same payin and the same URL — it never opens a second payment session. **Announced bank transfer** (manual alternative): announce the incoming deposit and share the reference with the sender. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "CL", "currency": "CLP", "method": "bank_transfer", "amount": "500000" }' ``` Response `201`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "4f81…", "status": "pending", "reference": "CBJ6T3W9M2K5", "note": "include the reference in the transfer description so the deposit is credited automatically" } ``` When the transfer arrives it is matched by the reference in the transfer description and your account is credited automatically. If the reference does not travel, the payer's document backs it up — see [matching an announced transfer](#matching-an-announced-transfer). **Announced bank transfer**, same as Chile but in soles: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "PE", "currency": "PEN", "method": "bank_transfer", "amount": "1800.00" }' ``` Response `201`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "6d20…", "status": "pending", "reference": "CBK7M2Q9X4T3", "note": "include the reference in the transfer description so the deposit is credited automatically" } ``` The `reference` is a **short 12-character alphanumeric code** (it fits any bank concept field) and must travel in the transfer description for the automatic match. Send `payer_document` as a backup — [how matching works](#matching-an-announced-transfer). **Dedicated CLABE account** (recommended): create a fixed CLABE bound to your account — every SPEI arriving to it is credited automatically, no references needed: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins/deposit-accounts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "MX", "currency": "MXN" }' ``` Response `201`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "instrument_id": "a1d4…", "account_id": "…", "country": "MX", "currency": "MXN", "method": "bank_transfer", "instrument": "734180000151000006", "status": "active" } ``` `instrument` is the CLABE you share with your payers. Creation is free; each deposit pays the regular payin fee. List your accounts with `GET /v1/payins/deposit-accounts`. You can also use a one-off **announced bank transfer** (`POST /v1/payins` with `method: "bank_transfer"`, `country: "MX"`). **Active collection (pull)**: you charge the payer directly with their authorization. The result is **synchronous** — if the charge is approved, the credit lands in the same call. For `debito_inmediato`, request the OTP first (free): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins/collect/otp \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "method": "debito_inmediato", "amount": "1200.00", "payer_document": "V12345678", "payer_phone": "04141234567", "payer_bank": "0102", "payer_account": "01020123456789012345" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "method": "debito_inmediato", "result": { "status": "sent", "otp_reference": "OTP-5521" } } ``` Then execute the collection: ```bash c2p (phone + ID + payer's OTP) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins/collect \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "method": "c2p", "amount": "1200.00", "description": "Order 5512", "payer_document": "V12345678", "payer_phone": "04141234567", "payer_bank": "0102", "otp": "12345678", "idempotency_key": "order-5512" }' ``` ```bash debito_inmediato (account + previously requested OTP) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins/collect \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "method": "debito_inmediato", "amount": "1200.00", "description": "Order 5512", "payer_document": "V12345678", "payer_account": "01020123456789012345", "payer_bank": "0102", "payer_account_type": "CNTA", "otp": "87654321", "otp_reference": "OTP-5521", "idempotency_key": "order-5512" }' ``` An active collection executes a real charge against the payer, so `idempotency_key` is **required** (body or `Idempotency-Key` header): a retry with the same key returns the original result with `idempotency_hit` and never re-charges. Response `200` (charge approved and credited): ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "7b3c…", "kind": "collect", "method": "c2p", "status": "credited", "local_amount": "1200.00", "fx_rate": "36.50", "usdt_gross": "32.876712", "fee": "0.300000", "usdt_credited": "32.576712", "paid": true, "provider_reference": "…" } ``` If the payer declines or the authorization fails, `paid` is `false`, the payin is marked `failed`, and nothing is charged. The exact rejection reason is persisted on the payin and exposed in the `failure` object (in the synchronous response, in `GET /v1/payins/{payin_id}`, and on the idempotent replay): ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "7b3c…", "kind": "collect", "method": "c2p", "status": "failed", "paid": false, "failure": { "source": "provider", "code": "provider_rejected", "message": "Documento de identidad del receptor errado" } } ``` * `source` tells you where the rejection originated (`provider` = the payer's bank declined; `core` = the pre-charge validation). * `code` and `message` carry the concrete reason (invalid or expired OTP, wrong document, insufficient payer funds, etc.) so you can tell the payer what to fix before retrying with a new idempotency key. **Collection QR** (the local interoperable standard): you generate the QR and your customer scans it with their banking app. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "BO", "currency": "BOB", "method": "qr", "amount": "700.00", "description": "App top-up", "expires_in": 3600 }' ``` Response `201`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "9c2a…", "status": "pending", "charge": { "charge_id": "…", "qr_image": "", "qr_image_url": "https://cdn.cbpayapp.com/public/payin-qr/.png", "qr_payload": "", "our_reference": "482915073", "status": "pending" } } ``` Display the QR to your customer — `qr_image_url` is a public CDN URL ready for an `` tag (prefer it over the base64 `qr_image`); when they pay, your account is credited automatically. It also works in USD (`currency: "USD"`). **Card payment page (`card`)**: you receive a `payment_url` for a hosted 3-D Secure checkout — the payer enters their card on a secure page branded with your organization's identity and, when their bank requires it, completes the authentication challenge right there. Card data never touches your system or your integration. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "BO", "currency": "BOB", "method": "card", "amount": "700.00", "description": "App top-up", "customer": { "email": "payer@example.com", "first_name": "Ana", "last_name": "Rojas" }, "success_url": "https://your-app.com/payment/ok", "failure_url": "https://your-app.com/payment/error", "idempotency_key": "topup-7719" }' ``` Response `201`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "b41c…", "status": "pending", "reference": "b41c…", "payment_url": "https://api.qbank.cl/pay/cards/9f3XkT…", "expires_at": "2026-07-16T18:30:00Z", "note": "share the payment_url with the payer; the balance is credited automatically once the card payment is approved" } ``` Share the `payment_url` (link, redirect or WebView). Flow details: * `customer` is an **optional** prefill of the billing details (`email`, `first_name`, `last_name`, `address`, `city`, `country` — plain text, max 120 chars per field); the payer can complete/correct them on the page. * `success_url` / `failure_url` (optional, public https) redirect the payer when done; without them the page shows the final result. * `expires_at` (optional, RFC3339, at least 15 minutes ahead) shortens the session lifetime; the default is 24 hours. If it expires unpaid, the payin moves to `expired` and you receive the `payin_expired` webhook. * The payer has a limited number of attempts; an issuer decline lets them retry with another card within the same session. * Approval is online: once the charge is approved your account is credited in USDT at your `payin_rate` and you receive `payin_credited` — same as every other mode. * A retry with the same `idempotency_key` returns the same payin and the same `payment_url`; it never opens a second payment session. * If the payer already saved cards with you, the page offers them on its own: they type their email (first field), verify it with a code and pay with one of them without re-typing it — with "Remember this device" they skip the code for 30 days. Details in [stored cards](/en/guides/stored-cards-subscriptions#the-payer-discovers-their-cards-on-the-payment-page). * It also works in USD (`currency: "USD"`). * **Settlement delay**: when your `payin_card` fee is configured with `settlement_hours` above zero, an approved charge confirms the payin as `credited` immediately — the `payin_credited` webhook fires and the checkout closes as paid — but the **balance** only becomes available at `settle_at` (RFC 3339, in the create/GET/list responses together with `settlement_pending: true`), or earlier if your org admin releases it manually; once released, the payin carries `settled_at`. The `payin_settlement_scheduled` webhook fires exactly once at confirmation with `status: "credited"` and the scheduled amounts. Details in [fees — card payin settlement delay](/en/concepts/fees#card-payin-settlement-delay). **Announced bank transfer** in guaraníes: you announce the deposit, your payer transfers (interbank SIPAP or an internal transfer at the receiving bank) with the reference in the transfer concept, and the credit is detected automatically. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "PY", "currency": "PYG", "method": "bank_transfer", "amount": "596000" }' ``` Response `201`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "8f41…", "status": "pending", "reference": "CBW4N8R2T6P9", "note": "include the reference in the transfer description so the deposit is credited automatically" } ``` Guaraníes use no decimals: announce the **exact integer amount** your payer will transfer (e.g. `"596000"`). The `reference` is a short 12-character alphanumeric code — designed for the SIPAP concept field, which accepts **at most 20 characters and no special characters** — and putting it in the concept ensures the automatic match. Send `payer_document` as a backup — see [matching an announced transfer](#matching-an-announced-transfer). **Dynamic PIX QR**: the same endpoint generates a PIX QR with the amount embedded. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "BR", "currency": "BRL", "method": "qr", "amount": "120.00", "description": "Order 7719", "expires_in": 1800 }' ``` In the response, `charge.qr_payload` is the PIX **"copia e cola"** code, so the payer can paste it into their banking app instead of scanning the image (`charge.qr_image` base64 or `charge.qr_image_url`, the public CDN URL). The QR expires per `expires_in` (default 1 hour); the payment is credited automatically once confirmed on the rail (continuous reconciliation — check on demand with `GET /v1/payins/{charge_id}`). In Brazil collections work exclusively through dynamic PIX QR (one QR = one payment, exact amount embedded). Announced bank transfers will come later. **Dedicated CVU account**: create a fixed CVU bound to your account — every ARS transfer arriving to it (from any CBU or CVU in the Argentine system) is credited automatically, no references needed: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins/deposit-accounts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "AR", "currency": "ARS" }' ``` Response `201`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "instrument_id": "f2b8…", "account_id": "…", "country": "AR", "currency": "ARS", "method": "bank_transfer", "instrument": "0000079900000000132537", "status": "active" } ``` `instrument` is the 22-digit CVU you share with your payers. Creation is free; every deposit pays the regular payin fee. List your accounts with `GET /v1/payins/deposit-accounts`. The CVU works in **ARS only** and is deposit-only (receive-only): no third party can debit it. Direct debit attempts (DEBIN) against a deposit CVU are rejected automatically. **International card payment page (`card`)**: charge in US dollars with Visa, Mastercard, American Express, Discover and Diners cards issued anywhere. You get a `payment_url` for a hosted checkout with 3-D Secure branded with your organization; card data is typed into the processor's secure fields embedded in that page and **never touches your system or your integration**. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "US", "currency": "USD", "method": "card", "amount": "49.90", "description": "Pro plan", "customer": { "email": "payer@example.com", "first_name": "Ana", "last_name": "Rojas" }, "success_url": "https://your-app.com/payment/ok", "failure_url": "https://your-app.com/payment/error", "save_card": true, "payer_reference": "customer-7719", "idempotency_key": "pro-plan-7719" }' ``` Response `201`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "3ab7…", "status": "pending", "reference": "3ab7…", "payment_url": "https://api.qbank.cl/pay/cards/Kt9XmQ…", "expires_at": "2026-07-26T18:30:00Z", "note": "share the payment_url with the payer; the balance is credited automatically once the card payment is approved" } ``` The contract is the **same** as the Bolivian card page (optional `customer`, `success_url`/`failure_url`, `expires_at`, limited attempts, an idempotent retry returns the same `payment_url`). What is specific to the international corridor: * 3-D Secure runs inside the page: if the issuer asks for a challenge, the payer completes it right there without leaving the checkout. * Most charges are approved online; if the issuer leaves the charge under verification, the credit lands as soon as the rail confirms it — you still get `payin_credited`, just a few minutes later. * `save_card: true` plus `payer_reference` store the card with the payer's consent for later charges (see [stored cards and subscriptions](/en/guides/stored-cards-subscriptions)). * If the payer already has saved cards, the page offers them after they verify their email with a code (once per device when they check "Remember this device", valid for 30 days) — they pay with 3-D Secure without re-typing the card. The international card corridor is enabled per account. Check `GET /v1/payins/methods` — it is the source of truth for what your account can collect today. **Announced bank transfer (`bank_transfer`) — two rails: domestic wire and international SWIFT**: collect dollars from any US bank account, with the same announced-transfer contract as the other countries. The US/USD corridor publishes **two deposit instructions** on purpose — a domestic rail (ABA routing number) for senders banking inside the US, and an international rail (SWIFT/BIC through a correspondent bank) for senders wiring from abroad. Announce the deposit once and the response carries both blocks — `deposit_instructions` (domestic) and `deposit_instructions_swift` (international) — each with its own copy-paste QR, so your payer picks the rail their bank supports: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "US", "currency": "USD", "method": "bank_transfer", "amount": "1250.00", "payer_name": "Acme Holdings LLC", "idempotency_key": "invoice-1042" }' ``` Response `201`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "8f4e…", "status": "pending", "reference": "CBM4X8Q2T7K9", "note": "include the reference in the transfer description so the deposit is credited automatically", "payer_source": "declared", "payer_name": "Acme Holdings LLC", "deposit_instructions": { "bank_name": "Partner Bank, N.A.", "account_number": "000123456789", "account_type": "checking", "holder_name": "CBPay Operations LLC", "holder_tax_id": "88-1234567", "routing_number": "021000021", "holder_address": "25 SW 9th Street, Suite 406, Miami, FL 33130, US", "reference_required": true, "qr_payload": "Bank: Partner Bank, N.A.\nAccount type: checking\nAccount number: 000123456789\nRouting number (ABA): 021000021\nHolder: CBPay Operations LLC\nHolder address: 25 SW 9th Street, Suite 406, Miami, FL 33130, US\nTax ID: 88-1234567\nAmount: 1250.00 USD\nReference: CBM4X8Q2T7K9", "qr_png_base64": "iVBORw0KGgoAAAANSUhEUgAA…" } } ``` For payers banking **inside the US**: a domestic wire (or ACH) with the `routing_number` (ABA). This rail has no SWIFT code — domestic US transfers do not need one. ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "8f4e…", "status": "pending", "reference": "CBM4X8Q2T7K9", "note": "include the reference in the transfer description so the deposit is credited automatically", "payer_source": "declared", "payer_name": "Acme Holdings LLC", "deposit_instructions_swift": { "bank_name": "Partner Bank International", "account_number": "9870001234", "account_type": "checking", "holder_name": "CBPay Operations LLC", "holder_tax_id": "88-1234567", "swift": "PRTBPRI3", "bank_address": "200 Example Blvd, San Juan, PR 00901, PR", "intermediary_bank_name": "Intermediary Bank N.A.", "intermediary_bank_swift": "INTRUS33", "holder_address": "25 SW 9th Street, Suite 406, Miami, FL 33130, US", "notes": "Select Puerto Rico as the final beneficiary bank country", "reference_required": true, "qr_payload": "Bank: Partner Bank International\nAccount type: checking\nAccount number: 9870001234\nSWIFT: PRTBPRI3\nBank address: 200 Example Blvd, San Juan, PR 00901, PR\nIntermediary bank: Intermediary Bank N.A.\nIntermediary SWIFT: INTRUS33\nHolder: CBPay Operations LLC\nHolder address: 25 SW 9th Street, Suite 406, Miami, FL 33130, US\nTax ID: 88-1234567\nAmount: 1250.00 USD\nReference: CBM4X8Q2T7K9\nNote: Select Puerto Rico as the final beneficiary bank country", "qr_png_base64": "iVBORw0KGgoAAAANSUhEUgAA…" } } ``` For payers wiring **from outside the US**: an international SWIFT transfer with the `swift` (BIC) and the correspondent bank (`intermediary_bank_name` / `intermediary_bank_swift`). The `notes` field carries the operational hints the sending bank needs to fill its form correctly (here: which country to select for the final beneficiary bank) — show it to the payer verbatim. The payer copies the `reference` (`CB…`) into the transfer **memo / remittance** field of whichever rail they use: it is the signal that matches the deposit to your announcement (see [matching an announced transfer](#matching-an-announced-transfer)). `holder_address` is the postal address of the account holder — US banks ask for it in their wire form, and the QR of each rail includes it as a "Holder address" line whenever it has a value (same for `notes` as a "Note" line). `intermediary_bank_name` / `intermediary_bank_swift` appear only on the rail that receives wires through a correspondent bank — show them to the payer exactly as they come; a wire that needs them and travels without them can bounce or arrive short. US deposit instructions are **mandatory** on this corridor: if your organization has not configured them yet, the announcement responds `422 deposit_instructions_unavailable` and nothing is created (see [common errors](#common-errors)). You can preview both destination accounts without announcing with `GET /v1/payins/deposit-instructions?country=US¤cy=USD&method=bank_transfer`. ## Universal checkout link (`checkout`) The universal payment link now lives in its own guide, covering the quote engine, every rail and the public endpoints: One link where the payer chooses how to pay - fiat in every live country, crypto, card or the CBPay app - settled in the balance you choose. ## Saved cards and recurring charges (card) Stored credentials (COF) and scheduled subscriptions moved to their own guide: Save cards with the payer's consent, charge them one-click or without the payer present, and schedule recurring subscriptions. ## Refunds (card) A credited card payin can be refunded in full or in part from your balance, with its own ledger entry, receipt and webhook: Refund a card payin fully or partially, void a same-day charge, and understand how a chargeback is applied. ## Matching an announced transfer An announced transfer (`method: "bank_transfer"`) has no payment session: the payer moves the money from their own bank, so the deposit is recognized when it lands. Matching runs in this order and stops at the first hit: 1. **`reference`** — the 12-character code in the transfer description. 2. **Payer document** — the announcement's `payer_document` against the payer the bank reports (dots, dashes and check digit are ignored). 3. **Single candidate** — exactly one pending announcement for that amount and currency. If none of the three resolves to **one** announcement — two pending announcements for the same amount, no reference, no payer document — the deposit is **not** credited on a guess: it lands as `unassigned` and your CBPay operator routes it. No money is lost; it is already in the collection account. ### Identify the payer (optional, recommended) `method: "bank_transfer"` accepts the payer's data. Every field is optional and additive — existing integrations keep working unchanged: | Field | Matched against | | ---------------- | -------------------------------------------------------------------------------- | | `payer_document` | Tax ID / national ID reported by the bank (dots, dashes and check digit ignored) | | `payer_name` | Payer name, by tokens (`JUAN PEREZ` matches `PEREZ JUAN SOTO`) | | `payer_account` | Payer account number, by its digits | ```bash Request theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "CL", "currency": "CLP", "method": "bank_transfer", "amount": "500000", "payer_document": "17438319-7" }' ``` ```json Response 201 theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "4f81…", "status": "pending", "reference": "CBJ6T3W9M2K5", "note": "include the reference in the transfer description so the deposit is credited automatically", "payer_source": "declared", "payer_document": "17438319-7" } ``` `payer_source` always comes back so your checkout knows what to ask the payer for: | Value | Meaning | | ------------------ | ---------------------------------------------------------------------------------------------- | | `declared` | You sent payer data — the document backs up the reference | | `account_identity` | No payer sent: the verified tax ID of your account is used (the holder deposits to themselves) | | `none` | No identity available — **insist on the reference**, it is the only strong signal left | A document shorter than 5 characters or with no digits is dropped as a signal (it cannot be told apart from an amount or a bank code). The announcement is still created and `payer_source` reports the real coverage. ### Retries and idempotency The announcement accepts `idempotency_key` (body) or the `Idempotency-Key` header. A retry with the same key returns the **original** announcement — same `reference` — with `idempotency_hit: true` and HTTP `200` instead of creating a second one. ```bash Request (retry) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -H "Idempotency-Key: topup-9912" \ -d '{ "country": "CL", "currency": "CLP", "method": "bank_transfer", "amount": "500000", "payer_document": "17438319-7" }' ``` ```json Response 200 theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "4f81…", "status": "pending", "reference": "CBJ6T3W9M2K5", "note": "include the reference in the transfer description so the deposit is credited automatically", "payer_source": "declared", "payer_document": "17438319-7", "idempotency_hit": true } ``` Two live announcements that look identical (same account, currency, amount and payer) are exactly the case matching refuses to resolve: the real deposit matches both and lands `unassigned`. That is why a POST **without** a key reuses a live identical announcement instead of duplicating it (also `200` with `idempotency_hit: true`). To collect **two real payments** of the same amount from the same payer, send a different `idempotency_key` for each one — each key creates its own announcement with its own `reference`. Keys are **unique per account and per logical operation**: if you reuse an `idempotency_key` that was already used with ANOTHER payin method (QR, checkout, card), the API replies `409 idempotency_conflict` instead of returning an object that does not match your request. ### Deposit instructions: where to send the money On corridors where your organization registered a destination account for announced transfers (today Chile, Paraguay and the United States), the announcement response includes a `deposit_instructions` block — the exact bank account the payer must transfer to, with the amount and `reference` already baked into a copy-paste QR: ```json Response 201 (with deposit instructions) theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "4f81…", "status": "pending", "reference": "CBJ6T3W9M2K5", "note": "include the reference in the transfer description so the deposit is credited automatically", "payer_source": "declared", "payer_document": "17438319-7", "deposit_instructions": { "bank_name": "Banco Ejemplo", "account_number": "001122334455", "account_type": "checking", "holder_name": "CBPay Operations SpA", "holder_tax_id": "77123456-7", "reference_required": true, "qr_payload": "Bank: Banco Ejemplo\nAccount type: checking\nAccount number: 001122334455\nHolder: CBPay Operations SpA\nTax ID: 77123456-7\nAmount: 500000 CLP\nReference: CBJ6T3W9M2K5", "qr_png_base64": "iVBORw0KGgoAAAANSUhEUgAA…" } } ``` You can also preview the destination account **before** creating a payin — useful to show the payer where they will need to send money once they confirm: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/payins/deposit-instructions?country=CL¤cy=CLP&method=bank_transfer" \ -H "Authorization: Bearer " ``` ```json Response 200 theme={"theme":{"light":"github-light","dark":"github-dark"}} { "deposit_instructions": { "bank_name": "Banco Ejemplo", "account_number": "001122334455", "account_type": "checking", "holder_name": "CBPay Operations SpA", "holder_tax_id": "77123456-7", "reference_required": true, "qr_payload": "Bank: Banco Ejemplo\nAccount type: checking\nAccount number: 001122334455\nHolder: CBPay Operations SpA\nTax ID: 77123456-7", "qr_png_base64": "iVBORw0KGgoAAAANSUhEUgAA…" } } ``` On the **US/USD** corridor the preview returns **two blocks**: the domestic rail under `deposit_instructions` and, when your organization has the international variant configured, the SWIFT rail under `deposit_instructions_swift` (same shape, its own QR). The rail fields are absent (not empty) on corridors that do not use them: | Field | What it is | | ------------------------- | -------------------------------------------------------------------------------------------------------- | | `routing_number` | ABA routing number of the destination bank — domestic rail (`deposit_instructions`) | | `swift` | SWIFT/BIC of the destination bank — international rail (`deposit_instructions_swift`) | | `bank_address` | Registered address of the destination bank | | `intermediary_bank_name` | Correspondent bank, when wires arrive through one | | `intermediary_bank_swift` | SWIFT/BIC of the correspondent bank | | `holder_address` | Postal address of the account holder (US wire forms ask for it) | | `notes` | Free operational note for the sending bank (e.g. which country to select for the final beneficiary bank) | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/payins/deposit-instructions?country=US¤cy=USD&method=bank_transfer" \ -H "Authorization: Bearer " ``` ```json Response 200 (US) theme={"theme":{"light":"github-light","dark":"github-dark"}} { "deposit_instructions": { "bank_name": "Partner Bank, N.A.", "account_number": "000123456789", "account_type": "checking", "holder_name": "CBPay Operations LLC", "holder_tax_id": "88-1234567", "routing_number": "021000021", "holder_address": "25 SW 9th Street, Suite 406, Miami, FL 33130, US", "reference_required": true, "qr_payload": "Bank: Partner Bank, N.A.\nAccount type: checking\nAccount number: 000123456789\nRouting number (ABA): 021000021\nHolder: CBPay Operations LLC\nHolder address: 25 SW 9th Street, Suite 406, Miami, FL 33130, US\nTax ID: 88-1234567", "qr_png_base64": "iVBORw0KGgoAAAANSUhEUgAA…" }, "deposit_instructions_swift": { "bank_name": "Partner Bank International", "account_number": "9870001234", "account_type": "checking", "holder_name": "CBPay Operations LLC", "holder_tax_id": "88-1234567", "swift": "PRTBPRI3", "bank_address": "200 Example Blvd, San Juan, PR 00901, PR", "intermediary_bank_name": "Intermediary Bank N.A.", "intermediary_bank_swift": "INTRUS33", "holder_address": "25 SW 9th Street, Suite 406, Miami, FL 33130, US", "notes": "Select Puerto Rico as the final beneficiary bank country", "reference_required": true, "qr_payload": "Bank: Partner Bank International\nAccount type: checking\nAccount number: 9870001234\nSWIFT: PRTBPRI3\nBank address: 200 Example Blvd, San Juan, PR 00901, PR\nIntermediary bank: Intermediary Bank N.A.\nIntermediary SWIFT: INTRUS33\nHolder: CBPay Operations LLC\nHolder address: 25 SW 9th Street, Suite 406, Miami, FL 33130, US\nTax ID: 88-1234567\nNote: Select Puerto Rico as the final beneficiary bank country", "qr_png_base64": "iVBORw0KGgoAAAANSUhEUgAA…" } } ``` The preview endpoint's `qr_payload` has no `Amount`/`Reference` lines (there is no payin yet); the one embedded in an actual announcement always has both, so the payer can pay without typing anything by hand. Both blocks on the announcement are a **frozen snapshot**: if your CBPay operator later updates a registered account, live announcements keep pointing at the account they were created with — only new ones pick up the change. The exact same `deposit_instructions` (and `deposit_instructions_swift`, when present) blocks are echoed back on `GET /v1/payins/{id}` and on the list (`GET /v1/payins`), so your front end does not need to cache it from the creation response. On corridors without a registered destination account, the field is simply absent — fall back to showing the `reference` and asking the payer to use their usual bank details for your organization. ## 3. Receiving the credit When the payment arrives (through any of the modes), your account is credited automatically and the `payin_credited` webhook fires: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "9c2a…", "account_id": "…", "country": "BO", "currency": "BOB", "local_amount": "700.00", "fx_rate": "6.91", "usdt_credited": "100.302460", "fee": "1.000000" } ``` `fx_rate` is your `payin_rate` at credit time — the conversion happens at exactly that rate: `usdt_gross = 700.00 / 6.91`. The payin object keeps the full detail: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/payins/9c2a… \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "9c2a…", "kind": "qr", "status": "credited", "local_amount": "700.00", "fx_rate": "6.91", "usdt_gross": "101.302460", "fee": "1.000000", "usdt_credited": "100.302460" } ``` ## Statuses | Status | Meaning | | ------------ | ------------------------------------------------------------------------- | | `pending` | Charge created, waiting for the payment | | `credited` | Payment received and credited in USDT | | `unassigned` | Deposit received without an automatic match (routed by the administrator) | | `expired` | The charge expired unpaid | | `failed` | The collection failed | A deposit that cannot be resolved to a single announcement stays `unassigned` until the CBPay team routes it to an account (see [matching an announced transfer](#matching-an-announced-transfer)). Once assigned, it is credited with the destination account's rate and fees, and the announcement it belonged to is closed. When an active charge (QR or checkout) dies unpaid, the payin moves from `pending` to `expired` (or `failed`) automatically and you receive the [`payin_expired`](/en/webhooks) webhook. No funds move: to retry the collection, create a new payin. ## Reads and history ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # One payin curl https://api.qbank.cl/platform/v1/payins/9c2a… \ -H "Authorization: Bearer " # History with filters curl "https://api.qbank.cl/platform/v1/payins?from=2026-07-01&to=2026-07-08&status=credited&country=BO&page_size=50" \ -H "Authorization: Bearer " ``` `from`/`to` use `YYYY-MM-DD` (organization timezone); an invalid date responds `400 invalid_range`. ## Common errors | HTTP | `error` | What to do | | ---- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | 400 | `invalid_request` | Check `method` (qr, bank\_transfer, fintoc, card; collect has its own endpoint) | | 400 | `idempotency_key_required` | Collect requires an idempotency key (real debit against the payer) | | 403 | `service_disabled` | Payins is not enabled for your account — see [services](/en/concepts/services) | | 422 | `core_rejected` | The processor rejected the charge; check the message | | 422 | `deposit_instructions_unavailable` | `bank_transfer` on a corridor that requires a registered destination account (today CL, PY, US) and your organization has not configured one yet — contact your CBPay operator | | 502 | `core_unavailable` | The charge could not be created; retry the creation (nothing was charged) | `GET /v1/payins/deposit-instructions` responds `404 not_found` when the corridor has no active destination account configured — treat it the same way as the `422` above: the payer cannot be shown an account yet. ## FAQ Subscribe to `payin_credited`: it carries the FX rate applied, the fee and the exact `usdt_credited`. You can also poll `GET /v1/payins/{id}`. The `payin_rate` in force at credit time (see `GET /v1/rates`). Your agreed spread is already inside the rate — it is never itemized. Yes — set `default_payin_asset` with `PUT /v1/settlement`. The credit still enters in USDT and is converted right after at the real price; `conversion_status` reports `done` or `pending_retry` (auto-retried). You receive `payin_expired` and the payin closes without moving money. Create a new charge — nothing was debited or credited. The reference still matches the announcement, but the amount that arrived is what gets credited. A transfer that resolves to no announcement stays `unassigned` for reconciliation; your CBPay team can assign it to the right payin manually. Nobody by chance. If the payer document does not tell them apart, both announcements stay `pending` and the deposit lands as `unassigned` for the operator to route. Sending `payer_document` in the announcement is what turns this case into an automatic credit. No — it is optional and nothing breaks without it. When you omit it, the verified tax ID of the account is used (`payer_source: account_identity`), which covers self-deposits. Send it whenever a third party pays for your client, and always show the `reference` to the payer. No. With `idempotency_key` (body or `Idempotency-Key` header) the retry returns the original announcement with `idempotency_hit: true`. Even without a key, a POST that is identical to a live announcement (same account, currency, amount and payer) reuses it — duplicating it would leave the real deposit `unassigned` for ambiguity. Send different keys only when you really want to collect twice. The corridor publishes two destination accounts on purpose, and your payer picks the rail their bank supports: senders banking **inside the US** use the domestic rail (`deposit_instructions`) with the `routing_number` (ABA) — a Fedwire or ACH; senders **outside the US** use the international rail (`deposit_instructions_swift`) with the `swift` (BIC), the correspondent bank (`intermediary_bank_name` / `intermediary_bank_swift`) and the `notes` hints for the wire form. Whatever the rail, the `reference` (`CB…`) in the memo / remittance field is what credits the deposit automatically. A wire is usually reported the same business day; an ACH can take one to three business days depending on the sending bank — the credit and the `payin_credited` webhook happen the moment the bank reports the deposit. Banks don't share a common QR standard for arbitrary destination accounts (unlike a merchant QR at checkout) — every bank encodes account transfers differently, and most banking apps can't auto-fill a transfer from a third-party QR at all. `qr_png_base64` renders the account details as a QR purely as a **copy shortcut on mobile**: the payer scans it, gets the multi-line text (bank, account, holder, amount, reference), and pastes it into their own bank's transfer form — they still confirm the transfer themselves. Don't build a scan-and-pay flow around it; show it next to the plain-text fields so the payer can always type them manually. The response and `GET /v1/payins/{id}` persist a `failure` block with the rail's code and message (for example, a document that does not match the payer's bank registration). Fix the input and retry with a new key. # Payouts Source: https://docs.cbpayapp.com/en/guides/payouts Send fiat to local bank accounts, debited from your USDT balance A payout sends money in local currency to a bank account in the destination country. The amount converts from local currency to USDT at **your account's rate** (the one from `GET /v1/rates`) and `usdt_amount + fee` (the fixed fee, when configured) is debited from your balance. This is the full lifecycle, including what happens to your balance at each step: ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} sequenceDiagram autonumber participant App as Your app participant CB as CBPay participant Rail as Local banking rail App->>CB: POST /v1/payouts (idempotency_key) CB->>CB: Converts at your rate and debits
usdt_amount + fee (available → held) CB-->>App: 202 processing (fx_rate, total_debit) CB->>Rail: Disperses in local currency alt The money arrives Rail-->>CB: Confirmed CB->>CB: Consumes the hold — final CB-->>App: Webhook payout_status_changed (completed) else The rail rejects Rail-->>CB: Rejected CB->>CB: Refunds the full debit to available CB-->>App: Webhook payout_status_changed (failed + status_code) end ``` ## 1. Discover the available corridors Countries, currencies and methods are defined by CBPay. Always check the catalog: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/payouts/methods \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "items": [ { "country": "CL", "currency": "CLP", "method": "bank_transfer" }, { "country": "PE", "currency": "PEN", "method": "bank_transfer" }, { "country": "PE", "currency": "PEN", "method": "yape" }, { "country": "BO", "currency": "BOB", "method": "qr" } ], "meta": { "retrieved": 4 } } ``` Available corridors and methods: | Country | Currency | Methods | | ------------- | --------- | ------------------------------------------------------------------------- | | Chile | CLP | `bank_transfer` | | Peru | PEN | `bank_transfer`, `yape` | | Mexico | MXN | `bank_transfer` (SPEI: CLABE or debit card) | | Venezuela | VES | `bank_transfer`, `pago_movil` | | Bolivia | BOB / USD | `bank_transfer`, `qr` (see [QR payout](#qr-payout)) | | Brazil | BRL | `pix` (by key or to account), `qr` (PIX QR — see [QR payout](#qr-payout)) | | Ecuador | USD | `bank_transfer`, `deuna`, `cash_pickup`, `cnb` | | Paraguay | PYG | `bank_transfer` | | Argentina | ARS / USD | `bank_transfer` (CBU or CVU) | | United States | USD | `ach`, `wire`, `swift` | Availability may vary; the catalog (`GET /v1/payouts/methods`) is always the source of truth. If a country has a single method, `method` is optional. Every method is charged the same way: your rate + fixed fee. For bank transfers you also need the banks catalog (that is where the beneficiary's `bank_code` comes from): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/payouts/banks?country=CL" \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "items": [ { "code": "001", "name": "Banco de Chile" }, { "code": "012", "name": "Banco del Estado de Chile" }, { "code": "016", "name": "Banco de Crédito e Inversiones" } ], "meta": { "retrieved": 3 } } ``` ## 2. Create the payout ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "MX", "currency": "MXN", "method": "bank_transfer", "amount": "1500.00", "beneficiary": { "name": "Maria Lopez", "account_type": "clabe", "account_number": "012180001234567895" }, "description": "Invoice 8841", "idempotency_key": "invoice-8841" }' ``` `beneficiary` is a key/value object whose required fields depend on the corridor (RUT and bank in Chile, CLABE in Mexico, CCI in Peru, PIX key in Brazil, etc.). The methods catalog documents each one. Every payout saves the beneficiary as a [contact](/en/guides/contacts) automatically (`"save_contact": false` to skip it). To pay them again without re-typing their data, send `"beneficiary_contact_id"` instead of `beneficiary` — their most recent saved beneficiary for that country and method is used (`422 no_saved_destination` if there is none). Response `202 Accepted`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payout_id": "0d4f…", "account_id": "…", "idempotency_key": "invoice-8841", "country": "MX", "currency": "MXN", "method": "bank_transfer", "local_amount": "1500.00", "fx_rate": "17.50", "usdt_amount": "85.714286", "fee": "0.300000", "total_debit": "86.014286", "settlement_asset": "USDT", "settlement_amount": "86.014286", "settlement_rate": "1", "status": "processing", "bank_reference": "", "created_at": "2026-07-06T20:00:00Z" } ``` At that moment your balance already reflects the debit: `total_debit` moved from `available` into `held` (on the `settlement_asset` balance). **`bank_reference` — the bank's own id for the transfer.** While the payout is in flight it comes back empty (`""`); once the payout is `completed`, it carries the transaction id assigned by the destination bank/rail. It is the value the beneficiary can use to cross-check the payment with their bank, and it also appears in the `payout_status_changed` webhook, the PDF receipt, the payouts CSV export and the statement. ### Paying from another balance (`settlement_asset`) By default the debit comes from your default settlement asset (USDT unless you change it via `PUT /v1/settlement`). To pay a single operation from another balance, add `settlement_asset` to the request. Example: a 100,000 CLP payout paid from the BTC balance goes through four transformations, all recorded on the response: 1. **CLP → USDT** at your rate: `100000 / 950.25 = 105.235465 USDT`. 2. **+ fixed fee**: `105.235465 + 0.30 = 105.535465 USDT` (`total_debit`). 3. **USDT → BTC** at the effective settlement price (`settlement_rate` `109029.34070000`): `105.535465 / 109029.3407 = 0.00096795 BTC` (rounded up to the satoshi). 4. **Debit and hold in BTC**: `settlement_amount` `0.00096795` leaves your BTC balance; the beneficiary receives their 100,000 CLP exactly as always. ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "country": "CL", "currency": "CLP", "local_amount": "100000", "fx_rate": "950.25", "usdt_amount": "105.235465", "fee": "0.300000", "total_debit": "105.535465", "settlement_asset": "BTC", "settlement_amount": "0.00096795", "settlement_rate": "109029.34070000", "status": "processing", "bank_reference": "" } ``` If the payout fails, the exact `settlement_amount` is refunded to your BTC balance — never re-quoted. If the BTC/GOLD execution price is unavailable at that moment you get `503 pricing_unavailable`, and volatile assets have a per-operation limit (`422 settlement_limit_exceeded`; check it in `GET /v1/settlement`). ## 3. Receive the final state Subscribe to the `payout_status_changed` event ([webhooks](/en/webhooks)): ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payout_id": "0d4f…", "account_id": "…", "country": "MX", "currency": "MXN", "local_amount": "1500.00", "usdt_amount": "85.714286", "total_debit": "86.014286", "status": "completed", "status_code": "", "bank_reference": "00761123456" } ``` * **`completed`**: the money arrived; the hold is consumed. * **`failed`**: the full debit is refunded automatically (`payout_refund` in your ledger). You can also query at any time: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/payouts/0d4f… \ -H "Authorization: Bearer " ``` ### Payout statuses | Status | Meaning | Your balance | | ------------ | ---------------------------------------- | ---------------------------------------- | | `processing` | Accepted and executing on the local rail | Debit held in `held` | | `completed` | The money reached the beneficiary | Hold consumed — final | | `failed` | The corridor rejected it or it failed | **Full automatic refund** (amount + fee) | ## Reads and history Every payout can be read individually and the listing accepts filters: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # One payout curl https://api.qbank.cl/platform/v1/payouts/0d4f… \ -H "Authorization: Bearer " # History with filters: dates, status, country and pagination curl "https://api.qbank.cl/platform/v1/payouts?from=2026-07-01&to=2026-07-08&status=failed&country=MX&page=1&page_size=50" \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "page": 1, "page_size": 50, "payouts": [ { "payout_id": "0d4f…", "country": "MX", "currency": "MXN", "method": "bank_transfer", "local_amount": "1500.00", "fx_rate": "17.50", "usdt_amount": "85.714286", "fee": "0.300000", "total_debit": "86.014286", "status": "failed", "status_code": "core_rejected", "status_message": "beneficiary account does not exist", "bank_reference": "", "created_at": "2026-07-06T20:00:00Z" } ] } ``` `from`/`to` use `YYYY-MM-DD` (organization timezone, both inclusive); an invalid date responds `400 invalid_range`. ## Examples by country Every corridor with its exact `beneficiary`, the full request and the real response. Rates (`fx_rate`) are illustrative — your account's rates from `GET /v1/rates` always apply; the debit is `usdt_amount + fee` (fixed, when configured; `0.30` here). ### Beneficiary fields per corridor | Country | Method | `beneficiary` fields | | ------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | CL | `bank_transfer` | `name`, `tax_id` (RUT), `bank_code`, `account_type`, `account_number` | | PE | `bank_transfer` | `name`, `account_number` (20-digit CCI) | | PE | `yape` | `name`, `phone` (`51XXXXXXXXX`) | | MX | `bank_transfer` | `name`, `account_type` (`clabe`/`debit_card`), `account_number` (+ `bank_code` for cards) | | VE | `pago_movil` | `phone`, `bank_code` (SUDEBAN), `document_value` | | VE | `bank_transfer` | `name`, `account_number` (20 digits), `document_value` | | BO | `bank_transfer` | `name`, `tax_id`, `bank_code`, `account_number` | | BR | `pix` | `name`, `tax_id` + (`pix_key` and `pix_key_type`) or (`bank_code` ISPB, `branch_code`, `account_number`) | | EC | `bank_transfer` | `name`, `document_value` (cédula), `sender_name`, `account_number` (+ `bank_code` and `account_type` for other banks) | | EC | `deuna` | `name`, `document_value`, `sender_name`, `phone` (wallet mobile number) | | EC | `cash_pickup` / `cnb` | `name`, `document_value`, `sender_name` — the beneficiary withdraws with their ID | | PY | `bank_transfer` | `name` (max 35 chars), `tax_id`, `bank_code`, `account_number` | | AR | `bank_transfer` | `name`, `tax_id` (11-digit CUIT/CUIL), `account_number` (22-digit CBU or CVU; USD is CBU-only) | | US | `ach` / `wire` / `swift` | `name`, `account_number`, `email`, `country_code` (any country), `address`, `city`, `postal_code` (+ `state` if `country_code` is `US`), `bank_name`, `bank_country` (`US` on `ach`/`wire`), `bank_code` (ABA routing for `ach`/`wire`, SWIFT BIC for `swift`; + `account_type` `CHECKING`/`SAVING` for `ach`) | Bank transfer in CLP. Requires RUT, bank and account: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "CL", "currency": "CLP", "method": "bank_transfer", "amount": "100000", "beneficiary": { "name": "Pedro Soto Fuentes", "tax_id": "12.345.678-5", "bank_code": "012", "account_type": "checking", "account_number": "123456789" }, "description": "Supplier payment", "idempotency_key": "cl-prov-0091" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payout_id": "b3e1…", "country": "CL", "currency": "CLP", "method": "bank_transfer", "local_amount": "100000", "fx_rate": "925.69", "usdt_amount": "108.027528", "fee": "0.300000", "total_debit": "108.327528", "status": "processing", "bank_reference": "" } ``` The banks catalog (`GET /v1/payouts/banks?country=CL`) lists the current `bank_code` values. Two methods: bank transfer (interbank CCI) and **Yape** (to a phone number). ```bash bank_transfer (CCI) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "PE", "currency": "PEN", "method": "bank_transfer", "amount": "1000.00", "beneficiary": { "name": "Rosa Alvarez Diaz", "account_number": "00219300123456789012" }, "idempotency_key": "pe-cci-3310" }' ``` ```bash yape (phone) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "PE", "currency": "PEN", "method": "yape", "amount": "150.00", "beneficiary": { "name": "Luis Ramos Vega", "phone": "51987654321" }, "idempotency_key": "pe-yape-8874" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payout_id": "c7a2…", "country": "PE", "currency": "PEN", "method": "yape", "local_amount": "150.00", "fx_rate": "3.40", "usdt_amount": "44.117648", "fee": "0.300000", "total_debit": "44.417648", "status": "completed", "bank_reference": "00761123456" } ``` For `yape` the phone uses the `51XXXXXXXXX` format (11 digits with country code). The result is usually synchronous. SPEI in MXN, to a CLABE (18 digits) or a debit card: ```bash CLABE theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "MX", "currency": "MXN", "method": "bank_transfer", "amount": "1500.00", "beneficiary": { "name": "Maria Lopez", "account_type": "clabe", "account_number": "012180001234567895" }, "idempotency_key": "mx-clabe-8841" }' ``` ```bash Debit card theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "MX", "currency": "MXN", "method": "bank_transfer", "amount": "800.00", "beneficiary": { "name": "Jorge Herrera", "account_type": "debit_card", "account_number": "4152313412341234", "bank_code": "40012" }, "idempotency_key": "mx-card-1102" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payout_id": "0d4f…", "country": "MX", "currency": "MXN", "method": "bank_transfer", "local_amount": "1500.00", "fx_rate": "17.50", "usdt_amount": "85.714286", "fee": "0.300000", "total_debit": "86.014286", "status": "processing", "bank_reference": "" } ``` With a CLABE the destination bank derives from its leading digits; with a card, `bank_code` is required. Two methods: **Pago Móvil** (phone + bank + ID) and bank transfer (20-digit account): ```bash pago_movil theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "VE", "currency": "VES", "method": "pago_movil", "amount": "2000.00", "beneficiary": { "phone": "04141234567", "bank_code": "0102", "document_value": "V12345678" }, "idempotency_key": "ve-pm-5567" }' ``` ```bash bank_transfer theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "VE", "currency": "VES", "method": "bank_transfer", "amount": "5000.00", "beneficiary": { "name": "Carmen Delgado", "account_number": "01020123456789012345", "document_value": "V87654321" }, "idempotency_key": "ve-bank-7810" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payout_id": "e9b4…", "country": "VE", "currency": "VES", "method": "pago_movil", "local_amount": "2000.00", "fx_rate": "666.00", "usdt_amount": "3.003004", "fee": "0.300000", "total_debit": "3.303004", "status": "completed", "bank_reference": "00761123456" } ``` `bank_code` uses SUDEBAN codes; for `bank_transfer` it can derive from the account's first 4 digits. ACH transfer in BOB or USD (besides the [QR](#qr-payout)): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "BO", "currency": "BOB", "method": "bank_transfer", "amount": "1382.00", "beneficiary": { "name": "Juan Quispe Mamani", "tax_id": "4567890", "bank_code": "1016", "account_number": "1234567890" }, "idempotency_key": "bo-ach-2204" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payout_id": "f2c8…", "country": "BO", "currency": "BOB", "method": "bank_transfer", "local_amount": "1382.00", "fx_rate": "6.91", "usdt_amount": "200.000000", "fee": "0.300000", "total_debit": "200.300000", "status": "processing", "bank_reference": "" } ``` For USD send `currency: "USD"` with the same structure. PIX by key (besides the [PIX QR](#qr-payout)): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "BR", "currency": "BRL", "method": "pix", "amount": "350.00", "beneficiary": { "name": "João da Silva", "tax_id": "123.456.789-09", "pix_key_type": "cpf", "pix_key": "12345678909" }, "idempotency_key": "br-pix-3321" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payout_id": "a6d1…", "country": "BR", "currency": "BRL", "method": "pix", "local_amount": "350.00", "fx_rate": "5.13", "usdt_amount": "68.226121", "fee": "0.300000", "total_debit": "68.526121", "status": "processing", "bank_reference": "" } ``` `pix_key_type`: `cpf`, `cnpj`, `phone`, `email` or `evp` (random key). **PIX to account (no key)** — when the beneficiary does not have (or share) a PIX key, send their bank details; it arrives just as fast (same PIX rail, 24/7): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "BR", "currency": "BRL", "method": "pix", "amount": "350.00", "beneficiary": { "name": "Empresa Exemplo Ltda", "tax_id": "19.385.062/0001-20", "bank_code": "45678923", "branch_code": "1", "account_number": "765432", "account_type": "CACC" }, "idempotency_key": "br-pix-acct-3322" }' ``` * `bank_code` is the destination bank's **ISPB** (8 digits), `branch_code` the agency, and `account_type` the account type (`CACC` checking — default —, `SVGS` savings, `TRAN` payment account, `SLRY` salary). * The final status arrives through the `payout_status_changed` webhook (continuous reconciliation against the rail); check on demand with `GET /v1/payouts/{id}`. Remittances in **USD** (1 to 10,000 per operation, up to 2 decimals) with four methods: bank transfer, the **DE UNA** wallet (by mobile number), **cash pickup** at a branch (`cash_pickup`) and cash at a **non-bank correspondent** (`cnb`). This is a remittance corridor: besides the beneficiary, the rail requires the **sender's** data (who originates the payment), sent flat inside the same `beneficiary` object with the `sender_*` prefix. ```bash bank_transfer (to account) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "EC", "currency": "USD", "method": "bank_transfer", "amount": "250.00", "beneficiary": { "name": "Carlos Andrade Vera", "document_value": "1712345678", "account_number": "2203456789", "sender_name": "Ana Torres Silva", "sender_document_value": "V23456789", "sender_country": "US" }, "idempotency_key": "ec-bank-4471" }' ``` ```bash deuna (wallet) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "EC", "currency": "USD", "method": "deuna", "amount": "80.00", "beneficiary": { "name": "Lucia Paredes Mora", "document_value": "0923456781", "phone": "0998765432", "sender_name": "Ana Torres Silva" }, "idempotency_key": "ec-deuna-5520" }' ``` ```bash cash_pickup (branch) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "EC", "currency": "USD", "method": "cash_pickup", "amount": "120.00", "beneficiary": { "name": "Miguel Zambrano Loor", "document_value": "1309876543", "sender_name": "Ana Torres Silva" }, "idempotency_key": "ec-cash-6612" }' ``` ```bash cnb (correspondent) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "EC", "currency": "USD", "method": "cnb", "amount": "60.00", "beneficiary": { "name": "Rosa Cedeño Vera", "document_value": "0801234567", "sender_name": "Ana Torres Silva" }, "idempotency_key": "ec-cnb-7703" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payout_id": "9f3a…", "country": "EC", "currency": "USD", "method": "bank_transfer", "local_amount": "250.00", "fx_rate": "1", "usdt_amount": "250.000000", "fee": "0.300000", "total_debit": "250.300000", "status": "processing", "bank_reference": "" } ``` * Ecuador is dollarized: the local currency IS the USD (`fx_rate: "1"`). * `document_value` is the beneficiary's cédula; `document_type` accepts `IDCD` (national ID, default), `CCPT` (passport) or `TXID` (RUC). * On `bank_transfer`, omitting `bank_code` targets an account at the corridor's issuing bank; for **another bank** send the `bank_code` from the catalog (`GET /v1/payouts/banks?country=EC`) plus `account_type` (`checking` or `savings`). * Optional structured names (`given_name`, `middle_name`, `first_surname`, `second_surname` and their `sender_*` counterparts): send them when you have them — they take precedence over the automatic split of `name`. * The final status arrives via the `payout_status_changed` webhook (with periodic reconciliation as backup). Bank transfer in PYG: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "PY", "currency": "PYG", "method": "bank_transfer", "amount": "500000", "beneficiary": { "name": "Sofia Benitez", "tax_id": "4123456", "bank_code": "0011", "account_number": "600123456" }, "idempotency_key": "py-bank-9917" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payout_id": "d4e7…", "country": "PY", "currency": "PYG", "method": "bank_transfer", "local_amount": "500000", "fx_rate": "6055.76", "usdt_amount": "82.566020", "fee": "0.300000", "total_debit": "82.866020", "status": "processing", "bank_reference": "" } ``` `name` accepts up to 35 characters in this corridor. Bank transfer in **ARS** or **USD** to any 22-digit **CBU or CVU** (bank accounts and virtual wallets). No `bank_code` needed: the CBU/CVU identifies the bank on its own. ```bash ARS (CBU or CVU) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "AR", "currency": "ARS", "method": "bank_transfer", "amount": "50000.00", "beneficiary": { "name": "Julieta Fernandez", "tax_id": "27-23456789-1", "account_number": "2850590940090418135201" }, "idempotency_key": "ar-ars-3311" }' ``` ```bash USD (CBU only) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "AR", "currency": "USD", "method": "bank_transfer", "amount": "100.00", "beneficiary": { "name": "Julieta Fernandez", "tax_id": "27-23456789-1", "account_number": "2850590940090418135201" }, "idempotency_key": "ar-usd-3312" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payout_id": "b7c1…", "country": "AR", "currency": "ARS", "method": "bank_transfer", "local_amount": "50000.00", "fx_rate": "1250.00", "usdt_amount": "40.000000", "fee": "0.300000", "total_debit": "40.300000", "status": "completed", "bank_reference": "00761123456" } ``` * `tax_id` is the destination account holder's **CUIT/CUIL** (11 digits; dashes are accepted and normalized). * **USD works bank-account-to-bank-account only (CBU)**: a CVU (virtual wallet) does not support dollars — the payout is rejected before it is sent. * Most payouts confirm in the same call (`status: "completed"`); if the rail leaves it `processing`, the final state arrives via the `payout_status_changed` webhook. * Exceptional: the rail can **reverse** an already-credited transfer (for example, by order of the receiving bank). If that happens the payout moves to `failed`, the debit is fully refunded and you receive the `payout_status_changed` webhook. Payouts in **USD** to US bank accounts, with three methods: * **`ach`** — ACH transfer to a checking or savings account. Submitted for **next-day** settlement. * **`wire`** — domestic wire transfer. Minimum **USD 25.00**. * **`swift`** — international USD wire via SWIFT. Minimum **USD 25.00**. The US banking rail requires the beneficiary's **complete identity and postal address on every transfer** — an incomplete beneficiary is rejected at creation (`422`, see below). The beneficiary's `country_code` is their **country of residence and can be ANY country** (for example, an ACH transfer to a US bank for a beneficiary living in Germany); what stays fixed is the **bank**: on `ach`/`wire` the receiving bank must be in the US (`bank_country: "US"`), while `swift` is international by design. The compliance floor (Cuba, Iran, North Korea and Syria) remains blocked for the beneficiary's country AND the bank's country. Required and optional fields: | Field | `ach` | `wire` | `swift` | Notes | | ----------------------------------------------- | ------------------ | ------------------ | ----------- | ------------------------------------------------------------------------------------- | | `name` | required | required | required | Full legal name of the holder | | `account_number` | required | required | required | US bank account number | | `email` | required | required | required | The rail registers it for every beneficiary | | `country_code` | required | required | required | ISO-3166 alpha-2 of the **beneficiary's residence** — any country (not just `US`) | | `address`, `city`, `postal_code` | required | required | required | Full postal address of the beneficiary | | `state` | conditional | conditional | conditional | Required **only if `country_code` is `US`** (2-letter state code); optional otherwise | | `phone` | optional | optional | optional | Beneficiary contact phone | | `bank_name` | required | required | required | Receiving bank's name | | `bank_code` | required | required | required | **ABA routing number** (9 digits) for `ach`/`wire`; **SWIFT BIC** for `swift` | | `account_type` | required | — | — | `CHECKING` or `SAVING` | | `bank_country` | required (`US`) | required (`US`) | required | Receiving bank's country — **fixed to `US` on `ach`/`wire`**; any country on `swift` | | `bank_address`, `bank_city`, `bank_postal_code` | required | required | optional | Receiving bank's address block | | `bank_state` | required (US bank) | required (US bank) | optional | 2-letter state code of the receiving bank | | `bank_phone` | optional | optional | optional | Receiving bank's phone | There is no US banks catalog: `bank_code` is the beneficiary bank's own ABA routing number (ACH/wire) or SWIFT BIC (swift), which the beneficiary provides. To autodetect the bank while the sender types it, call `GET /v1/payouts/bank-directory/lookup` with the routing number or SWIFT/BIC — it resolves the bank name, city, state and address block from an embedded public bank directory, so you can prefill `bank_name` and the optional `bank_*` address fields. A `404 bank_not_found` simply means the code is not in the directory: keep the form manual. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/payouts/bank-directory/lookup?routing_number=021000021" \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "routing_number": "021000021", "bank_name": "JPMORGAN CHASE", "bank_city": "TAMPA", "bank_state": "FL", "bank_postal_code": "33610", "bank_country": "US", "bank_phone": "813-432-3700", "source": "directory", "directory_vintage": "fed_ach_2019" } ``` ```bash ach theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "US", "currency": "USD", "method": "ach", "amount": "250.00", "beneficiary": { "name": "John Carter", "email": "john.carter@example.com", "account_number": "123456789012", "account_type": "CHECKING", "country_code": "US", "address": "1200 Brickell Ave", "city": "Miami", "state": "FL", "postal_code": "33131", "bank_name": "Example Bank", "bank_code": "021000089", "bank_address": "270 Park Ave", "bank_city": "New York", "bank_state": "NY", "bank_postal_code": "10017", "bank_country": "US" }, "description": "Invoice 2210", "idempotency_key": "us-ach-2210" }' ``` ```bash ach (beneficiary outside the US) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "US", "currency": "USD", "method": "ach", "amount": "250.00", "beneficiary": { "name": "Anna Becker", "email": "anna.becker@example.com", "account_number": "123456789012", "account_type": "CHECKING", "country_code": "DE", "address": "Friedrichstrasse 100", "city": "Berlin", "postal_code": "10117", "bank_name": "Example Bank", "bank_code": "021000089", "bank_address": "270 Park Ave", "bank_city": "New York", "bank_state": "NY", "bank_postal_code": "10017", "bank_country": "US" }, "description": "Invoice 2210", "idempotency_key": "us-ach-2210-de" }' ``` ```bash wire theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "US", "currency": "USD", "method": "wire", "amount": "1000.00", "beneficiary": { "name": "John Carter", "email": "john.carter@example.com", "account_number": "123456789012", "country_code": "US", "address": "1200 Brickell Ave", "city": "Miami", "state": "FL", "postal_code": "33131", "bank_name": "Example Bank", "bank_code": "021000089", "bank_address": "270 Park Ave", "bank_city": "New York", "bank_state": "NY", "bank_postal_code": "10017", "bank_country": "US" }, "description": "Invoice 2211", "idempotency_key": "us-wire-2211" }' ``` ```bash swift theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "US", "currency": "USD", "method": "swift", "amount": "1000.00", "beneficiary": { "name": "John Carter", "email": "john.carter@example.com", "account_number": "123456789012", "country_code": "US", "address": "1200 Brickell Ave", "city": "Miami", "state": "FL", "postal_code": "33131", "bank_name": "Example Bank", "bank_code": "CHASUS33XXX", "bank_country": "US" }, "description": "Invoice 2212", "idempotency_key": "us-swift-2212" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payout_id": "c5f2…", "country": "US", "currency": "USD", "method": "ach", "local_amount": "250.00", "fx_rate": "0.9980", "usdt_amount": "250.501002", "fee": "0.300000", "total_debit": "250.801002", "status": "processing", "bank_reference": "" } ``` * **First payout to a brand-new beneficiary may stay `processing` longer**: the rail reviews new beneficiaries before moving money, so the response can come back with `status: "processing"` and `status_code: "pending_aml"`. The transfer executes automatically once the rail approves the beneficiary — you always get the final state via the `payout_status_changed` webhook (with periodic reconciliation as backup). Subsequent payouts to the same beneficiary go straight through. * If the rail **rejects the beneficiary**, the payout ends `status: "failed"` with `status_code: "counterparty_rejected"` and the debit is refunded automatically. * **Minimums**: `wire` and `swift` require at least **USD 25.00**; below that the creation is rejected with `422` and `status_message` `"…payouts require an amount of at least USD 25"`. ACH has no validated minimum. * The rail asks for a **payment purpose declaration** on every transfer. The defaults apply unless you override them per operation in `options` (values up to 140 characters): | `options` key | What it declares | Default | | ----------------- | -------------------------------------------------------------------- | ----------------- | | `purpose` | Payment purpose | `Invoice_Payment` | | `crypto_activity` | Whether the payment relates to crypto buy/sell activity (`Yes`/`No`) | `No` | | `payment_gateway` | Deposit gateway declaration | rail default | ## Mandatory supporting document on USD transfers EVERY outbound **USD** transfer over a bank rail (`ach`, `wire` or `swift`) requires an attached **supporting document** (invoice or receipt) — it is a requirement of the processing bank and applies to **any USD corridor**, not just the US one (for example, also to an international SWIFT such as `PY/USD/swift`). Send the file with `POST /v1/payouts/documents`: raw binary body with its `Content-Type` (PDF, PNG, JPEG, WEBP, TXT, CSV, DOC(X) or XLS(X), up to 50 MB) and the filename in the `name` query param. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST "https://api.qbank.cl/platform/v1/payouts/documents?name=invoice-2210.pdf" \ -H "Authorization: Bearer " \ -H "Content-Type: application/pdf" \ --data-binary "@invoice-2210.pdf" ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "document_key": "payout-docs/9f1c…/org/…/3c2e…" } ``` The `document_key` travels in `options.supporting_document_key`. On `ach`/`wire` you can add `options.document_reference_number` (invoice or document reference number) — it is sent to the bank. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "US", "currency": "USD", "method": "ach", "amount": "250.00", "beneficiary": { "…": "…" }, "options": { "supporting_document_key": "payout-docs/9f1c…/org/…/3c2e…", "document_reference_number": "INV-2210" }, "idempotency_key": "us-ach-2210" }' ``` * A USD bank-rail payout created **without the document** is rejected at creation with `400 supporting_document_required` (no debit). * A key uploaded by **another account** is rejected with `400 invalid_document_key`. * The `document_key` stays stored: if the create fails for another reason, you reuse it on the retry — **no need to re-upload the file**. ## QR payout Paying a collection QR (Bolivia, Brazil PIX) now has its own guide: Scan the QR for free, show your user the recipient's data and confirm the payment in a second call - charged like a regular payout. ## Common errors | HTTP | `error` | What to do | | ---- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `idempotency_key_required` | Send the key in body or header | | 400 | `beneficiary_required` | Include the `beneficiary` object | | 400 | `supporting_document_required` | USD bank-rail transfer without the attached supporting document — upload it with `POST /v1/payouts/documents` and pass `options.supporting_document_key` (see [supporting document](/en/guides/payouts#mandatory-supporting-document-on-usd-transfers)) | | 400 | `invalid_document_key` | The `supporting_document_key` does not belong to your account — upload the document with your own credentials and use the returned key | | 402 | `insufficient_funds` | Fund the account; the payout was not created | | 403 | `account_blocked` | The account is not active; contact the CBPay team | | 403 | `service_disabled` | Payouts is not enabled for your account — see [services](/en/concepts/services) | | 403 | `compliance_hold` | The payout was held by the platform's compliance controls and was NOT created (no debit). By policy the exact reason is not disclosed — contact support with the timestamp; see [errors](/en/errors) | | 422 | `currency_not_supported` | No FX rate for that currency | | 422 | (payout with `status: failed`) | The corridor rejected the data; the debit was already refunded — fix `beneficiary` and retry with a new key | | 503 | `channel_unavailable` | The payout channel is temporarily unavailable; retry later with the SAME `idempotency_key` | | 503 | `compliance_check_unavailable` | The compliance check could not be evaluated; the payout was NOT created — retry with the SAME `idempotency_key` | ## Immediate rejection vs later failure If the processor rejects the payout at creation, you receive `422` with the object in `status: failed` and the refund already applied. If it fails later (e.g. the destination account does not exist), the webhook arrives with `status: failed` and the automatic refund happens at that moment. ### Reading `status_code` on a failed payout | `status_code` | Meaning | Action | | ----------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | `core_rejected` | The processor rejected the operation at creation (invalid beneficiary data, corridor unavailable) | Read `status_message`, fix the data and create a new payout with a new key | | `counterparty_rejected` | The banking rail rejected the beneficiary itself (US/USD corridor) | Check the beneficiary's identity and address data with the holder, then create a new payout with a new key | | `channel_unavailable` | The payout channel became temporarily unavailable | Retry later; the refund (if a debit happened) is already applied | | *another code* | Later rejection by the banking rail (e.g. destination account closed) | Same: fix the data and create a new operation | | *(empty)* | Generic corridor failure | Check `status_message`; if unclear, contact support with the `payout_id` | In every case the refund is already applied — verify it with the `payout_refund` entry in [movements](/en/concepts/movements-reconciliation). A payout in `processing` cannot be cancelled through the API: the rail already has it. Wait for the final state via webhook or `GET` — it always arrives, with an automatic refund on failure. ## FAQ At creation: the payout debits and holds the funds immediately. If the payout fails, the exact debited amount (fee included) is refunded automatically. No — once dispatched to the rail it resolves to `completed` or `failed` on its own. Subscribe to `payout_status_changed` for the final state. The rate quoted at creation (returned as `fx_rate`), frozen for that operation. Your agreed spread is already inside the rate. Yes — set a per-account default (`PUT /v1/settlement`) or override per payout with `settlement_asset` (USDC, BTC, GOLD). Refunds return the exact settled amount, never re-quoted. The beneficiary failed the compliance screening: the payout was **not** created and your `idempotency_key` was not consumed. Review the beneficiary data or contact your CBPay team. Retry with the **same** `idempotency_key`: you get the original payout back (`idempotency_hit: true`) — never a duplicate. A new key is a new, independent payout. The US/USD rail reviews every brand-new beneficiary before moving money: the payout stays `processing` with `status_code: "pending_aml"` until the rail approves the beneficiary, and then executes automatically. You receive the final state via the `payout_status_changed` webhook — the next payouts to that same beneficiary no longer wait. If the rail rejects the beneficiary, the payout ends `failed` with `status_code: "counterparty_rejected"` and the debit is refunded. # QR payout Source: https://docs.cbpayapp.com/en/guides/qr-payout Scan a collection QR (Bolivia, Brazil PIX) and pay it in two steps: free scan, charged confirm In Bolivia (the local interoperable QR) and Brazil (**PIX QR**, including the "copia e cola" code) you can also **pay a collection QR** in two steps: scan and confirm. Scanning is **free**; you are only charged on confirm, exactly like a regular payout (your rate + fixed fee). Without `country`/`currency` Bolivia (BOB) is assumed; for Brazil send `country: "BR"` and `currency: "BRL"`. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR scan["1. POST qr/scan
(free)"] --> data["Recipient data
+ provider_reference"] data --> userConfirms{"Does the user
confirm?"} userConfirms -->|"Yes"| confirm["2. POST qr/confirm
(charged: your rate + fixed)"] userConfirms -->|"No"| done["Nothing was charged"] confirm --> result{"Synchronous
result"} result -->|"completed"| paid["Paid — debit consumed"] result -->|"failed"| refund["Full automatic
refund"] ``` ## 1. Scan the QR (free) ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts/qr/scan \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "qr_payload": "", "currency": "BOB" }' ``` Returns the recipient's data so the user can confirm who they are paying: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "scan_id": "…", "provider_reference": "…", "beneficiary_name": "Juan Quispe", "destination_account": "…", "amount": "700.00", "currency": "BOB", "glosa": "", "status": "…" } ``` `qr_payload` takes the raw PIX QR content (the EMV BR Code) **or the "copia e cola" code** — they are the same string: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts/qr/scan \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "BR", "currency": "BRL", "qr_payload": "00020126360014br.gov.bcb.pix0114+5511998765432520400005303986540575.005802BR5913LOJA DA MARIA6009SAO PAULO62110507PED423163040BF9" }' ``` The scan decodes the BR Code locally (validating its checksum) and returns the destination PIX key, the merchant name and the amount when the QR carries a fixed one: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "scan_id": "PIXSCAN-…", "provider_reference": "", "beneficiary_name": "LOJA DA MARIA", "destination_account": "+5511998765432", "amount": "75.00", "currency": "BRL", "glosa": "", "status": "scanned" } ``` * An empty `amount` means an **open-amount** QR: you decide how much to pay on confirm. With a fixed amount, the confirm must send exactly that amount. * **Static** PIX QRs are supported (the printed/reusable ones with the key embedded). A **dynamic** QR (payload carrying the PSP's URL instead of a key) answers `400` with `dynamic pix qr codes are not supported yet` — ask the beneficiary for their PIX key and use the [`pix`](/en/guides/payouts#examples-by-country) method. * A tampered or truncated payload answers `400` (invalid CRC checksum). ## 2. Confirm the payment (charged here) ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts/qr/confirm \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "provider_reference": "", "amount": "700.00", "currency": "BOB", "description": "QR lunch payment", "idempotency_key": "qr-2026-07-07-a" }' ``` ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts/qr/confirm \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "BR", "currency": "BRL", "provider_reference": "", "amount": "75.00", "description": "Order 4231", "idempotency_key": "qr-br-2026-07-16-a" }' ``` * `amount` is always required: with a fixed-amount QR it must match exactly — otherwise you get `422` with the payout in `status: failed` (`status_message: "amount mismatch: the qr requires exactly 75.00 BRL"`) and the **refund already applied**; fix the amount and retry with a new key. With an open-amount QR whatever you send is what gets paid. * A **static PIX QR is reusable by design** (a shop's printed QR gets paid many times): you can pay it again with a different `idempotency_key`. A failed attempt **does not burn the QR**. * The payment travels through the same PIX rail as the `pix` method (24/7); the QR's `txid` goes with it so the merchant reconciles automatically. * `usdt_amount + fixed fee` is debited at **your rate**, just like a `bank_transfer`. * The result is **synchronous**: the response already carries the final state (`completed`, or `failed` with an automatic refund) — no waiting. * Retries with the same `idempotency_key` return the original payout. In Bolivia the scan reference is single-use (a scanned QR can only be paid once); in Brazil a static PIX QR is reusable and each payment carries its own key. ## Errors | HTTP | Code | What to do | | ---- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | 400 | `invalid_qr_payload` | The QR is unreadable, corrupt or a dynamic QR (not supported) — nothing was created and your key was not consumed; ask the payer for a static QR | | 400 | `idempotency_key_required` | Reusable static QRs require an explicit `idempotency_key` on confirm | | 402 | `insufficient_funds` | Top up your balance and retry with the same key | | 403 | `compliance_hold` | The beneficiary failed screening; the payout was not created | | 422 | payout `failed` + refund | The amount does not match a fixed-amount QR, or the rail rejected the payment — the debit is refunded automatically | | 503 | `channel_unavailable` | Rail temporarily unavailable; retry later with the same key | The general error catalog lives in [Errors](/en/errors). ## FAQ No — the scan is a free local read. You are only charged when the confirm creates the payout. One-time QRs (fixed reference) admit a single payment. Reusable static QRs can be paid legitimately more than once — that is why the confirm requires an explicit `idempotency_key` per payment. Not yet — a dynamic QR answers `invalid_qr_payload` (400) with guidance. Ask the payer for the static QR of the destination. The same as a regular payout: the rate quoted at confirm, frozen for that operation, with your spread already inside. Fixed-amount QRs must be paid exactly; a mismatch fails the payout with an automatic refund. Open-amount QRs accept the amount you pass on confirm. Retry with the **same** `idempotency_key` — the QR never gets "burned" by validation errors: nothing is created until the payload validates. # QR Crypto POS Source: https://docs.cbpayapp.com/en/guides/qr-pos Amount-bearing crypto QR charges for processors with physical POS terminals: register your verified merchants, generate the QR, detect the payment and reconcile per client QR Crypto POS is the product for **processors/acquirers with a company account** operating physical POS terminals: you register your merchants (restaurants, hotels, stores) as verified *merchants* and generate **crypto QR charges with an exact amount** (USDT, USDC, BTC). The POS shows or prints the QR, the customer scans it with their wallet or exchange app, and the API detects the on-chain payment: the balance is credited to **your account** (auto-converted to your settlement asset) with the **merchant attribution** on every charge — so you know exactly how much each merchant collected and can settle with them later over any rail (transfers, fiat payouts, crypto). QR Crypto POS is available to **verified company accounts** with the `pos` service enabled. Every charge uses an **exclusive address** (an ephemeral wallet from the [universal checkout link](/en/guides/checkout) engine): payments can never cross between charges. ## End-to-end flow ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} sequenceDiagram participant POS as Merchant POS participant YOU as Your backend (API key) participant CB as CBPay participant Chain as Blockchain YOU->>CB: POST /v1/pos/merchants (approved KYB verification_id) CB-->>YOU: merchant_id POS->>YOU: 25 USDT sale YOU->>CB: POST /v1/pos/charges (merchant_id, amount, crypto) CB-->>YOU: exclusive address + QR + due + expires_at POS->>POS: shows/prints the QR Chain-->>CB: deposit detected (confirming within seconds) Chain-->>CB: deposit confirmed CB->>CB: credit + auto-conversion to the settlement asset CB-->>YOU: payin_credited webhook with pos_merchant YOU->>POS: PAID ``` ## 1. Register the merchant (once per client) Every merchant is bound to an **approved** [third-party KYC/KYB verification](/en/guides/kyc) — the merchant identity comes from there, never hand-declared. You can set an **informative commission** (`fee_percent` + `fee_fixed`): it moves no money, but the API computes it on every paid charge and the summary tells you the net to distribute. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST "https://api.qbank.cl/platform/v1/pos/merchants" \ -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \ -d '{ "verification_id": "9e2f41d0-6b3e-4b57-9d5c-2f2f0a97c001", "name": "La Terraza Restaurant", "external_ref": "resto-001", "fee_percent": "1", "fee_fixed": "0" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "merchant_id": "d2875683-fc80-4c7b-876a-fb585d7c6982", "account_id": "5138e8dd-64bd-43ef-aafe-8d9ef23bec9e", "name": "La Terraza Restaurant", "verification_id": "9e2f41d0-6b3e-4b57-9d5c-2f2f0a97c001", "external_ref": "resto-001", "fee_percent": "1", "fee_fixed": "0", "status": "active", "created_at": "2026-07-17T19:40:00Z", "updated_at": "2026-07-17T19:40:00Z" } ``` Query with `GET /v1/pos/merchants` (paginated) and `GET /v1/pos/merchants/{id}`. `PATCH /v1/pos/merchants/{id}` updates `status` (`active`/`disabled` — a disabled merchant cannot generate new charges), the commission and the `external_ref`. Identity is never edited: it comes from the verification. ## 2. Create the charge (one per sale) The contract mirrors the universal checkout link: the amount is denominated in your `settlement_asset` (your account default when omitted) and the **due the customer pays is quoted in the QR's crypto** at charge-creation time — when the customer pays in a different asset than yours, the conversion is already included in the due (you receive your exact target). ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST "https://api.qbank.cl/platform/v1/pos/charges" \ -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \ -d '{ "merchant_id": "d2875683-fc80-4c7b-876a-fb585d7c6982", "amount": "25", "crypto": "tron:usdt", "reference": "TICKET-0451", "expires_in": 900, "idempotency_key": "pos-0451-1" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "charge_id": "66773a3a-9911-4482-ae3c-09a481aba018", "payin_id": "bd3d88ca-af9c-4c05-a6f8-0982a2d187d5", "status": "pending", "amount": "25", "settlement_asset": "USDT", "crypto": "tron:usdt", "chain": "tron", "asset": "USDT", "address": "TC5SToDEigQtie7Crf9et7ui7zDsvdDHeG", "due": "25.000000", "received": "0.000000", "qr_payload": "TC5SToDEigQtie7Crf9et7ui7zDsvdDHeG", "qr_png_base64": "iVBORw0KGgo…", "merchant": { "id": "d2875683-…", "name": "La Terraza Restaurant", "external_ref": "resto-001" }, "reference": "TICKET-0451", "expires_at": "2026-07-17T19:55:00Z", "receipt_url": "https://api.qbank.cl/platform/v1/payins/bd3d88ca-…/receipt", "created_at": "2026-07-17T19:40:00Z" } ``` * `crypto`: `tron:usdt`, `eth:usdt`, `eth:usdc` or `btc:btc`. * `expires_in`: 300–86400 seconds (default 900). Quotes involving BTC are frozen for 15 minutes (`quote_expires_at`). * `idempotency_key` is **required**: retrying with the same key returns the SAME charge and the SAME address (`idempotency_hit: true`) — a POS retry can never open a second charge. * The **QR is the raw address** (compatible with Binance and every wallet); print the `due` next to it. For POS volume we recommend **TRON/USDT** as the primary rail: it confirms in \~1 minute with the lowest network costs. BTC confirms in \~30 minutes — fine for large tickets, not for coffee. ## 3. Detect the payment (polling or webhook) **POS polling**: `GET /v1/pos/charges/{charge_id}` every few seconds. As soon as the deposit appears on-chain (2-3 s typical, BEFORE confirmations), the response carries the **early detection**: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "charge_id": "66773a3a-…", "status": "pending", "confirming": true, "detected_amount": "25.000000", "due": "25.000000", "received": "0.000000" } ``` `confirming` is a UX signal ("payment detected, confirming…") — the actual credit only happens on on-chain confirmation: `status` flips to `paid`, `received` reflects the accumulated total and `paid_at` is stamped. **Webhook** (recommended to close the sale): `payin_credited` arrives with the attribution block: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "event_type": "payin_credited", "data": { "payin_id": "bd3d88ca-…", "kind": "pos", "settled_via": "crypto:tron:usdt", "crypto_amount": "25.000000", "settlement_asset": "USDT", "asset_amount": "25", "pos_merchant": { "id": "d2875683-…", "name": "La Terraza Restaurant", "external_ref": "resto-001" }, "receipt_url": "…" } } ``` ### Charge states | State | Meaning | What to do | | ------------------------------ | ------------------------------------------- | --------------------------------------------------------------------- | | `pending` | Waiting for the payment | The POS keeps showing the QR | | `pending` + `confirming: true` | Deposit detected, confirming on-chain | Show "payment detected" (TRON \~1 min, ETH minutes, BTC \~30 min) | | `paid` | Target reached and credited to your balance | Close the sale; the auto-swap to your settlement asset runs by itself | | `expired` | Expired without completing the payment | Create a new charge if the customer still wants to pay | **Partial payments** accumulate (`received`) toward the target; `paid` only when complete. **Late payments**: money arriving AFTER expiry is still credited to your account (the address stays alive) and the webhook fires — the expired charge's `received` reflects it for reconciliation; a real payment is never lost. **Overpayments** (tips) are credited too. ## 4. Reconcile and distribute per merchant `GET /v1/pos/charges?from=…&to=…&merchant_id=…` lists each merchant's charges (`merchant_id`, `status` filters; standard pagination). `GET /v1/pos/summary?from=…&to=…` returns the PER-MERCHANT aggregate — the view for settling with each client: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/pos/summary?from=2026-07-01&to=2026-07-17" \ -H "Authorization: Bearer $API_KEY" ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "from": "2026-07-01", "to": "2026-07-17", "merchants": [ { "merchant_id": "d2875683-…", "name": "La Terraza Restaurant", "external_ref": "resto-001", "charges_count": 214, "paid_count": 201, "fee_percent": "1", "fee_fixed": "0", "totals": [ { "settlement_asset": "USDT", "gross": "5025.000000", "processor_fee": "50.250000", "net_for_merchant": "4972.750000" } ], "refunded": [ { "asset": "USDT", "amount": "2.000000" } ] } ] } ``` `gross` is what was collected (the target of paid charges), `processor_fee` is your commission configured on the merchant, and `net_for_merchant` is what you owe them (same-asset refunds already deducted). Distribute over the existing rails: [transfers](/en/guides/transfers), [fiat payouts](/en/guides/payouts) or [crypto withdrawals](/en/guides/crypto). ## 5. Refunds `POST /v1/pos/charges/{charge_id}/refund` returns (part of) what was received to the payer as a **regular crypto withdrawal** from your balance — with hold, withdrawal fee and every compliance control of the rail. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST "https://api.qbank.cl/platform/v1/pos/charges/66773a3a-…/refund" \ -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \ -d '{ "amount": "25", "to_address": "TXHkw6bYtL2j…", "idempotency_key": "pos-ref-0451-1" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "refund_id": "b92b1ac0-…", "charge_id": "66773a3a-…", "withdrawal_id": "4630fe8c-…", "amount": "25.000000", "asset": "USDT", "to_address": "TXHkw6bYtL2j…", "status": "processing", "tx_id": "SIMTX…", "created_at": "2026-07-17T20:01:00Z" } ``` Key rules: * **`to_address` is always explicit.** We never auto-refund to the deposit origin: it may be an exchange hot wallet your customer does not control (the money would be lost). Ask for and confirm the refund address; the received `from_address` values stay in the charge detail as a reference. * **Hard cap**: the sum of a charge's refunds can never exceed what it received (`422 refund_exceeds_received`), even under concurrent requests. * Available on any charge **with received funds** — including an expired charge that got a late payment (the most common refund case). * The amount is refunded in the **charge's crypto**: since the payment was converted to your settlement asset, you need balance in that crypto ([swap](/en/guides/swaps) back if missing; the error is `insufficient_funds`). * The status follows the withdrawal lifecycle (`pending` → `processing` → `completed`/`failed`; a failed withdrawal refunds your debit and releases the cap). Query with `GET /v1/pos/charges/{id}/refunds`. ## Product errors | HTTP | Code | Fix | | ---- | --------------------------- | ----------------------------------------------------------------------------------------------------- | | 422 | `verification_required` | Register the merchant with the `verification_id` of their approved third-party KYC/KYB | | 422 | `verification_not_approved` | Wait for the verification approval (or check its status) before registering the merchant | | 422 | `merchant_disabled` | Re-enable the merchant (`PATCH status: "active"`) before charging | | 400 | `idempotency_key_required` | Send `idempotency_key` on the charge and the refund (body or `Idempotency-Key` header) | | 400 | `invalid_request` | `crypto` must be `tron:usdt`, `eth:usdt`, `eth:usdc` or `btc:btc`; `expires_in` between 300 and 86400 | | 503 | `pricing_unavailable` | The crypto price is not settlement grade (BTC); retry in a moment | | 422 | `nothing_received` | The charge has not received any on-chain payment: there is nothing to refund | | 422 | `refund_exceeds_received` | Lower the amount: received − already refunded is the maximum | | 400 | `to_address_required` | Send the explicit refund address (never auto-refunded to the origin) | | 402 | `insufficient_funds` | Not enough balance in the charge's crypto for the refund: swap back first | | 403 | `company_required` | QR Crypto POS is for company accounts | ## FAQ Early detection (`confirming: true`) appears within 2-3 seconds. Final confirmation depends on the network: TRON \~1 minute, Ethereum a few minutes, Bitcoin \~30 minutes. You decide whether to release the sale on `confirming` (your risk) or wait for `paid`. For POS we recommend TRON/USDT. Yes — `qr_png_base64` is print-ready and `qr_payload` is the raw address in case your POS renders the QR locally. Print the `due` and the network next to it: the customer must send the exact amount over the right network. Less: the charge stays `pending`, accumulating (`received`) toward the target. More (tips): the excess is credited too. Late (after expiry): still credited with its webhook — the expired charge's detail shows the `received` for reconciliation. A real payment is never lost. No — everything is credited to YOUR account (the processor), converted to your settlement asset. The per-merchant attribution (charges, webhooks and summary) tells you exactly how much belongs to each merchant so you can distribute over the rail of your choice. Every credited payment pays your account's `funding` fee (percent + fixed) and, when the paid asset differs from your settlement asset, the automatic conversion with its spread. The per-merchant commission (`fee_percent`/`fee_fixed`) is yours with your client: informative, we never charge it. In this version the POS talks to YOUR backend and your backend queries with your API key (a physical terminal must never hold your key). If you need backend-less POS terminals, tell us: a public read-only polling token per charge is on the roadmap. # Receipts Source: https://docs.cbpayapp.com/en/guides/receipts Branded PDF per operation, with a QR authenticity check, receipt_url on every response and automatic email delivery Every operation on your account — payouts, payins, transfers, crypto deposits and withdrawals, swaps and card purchases — has a downloadable **branded PDF receipt**: logo, colors, the operation status and a **signed verification code with a QR** that anyone can check publicly to confirm the document is authentic. There is nothing to build: every response and webhook of an operation includes its `receipt_url` ready to download, and when the operation reaches a final state the receipt is also **emailed automatically** to the account owner (with opt-out). ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} sequenceDiagram participant C as Your integration participant API as CBPay API participant T as Third party (receipt recipient) C->>API: POST /v1/payouts API-->>C: 201 with receipt_url Note over API: The operation reaches a final state API-->>C: Webhook payout_status_changed (includes receipt_url) API-->>C: Email to the account owner with the PDF attached C->>API: GET /v1/payouts/{id}/receipt API-->>C: Branded PDF with verification QR C->>T: Share the PDF T->>API: Scans the QR → GET /verify/receipts/{code} API-->>T: Page with the REAL, current status and amount ``` ## Downloading a receipt Every transactional resource with `GET /{id}` has its `GET .../receipt`. The PDF defaults to Spanish; add `?lang=en` for English. | Operation | Endpoint | | ------------------------- | -------------------------------------------------------------------- | | Payout | `GET /v1/payouts/{payoutID}/receipt` | | Payin | `GET /v1/payins/{payinID}/receipt` | | Internal transfer | `GET /v1/transfers/{transferID}/receipt` | | Crypto withdrawal | `GET /v1/crypto/withdrawals/{withdrawalID}/receipt` | | Crypto deposit | `GET /v1/crypto/deposits/{depositID}/receipt` | | Swap | `GET /v1/swaps/{swapID}/receipt` | | Card purchase | `GET /v1/cards/{cardID}/transactions/{transactionID}/receipt` | | Banking operation | `GET /v1/banking/operations/{operationID}/receipt` | | Segregated wallet send | `GET /v1/segregated-wallets/{walletID}/sends/{sendID}/receipt` | | Segregated wallet deposit | `GET /v1/segregated-wallets/{walletID}/deposits/{depositID}/receipt` | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/payouts/9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d/receipt?lang=en" \ -H "Authorization: Bearer $CBPAY_TOKEN" \ -o receipt.pdf ``` The crypto deposit `depositID` comes from `GET /v1/crypto/transactions` (each deposit's `deposit_id` field, next to its `receipt_url`). Only the operation's owner (or the organization admin) can download the receipt: a foreign ID returns `404 not_found`. The PDF shows the beneficiary data exactly as you submitted it. A payout receipt includes a **Bank reference** line — the transaction id assigned by the destination bank — as soon as the bank confirms the payment. While the payout is in transit the line simply does not appear: download the receipt again once it is `completed`. ## `receipt_url` in responses and webhooks Never build the URLs by hand: every payout, payin, transfer, withdrawal, deposit, swap and card transaction response includes `receipt_url`, and the final-state webhooks (`payout_status_changed`, `payin_credited`, `transfer_received`, `crypto_deposit_credited`, `crypto_withdrawal_status_changed`, `card_transaction`) carry it in the payload too. ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payout_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "status": "completed", "local_amount": "800.00", "currency": "VES", "receipt_url": "https://api.qbank.cl/platform/v1/payouts/9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d/receipt" } ``` ## Statuses and watermark The receipt reflects the operation status **at download time**: | Operation status | Badge | Watermark | | -------------------------------------- | ------------------ | --------------------------- | | `completed` / `credited` / `confirmed` | Green "Completed" | No | | `pending` / `processing` | Amber "Processing" | Yes — diagonal "PROCESSING" | | `failed` / `declined` / `reversed` | Red "Failed" | Yes — diagonal "FAILED" | A watermarked receipt is **not proof of payment**: the operation has not completed yet (or it failed). Download it again after the final-state webhook and it will come out clean. ## Authenticity verification (QR) Every PDF carries a printed **signed verification code** and its QR. The QR opens a public URL — no credentials — that answers with the operation's **real, current** data: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/verify/receipts/P9b1deb4d3b7d4bad9bdd2b0d7b3dcb6d16827185..." ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "valid": true, "type": "payout", "status": "ok", "raw_status": "completed", "amount": "800.00 VES", "detail": "Venezuela — Pago Móvil", "date": "2026-07-11 15:29 UTC", "issued_by": "CBPay" } ``` If the same URL is opened in a **browser** (for example by scanning the QR with a phone), it redirects to the **public tracking page** — a Wise-style hosted page with the live status, a step-by-step timeline and the PDF download. See [Transaction tracking link](/en/guides/tracking). * The response **never** includes the beneficiary's personal data, accounts or addresses: only type, status, amount and date. * The code is cryptographically signed: a tampered or made-up one answers `404` with `"valid": false`. * The verification shows the **current** data: if someone edits the PDF to inflate the amount, the QR exposes it instantly. * The `verify_url` included in receipt payloads and receipt emails points directly to the tracking page, so recipients land on the timeline view. ## Automatic receipt email When an operation reaches a final state (completed or failed), the account owner receives an email with the PDF attached and the verification link. It applies to payouts, payins, sent transfers, crypto deposits and withdrawals and swaps (card purchases do not email, to avoid flooding your inbox). To disable it (or turn it back on) per account: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X PATCH "https://api.qbank.cl/platform/v1/me" \ -H "Authorization: Bearer $CBPAY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"receipt_emails": false}' ``` ## Errors | HTTP | Code | Cause and solution | | ---- | ----------------------- | ---------------------------------------------------------------------------------------------- | | 404 | `not_found` | The ID does not exist or does not belong to your account. Check the ID in the product listing. | | 429 | `too_many_attempts` | Too many public verifications from your IP. Wait a moment and retry. | | 500 | `receipt_render_failed` | Transient error rendering the PDF. Retry the download. | ## FAQ As many times as you want: it is rendered on the fly with the operation's current data. That is why a receipt downloaded while `pending` comes out watermarked, and the same endpoint returns the clean version after the final webhook. Yes — that is what it is for. Whoever receives it can scan the QR and confirm against the platform that the document is authentic and that the status and amount are real, no credentials needed. The PDF is only a representation: the truth lives in the platform. The QR and code carry a cryptographic signature bound to the real operation; checking them shows the true amount and status, so any tampering is exposed. English (`?lang=en`), Spanish (`?lang=es`) and Simplified Chinese (`?lang=zh`). The public tracking page auto-detects the visitor's browser language. The branding of the platform you operate on (the operator's logo, colors and contact details). The PDF/Excel statement uses the same identity. No. The code verifies for as long as the operation exists, and it always answers its current status. Every receipt also has a shareable public tracking page with a live status timeline — no sign-in required. # Payin refunds Source: https://docs.cbpayapp.com/en/guides/refunds Refund a card payin in full or in part: the money leaves your balance, lands on your statement with a verifiable receipt, and chargebacks are applied automatically When you need to return money to a cardholder — a cancelled order, a duplicate charge, a dispute resolved in the customer's favour — the refund is an operation **on your account**: the processor returns the money to the card and we **debit that same value from your balance**, with its own ledger entry on your statement, its verifiable receipt and its webhook. It is the mirror image of a payin: a payin credits, a refund debits. ## What can be refunded | Requirement | Detail | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Method | Card payins only (`method: "card"`, including checkout paid by card and MIT charges on a stored card) | | Status | The payin must be `credited` **with its balance already available** — a card payin under a settlement delay (`settlement_pending: true`, balance landing at `settle_at`) cannot be refunded until the settlement releases | | Balance | You need enough **USDT balance** at the time you request it | | Amount | Full or partial; several partials on the same payin add up to the cap | QR, announced transfer, dedicated deposit account and collect payins **cannot** be refunded through this path (`refund_not_supported`): those rails have no refund capability at the processor. POS charges are refunded through the crypto rail with `POST /v1/pos/charges/{id}/refunds`. **Fees and the FX margin are not refundable.** We debit the value the payin brought in (gross), not the net that was credited: if you charged 100.00 USD and we credited 97.10 USDT after a 2.90 fee, refunding the full amount debits **100.000000 USDT**. You cover the difference, just like with any card processor. ## Lifecycle ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} sequenceDiagram participant Y as Your system participant C as CBPay participant P as Processor Y->>C: POST /v1/payins/{id}/refunds C->>C: reserves the amount from your balance C->>P: refund alt approved P-->>C: approved C-->>Y: 201 completed + payin_refunded webhook else declined P-->>C: declined C->>C: returns the reserved amount to your balance C-->>Y: 422 failed + payin_refunded webhook else no clear answer C-->>Y: 202 pending (reconciliation_required) P->>C: processor notification C-->>Y: payin_refunded webhook with the final status end ``` | Status | What it means | What to do | | ----------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `pending` | We asked the processor and there is no definitive answer yet. Your balance is already reserved. | Wait for the webhook. Do **not** retry with a different key: you would refund twice. | | `completed` | The processor approved it. The debit is final on your statement. | Nothing. Final status. | | `failed` | The processor declined it. The reserved amount went back to your balance, exactly. | Check `failure_reason` and decide whether to retry with a new key. | A `202 pending` **is not an error**: the refund may already be done. If you retry with a different `idempotency_key`, the payer receives the money twice. Always retry with the **same** key (we return the original object) or wait for the webhook. ## Request a refund Omit `amount` to refund everything that is left to refund: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins/9f1c2b30-…/refunds \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "reason": "order cancelled by the customer", "idempotency_key": "refund-order-8841" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "refund_id": "3a7d51c8-…", "payin_id": "9f1c2b30-…", "account_id": "c57f05b6-…", "kind": "refund", "status": "completed", "currency": "USD", "local_amount": "100.00", "usdt_debited": "100.000000", "requested_by": "account", "reason": "order cancelled by the customer", "idempotency_key": "refund-order-8841", "receipt_url": "https://api.qbank.cl/platform/v1/payin-refunds/3a7d51c8-…/receipt", "created_at": "2026-07-25T14:02:11Z", "updated_at": "2026-07-25T14:02:14Z" } ``` The response is `201` when the processor approves right away, `202` when it is still in flight and `422` when it declines. Send `amount` in the **payin currency** (not in USDT). The debit is computed proportionally to the value that payin brought in: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins/9f1c2b30-…/refunds \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "amount": "40.00", "reason": "partial refund for a missing item", "idempotency_key": "refund-order-8841-partial-1" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "refund_id": "b02e77af-…", "payin_id": "9f1c2b30-…", "kind": "refund", "status": "completed", "currency": "USD", "local_amount": "40.00", "usdt_debited": "40.000000", "requested_by": "account", "receipt_url": "https://api.qbank.cl/platform/v1/payin-refunds/b02e77af-…/receipt", "created_at": "2026-07-25T14:20:03Z", "updated_at": "2026-07-25T14:20:06Z" } ``` You can request several partials on the same payin. When the sum exceeds what is left to refund, we answer `422 refund_exceeds_payin` without touching your balance. If the payin has not settled at the processor yet, `kind: "void"` voids it instead of creating a refund. The effect on your balance is the same; for the cardholder, a void usually shows up on their statement sooner. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins/9f1c2b30-…/refunds \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "kind": "void", "idempotency_key": "void-order-8841" }' ``` If the payin can no longer be voided, the processor declines it and you can request a regular refund. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/payin-refunds/3a7d51c8-… \ -H "Authorization: Bearer " ``` And the history of one payin: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/payins/9f1c2b30-…/refunds?from=2026-07-01&to=2026-07-25" \ -H "Authorization: Bearer " ``` ## Second factor `POST /v1/payins/{payinID}/refunds` **takes money out of your account**, so it requires a second factor when a person originates it with their session (action `payin_refund`). If your organization has it enabled, the first call answers `403 otp_required` with a `challenge_id`: verify the code and repeat the request with the challenge token. **API keys are exempt** by design, same as everywhere else on the platform: your backend integrates without friction. ## History and filters ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/payin-refunds?from=2026-07-01&to=2026-07-25&status=completed&kind=refund&page=1&page_size=50" \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "page": 1, "page_size": 50, "refunds": [{ "refund_id": "3a7d51c8-…", "payin_id": "9f1c2b30-…", "kind": "refund", "status": "completed", "currency": "USD", "local_amount": "100.00", "usdt_debited": "100.000000", "requested_by": "account", "receipt_url": "https://api.qbank.cl/platform/v1/payin-refunds/3a7d51c8-…/receipt", "created_at": "2026-07-25T14:02:11Z", "updated_at": "2026-07-25T14:02:14Z" }] } ``` Filters: `status` (`pending`, `completed`, `failed`), `kind` (`refund`, `void`, `chargeback`), `payin_id`, `from`/`to` and pagination. ### How much of each payin is refunded Every payin exposes its refund progress, and you can filter the payin list by it: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/payins?from=2026-07-01&to=2026-07-25&refund_status=partial" \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "page": 1, "page_size": 50, "payins": [{ "payin_id": "9f1c2b30-…", "status": "credited", "currency": "USD", "local_amount": "100.00", "refund_status": "partial", "refunded_amount": "40.000000", "refunded_local": "40.00" }] } ``` | `refund_status` | Meaning | | --------------- | ------------------------------------------- | | `none` | No refunds | | `partial` | Partially refunded | | `full` | Fully refunded (or covered by a chargeback) | The payin **does not change status**: it stays `credited`. Your financial history is never rewritten; the refund is a new movement. ## Chargebacks When the card issuer imposes a **chargeback**, the money is already gone: it is neither your decision nor ours. In that case: * We debit the amount from your balance **automatically**, with `kind: "chargeback"`. * The debit is applied **even if you have no balance**: the account can go negative and that debt is netted against your next credits. * You receive the `payin_refunded` webhook with `kind: "chargeback"` and, if the balance went negative, the `balance_after` field. A chargeback **cannot be requested through the API**: it arrives from the issuer. All you can do is see it on your statement, your history and its receipt. What we debit across refunds and chargebacks on the same payin never exceeds what that payin brought in. If a chargeback arrives after you already refunded, the debit is capped to whatever was left — or lands at zero with the reason recorded — so we never charge you the same money twice. ## The `payin_refunded` webhook Emitted on every final state (`completed` or `failed`) and on chargebacks. `pending` refunds do not emit: wait for the final state. ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "event_type": "payin_refunded", "data": { "refund_id": "3a7d51c8-…", "payin_id": "9f1c2b30-…", "account_id": "c57f05b6-…", "kind": "refund", "status": "completed", "currency": "USD", "local_amount": "100.00", "usdt_debited": "100.000000", "receipt_url": "https://api.qbank.cl/platform/v1/payin-refunds/3a7d51c8-…/receipt" } } ``` A decline also carries `failure_reason`; a chargeback that leaves the account negative carries `balance_after`. ## Receipt Every refund has its PDF receipt with your organization's branding and a public verification code: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -L https://api.qbank.cl/platform/v1/payin-refunds/3a7d51c8-…/receipt \ -H "Authorization: Bearer " -o refund.pdf ``` Anyone can validate that code on the public verification page, without credentials and without seeing personal data. Details in [Receipts](/en/guides/receipts). ## Errors | HTTP | Code | Fix | | ---- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `idempotency_key_required` | Send `idempotency_key` in the body or the `Idempotency-Key` header. | | 400 | `invalid_payload` | `kind` only accepts `refund` or `void`. | | 400 | `invalid_amount` | `amount` must be a positive decimal in the payin currency. | | 402 | `insufficient_funds` | Top up the account and retry **with the same** `idempotency_key`. | | 404 | `not_found` | The payin (or the refund) does not exist on your account. | | 409 | `idempotency_conflict` | Another refund with that key is in flight; check its status. | | 422 | `payin_not_refundable` | The payin is not credited or has no processor reference. | | 422 | `settlement_pending` | The payin's balance is scheduled for settlement and is not available yet; refund it after it is released (at `settle_at`, or earlier if your org admin releases it). | | 422 | `refund_not_supported` | That rail cannot be refunded (QR, transfer, dedicated account, collect). POS charges are refunded through the crypto rail. | | 422 | `refund_exceeds_payin` | The requested sum exceeds what is left to refund. | Full catalogue in [Errors](/en/errors). ## FAQ No. The fee and the FX margin of the original payin are not refundable: we debit the gross value the payin brought in and the house keeps what it charged. This is the standard behaviour of the card industry. Not with another key. A `202` means the refund may already have been executed and we do not have confirmation yet. Retrying with a new key would be a second real refund. Repeat the request with the **same** `idempotency_key` (we return the same object) or wait for the `payin_refunded` webhook. Always **USDT**, the currency the payin was credited in, even if your account has a different default settlement asset for payins. We never convert on your behalf: if you do not have enough USDT, we answer `insufficient_funds`. As long as the payin is `credited` and has refundable value left, yes on our side. The real limit comes from the processor and the card scheme rules (usually 180 days); past that window the refund is declined with `failed` and the reserved amount returns to your balance untouched. The account is allowed to go negative for this reason only. The debt is settled automatically against your next credits; meanwhile, operations that move money out still require available balance. Yes. The admin panel can originate the refund on any account of the organization; it is audited with the admin who executed it and shows up in your history with `requested_by: "admin"`. # Wallet screening Source: https://docs.cbpayapp.com/en/guides/screenings Assess the AML risk of any blockchain address — sanctions, illicit funds, exposure — before transacting with it **Wallet screening** evaluates a blockchain address against global on-chain intelligence and returns its risk level: whether it belongs to a sanctioned entity, whether it received funds of illicit origin (ransomware, darknet markets, thefts) and which categories it is exposed to. Use it before sending funds to a third-party address, when a customer hands you a new wallet, or as part of your own compliance program. It is the on-chain counterpart of [AML screening](/en/guides/aml) (which evaluates person/company identities): here the subject is the **address**. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR scan["POST /v1/screenings/addresses
(charges a fee)"] --> riesgo{"risk"} riesgo -->|"Low / Medium"| ok["Proceed normally"] riesgo -->|"High"| cuidado["Review the details
(exposures and triggers)"] riesgo -->|"Severe"| bloquear["Do not transact
(sanctions / direct illicit)"] ``` The `address_screening` service fee (fixed, per scan) is debited on execution and **automatically refunded** if the screening fails. With a fee of 0 the service is free for you. It requires your own [identity verification approved](/en/guides/kyc#your-own-verification-onboarding) and the `screenings` service enabled on your account. ## Run a screening The assessment is **network-agnostic**: the same address is evaluated across every supported blockchain at once. The `chain` field is optional and only labels your record. Since the scan charges a fee, the `idempotency_key` is **required** — retrying with the same key never charges twice. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/screenings/addresses \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "address": "TN2BBWc9EF8MMB6i1c4HZHXAssTEXstMDo", "chain": "tron", "idempotency_key": "scan-customer-742-1" }' ``` Response `201` — a clean address: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "screening_id": "5f0b1c9a-2f3e-4a7b-9c1d-8e6f5a4b3c2d", "account_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "address": "TN2BBWc9EF8MMB6i1c4HZHXAssTEXstMDo", "chain": "tron", "risk": "Low", "screening_fee": "0.500000", "fee_asset": "USDT", "idempotency_key": "scan-customer-742-1", "created_at": "2026-07-12T14:30:00Z", "assessment": { "address": "TN2BBWc9EF8MMB6i1c4HZHXAssTEXstMDo", "risk": "Low", "address_identifications": [], "exposures": [ { "category": "exchange", "value_usd": "1250.75" } ], "triggers": [] } } ``` And a sanctioned address: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "screening_id": "7a2c4e6f-8b1d-4c3a-9e5f-1a2b3c4d5e6f", "account_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "address": "0x098B716B8Aaf21512996dC57EB0615e2383E2f96", "chain": "eth", "risk": "Severe", "risk_reason": "Identified as Sanctioned Entity", "screening_fee": "0.500000", "fee_asset": "USDT", "idempotency_key": "scan-suspicious-9", "created_at": "2026-07-12T14:31:00Z", "assessment": { "address": "0x098B716B8Aaf21512996dC57EB0615e2383E2f96", "risk": "Severe", "risk_reason": "Identified as Sanctioned Entity", "cluster_name": "OFAC SDN Ronin Bridge Exploiter", "cluster_category": "sanctioned entity", "address_identifications": [ { "name": "SANCTIONS: OFAC SDN Ronin Bridge Exploiter", "category": "sanctioned entity", "description": "This specific address 0x098b716b8aaf21512996dc57eb0615e2383e2f96 within this cluster has been identified as belonging to a sanctioned entity." } ], "exposures": [], "triggers": [] } } ``` Apply your policy on `risk` (level table below). The `assessment` object carries the full evidence for your records: point identifications, USD exposure per category and the risk rules triggered. Retrying with the same `idempotency_key` returns the original screening with `idempotency_hit: true` and **does not charge again**: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "screening_id": "5f0b1c9a-2f3e-4a7b-9c1d-8e6f5a4b3c2d", "account_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "address": "TN2BBWc9EF8MMB6i1c4HZHXAssTEXstMDo", "risk": "Low", "screening_fee": "0.500000", "fee_asset": "USDT", "idempotency_key": "scan-customer-742-1", "created_at": "2026-07-12T14:30:00Z", "idempotency_hit": true } ``` ## Query the history Every screening is stored. The list requires `from`/`to` and supports pagination and a risk filter: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/screenings/addresses?from=2026-07-01&to=2026-07-12&risk=severe&page=1&page_size=50" \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "page": 1, "page_size": 50, "screenings": [ { "screening_id": "7a2c4e6f-8b1d-4c3a-9e5f-1a2b3c4d5e6f", "account_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "address": "0x098B716B8Aaf21512996dC57EB0615e2383E2f96", "chain": "eth", "risk": "Severe", "risk_reason": "Identified as Sanctioned Entity", "screening_fee": "0.500000", "fee_asset": "USDT", "idempotency_key": "scan-suspicious-9", "created_at": "2026-07-12T14:31:00Z" } ] } ``` And the detail by id (includes the full `assessment`): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/screenings/addresses/7a2c4e6f-8b1d-4c3a-9e5f-1a2b3c4d5e6f \ -H "Authorization: Bearer " ``` ## Risk levels | `risk` | What it means | What to do | | -------- | -------------------------------------------- | ------------------------------------------------ | | `Low` | No relevant risk signals | Proceed normally | | `Medium` | Minor exposure to risk categories | Proceed; consider keeping an internal record | | `High` | Significant exposure to illicit funds | Review `exposures`/`triggers` before transacting | | `Severe` | Sanctioned entity or direct illicit activity | **Do not transact** with the address | Levels are **final** (a screening is a snapshot at query time): if you need to re-assess the same address later, run a fresh scan with a new `idempotency_key`. ## Automatic protection (free of charge) Beyond on-demand screening, the platform **protects your crypto operations automatically and for free**: * **On-chain withdrawals**: the destination address is assessed before signing. If it is severe-risk, the withdrawal is rejected and the held amount is **fully refunded** to your balance (you will see the withdrawal `failed` with `core_rejected` and its `crypto_withdrawal_status_changed` webhook). * **Incoming deposits**: the sender of every deposit is assessed before crediting. A severe-risk sender leaves the deposit **held for compliance review** (`crypto_deposit_held` webhook); a high-risk one is credited normally with an informational alert (`crypto_deposit_alert` webhook). A held deposit is NOT lost: your operator's compliance team reviews it and decides to release it (it is credited with its normal funding fee) or reject it. If you receive a `crypto_deposit_held`, contact your operator with the `tx_id`. ## Webhooks | Event | When | | ---------------------- | --------------------------------------------------------------------- | | `crypto_deposit_held` | An incoming deposit was held due to sender risk | | `crypto_deposit_alert` | A deposit was credited but the sender shows high risk (informational) | ```json crypto_deposit_held theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "ae8c…", "hold_id": "c1d2e3f4-…", "chain": "tron", "asset": "usdt", "tx_id": "8a5b3c…", "risk": "Severe", "status": "held" } ``` Subscribe just like the rest of the events (see [Webhooks](/en/webhooks)). ## Errors | HTTP | `error` | Cause | Solution | | ---- | -------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------- | | 400 | `idempotency_key_required` | Missing idempotency key | Send `idempotency_key` in the body or the `Idempotency-Key` header | | 400 | `invalid_payload` | Missing `address` or it is too long | Check the `address` field | | 400 | `invalid_range` | Missing or invalid `from`/`to` on the list | Use `YYYY-MM-DD` for both | | 402 | `insufficient_funds` | Insufficient balance for the fee | Fund the account and retry with the SAME key | | 403 | `verification_required` | Your account has not passed verification yet | Complete your [onboarding](/en/guides/kyc#your-own-verification-onboarding) | | 403 | `service_disabled` | The `screenings` service is disabled for your account | Contact your operator | | 404 | `not_found` | The `screening_id` does not exist or is not yours | Check the id | | 422 | `invalid_address` | The address could not be screened (format) | Check the address format | | 502 | `screening_unavailable` | Service temporarily unavailable (the fee was refunded) | Retry later with the SAME key | ## FAQ No. The assessment covers every supported network at once: an ETH address is evaluated with all of its known on-chain activity. `chain` is just an optional label for your own records. Yes. The product accepts any blockchain address — that is exactly the use case of assessing a third party before transacting with them. Each scan charges its fee. A screening is a snapshot at query time and is stored as evidence with its date. An address' risk can change (new sanctions, new activity): for sensitive decisions, re-assess with a fresh scan. No. The automatic screening of withdrawals and deposits is part of the platform's compliance program and has no cost. Only the on-demand scan (`POST /v1/screenings/addresses`) charges the `address_screening` fee. For safety, withdrawals are not signed without assessing the destination: the withdrawal is rejected with a full refund and you can retry later. Incoming deposits are NOT held because of a service outage — they are credited normally. # Segregated wallets Source: https://docs.cbpayapp.com/en/guides/segregated-wallets On-chain wallets with their own balance: create, import, receive, send, export the key and auto-forward — the balance lives on the blockchain, never in the ledger **Segregated wallets** are on-chain wallets **owned by your account**: their balance **is** the on-chain balance of the address, not a virtual balance in the CBPay ledger. You can receive and **send crypto directly from each wallet**, **import** external wallets with their private key, and **export** the key whenever you want (shared custody). They are ideal for segregating funds per client, per project or per business unit, with full control of the keys. Available to persons and companies, with different limits: **company** accounts can create **unlimited** segregated wallets; **person** accounts can hold **1 per network+asset pair** (a second one responds `422 wallet_limit_reached`). Don't confuse them with the [crypto](/en/guides/crypto) product: there, deposits **credit your USDT/USDC ledger balance** and withdrawals leave from a hot wallet. With segregated wallets the balance **lives on-chain in the wallet** and sends leave **from that same wallet**. **Gas** (TRX on TRON, ETH on Ethereum) is on you: each wallet must hold gas to be able to send. On Bitcoin there is no gas: the network fee comes out of the wallet's BTC balance. Two products, two routes: deposit wallets live under `/v1/crypto/wallets` and segregated wallets under `/v1/segregated-wallets`. Every wallet response carries a `type` discriminator (`deposit` / `segregated`) so you can always tell them apart. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR create["POST /v1/segregated-wallets"] --> wallet["On-chain wallet
(own balance)"] deposit["On-chain deposit"] --> wallet wallet --> whIn["webhook
wallet_deposit_received"] wallet --> send["POST /v1/segregated-wallets/{id}/sends"] send --> whOut["webhook
wallet_send_status_changed"] wallet --> export["POST /v1/segregated-wallets/{id}/export
(private key)"] ``` ## Supported pairs | Chain | Asset | Network gas | | ------ | ------ | --------------------------------------------------------- | | `tron` | `usdt` | TRX | | `eth` | `usdt` | ETH | | `eth` | `usdc` | ETH | | `btc` | `btc` | — (the network fee comes out of the wallet's BTC balance) | Native `eth` can be sent from any wallet on an `eth` chain (it is the network gas). On Bitcoin there is no separate gas: the network fee is deducted from the wallet's balance on every send. ## 1. Create a wallet ```bash TRON USDT theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/segregated-wallets \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -H "Idempotency-Key: wallet-client-001" \ -d '{ "chain": "tron", "asset": "usdt", "label": "Acme Client" }' ``` ```bash ETH USDC theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/segregated-wallets \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -H "Idempotency-Key: wallet-project-x" \ -d '{ "chain": "eth", "asset": "usdc", "label": "Project X" }' ``` Response `201`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "wallet_id": "b7e3a1c2-9f4d-4a8b-8c1e-2d3f4a5b6c7d", "type": "segregated", "chain": "tron", "asset": "USDT", "address": "TRmSZRaMAqLEevAdGwo3R43bRBXamWR5bd", "label": "Acme Client", "origin": "created", "custody": "cbpay", "exported": false, "created_at": "2026-07-11T12:00:00Z" } ``` The `custody` field reflects the key custody regime: | `custody` | Meaning | | --------- | --------------------------------------------------------------------------------------------- | | `cbpay` | Created by the platform and key never exported: only your API operations can move the balance | | `client` | Imported, or whose key was exported: you can also sign outside the platform | Under `client` custody the platform syncs the wallet's complete on-chain activity — including movements signed outside — and flags them as external, so your records and statement stay complete. Under `cbpay` custody the wallet's accounting is **guaranteed**: the statement shows its lifetime reconciliation (`lifetime_in` − `lifetime_out` = `computed_balance`) and each send's detail (`GET /v1/segregated-wallets/{walletID}/sends/{sendID}`) includes `funding_sources`: the FIFO attribution of which deposits funded that send, with `tx_id`, origin address and per-tranche amount. The `Idempotency-Key` (or `idempotency_key` in the body) makes retries safe: a repeat returns the SAME wallet with `idempotency_hit: true` and **never** creates a second one. Creating a wallet may charge the `wallet_creation` fee (fixed; 0 = free, the default). ## 2. Import an external wallet Bring in a wallet you already control by providing its private key. The key **travels encrypted in transit to the custodian and is never stored or logged in the platform**. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/segregated-wallets/import \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -H "X-OTP-Token: " \ -d '{ "chain": "tron", "asset": "usdt", "private_key_hex": "<64 hex>", "label": "Migrated wallet", "idempotency_key": "import-001" }' ``` Response `201` with the same shape as create (`origin: "imported"`). Charges the `wallet_import` fee. Import and export handle private key material: they **require a signed-in user session with 2FA** (API keys are not allowed) and OTP. If the chain/address pair already exists, the core responds `core_rejected`. ## 3. Check balance, deposits and transactions The balance comes **live from the blockchain** and includes network gas (so you can see if the wallet is short on TRX/ETH to send). ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Live on-chain balance (includes gas) curl https://api.qbank.cl/platform/v1/segregated-wallets/{walletID}/balance \ -H "Authorization: Bearer " # Received deposits (with pagination and dates) curl "https://api.qbank.cl/platform/v1/segregated-wallets/{walletID}/deposits?from=2026-07-01&to=2026-07-11&page=1&page_size=50" \ -H "Authorization: Bearer " # Full on-chain activity (deposits + sends) curl "https://api.qbank.cl/platform/v1/segregated-wallets/{walletID}/transactions?from=2026-07-01&to=2026-07-11" \ -H "Authorization: Bearer " ``` When a confirmed deposit arrives, CBPay emits the [`wallet_deposit_received`](/en/webhooks) webhook — **without touching your ledger**, because the balance is already in the wallet. ## 4. Send crypto from the wallet The send leaves **from the wallet itself** (real source address), signed by the custodian. `idempotency_key` is required. ```bash By amount theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/segregated-wallets/{walletID}/sends \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -H "X-OTP-Token: " \ -d '{ "asset": "usdt", "to_address": "TXYZ...destination", "amount": "25.50", "idempotency_key": "send-2026-07-11-a" }' ``` ```bash By minimal units theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/segregated-wallets/{walletID}/sends \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -H "X-OTP-Token: " \ -d '{ "asset": "usdt", "to_address": "TXYZ...destination", "amount_raw": "25500000", "idempotency_key": "send-2026-07-11-b" }' ``` Response `202` (the send is asynchronous; the final state arrives by webhook): ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "send_id": "9c8b7a6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d", "wallet_id": "b7e3a1c2-9f4d-4a8b-8c1e-2d3f4a5b6c7d", "chain": "tron", "asset": "USDT", "to_address": "TXYZ...destination", "amount_raw": "25500000", "fee": "0.000000", "fee_asset": "USDT", "status": "processing", "tx_id": "b1946ac92492d2347c6235b4d2611184...", "idempotency_key": "send-2026-07-11-a", "created_at": "2026-07-11T12:05:00Z" } ``` Before sending, CBPay verifies the wallet holds enough **gas** (TRON and Ethereum only; Bitcoin skips this check since the fee comes out of the amount); if not, it responds `422 insufficient_gas` with the required minimum — charging nothing. The send may charge the `wallet_send` fee (from your account's settlement balance in the ledger; **the wallet's on-chain funds are never touched**), refunded if the custodian rejects the send. A replay with the same `idempotency_key` → `200` with the original send and `idempotency_hit: true` — **never re-sent**. On an ambiguous failure (timeout/network) the send stays `pending`: retry with the **same** key; the dedupe guarantees it is not duplicated. Check the history: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/segregated-wallets/{walletID}/sends?from=2026-07-01&to=2026-07-11" \ -H "Authorization: Bearer " curl https://api.qbank.cl/platform/v1/segregated-wallets/{walletID}/sends/{sendID} \ -H "Authorization: Bearer " ``` ## 5. Export the private key Retrieve the wallet's private key. It is **shared custody**: after export, the wallet **remains fully operational** in CBPay (it can receive and send), but you also control the funds with the key. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/segregated-wallets/{walletID}/export \ -H "Authorization: Bearer " \ -H "X-OTP-Token: " \ -H "Content-Type: application/json" \ -d '{ "reason": "Custody migration to the client cold wallet" }' ``` Response `200`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "wallet": { "wallet_id": "b7e3a1c2-9f4d-4a8b-8c1e-2d3f4a5b6c7d", "type": "segregated", "chain": "tron", "asset": "USDT", "address": "TRmSZRaMAqLEevAdGwo3R43bRBXamWR5bd", "exported": true, "exported_at": "2026-07-11T12:10:00Z" }, "private_key_hex": "<64 hex>", "export": { "custody": "shared", "warning": "anyone holding this private key controls the wallet funds; the wallet remains operational in the platform" } } ``` This is the most sensitive operation of the product. It requires a **signed-in user session with 2FA** (no API keys), a **verified account**, and a `reason` of at least 20 characters kept in the audit trail. Each export fires the `wallet_key_exported` webhook to your organization. Charges the `wallet_export` fee. Whoever holds the private key controls the funds: store it securely. ## 6. Auto-forward Automatically forward everything that arrives at the wallet to an address of yours (useful to consolidate into cold storage). Since it redirects future funds, it requires verification and OTP. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Read the current rule curl https://api.qbank.cl/platform/v1/segregated-wallets/{walletID}/auto-forward \ -H "Authorization: Bearer " # Enable / update curl -X POST https://api.qbank.cl/platform/v1/segregated-wallets/{walletID}/auto-forward \ -H "Authorization: Bearer " \ -H "X-OTP-Token: " \ -H "Content-Type: application/json" \ -d '{ "linked_address": "TColdWallet...destination", "enabled": true }' # Disable curl -X POST https://api.qbank.cl/platform/v1/segregated-wallets/{walletID}/auto-forward \ -H "Authorization: Bearer " \ -H "X-OTP-Token: " \ -H "Content-Type: application/json" \ -d '{ "enabled": false }' ``` ## Send statuses | `status` | Type | Meaning / what to do | | ------------ | --------- | --------------------------------------------------------------------- | | `processing` | Transient | Broadcast on-chain; wait for the webhook confirmation | | `pending` | Transient | Ambiguous dispatch failure; retry with the **same** `idempotency_key` | | `completed` | Final | Confirmed on-chain | | `failed` | Final | Rejected; no funds moved | ## Product errors | HTTP | `error` | Fix | | ---- | --------------------------------- | ---------------------------------------------------------------------------------------------------------- | | 422 | `wallet_limit_reached` | A person account already holds its segregated wallet for that network+asset pair (companies have no limit) | | 403 | `human_session_required` | Import and export require a signed-in user session with 2FA (no API keys) | | 403 | `verification_required` | Complete your account onboarding ([verification](/en/guides/kyc)) | | 403 | `service_disabled` | The `wallets` service is not enabled; contact your operator | | 400 | `idempotency_key_required` | Send `idempotency_key` (body or `Idempotency-Key` header) | | 400 | `invalid_asset` / `invalid_chain` | Check the supported chain/asset pair | | 422 | `insufficient_gas` | The wallet has no gas (TRX/ETH) for the network fee; fund it and retry | | 409 | `idempotency_conflict` | Another request with the same key is still in flight; retry with the same key | | 503 | `export_unavailable` | Key export is not enabled in this environment | ## Related webhooks | Event | When | | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `wallet_deposit_received` | An on-chain deposit arrived at a segregated wallet (does not touch the ledger) | | `wallet_send_status_changed` | A send from the wallet changed status | | `wallet_key_exported` | A wallet's private key was exported (security alert) | | `wallet_external_movement` | The sync detected an on-chain movement that did not go through the platform (expected under `client` custody) | | `wallet_key_compromise_suspected` | **Critical alarm**: funds left a `cbpay`-custody wallet without going through the platform — treat the key as compromised and contact support immediately | Example payloads are on the [webhooks page](/en/webhooks). ## FAQ In the [crypto](/en/guides/crypto) product, deposits credit your USDT/USDC virtual ledger balance and withdrawals leave from a hot wallet — CBPay custodies and consolidates the funds. With segregated wallets the balance lives on-chain in each wallet, sends leave from that same address, and you can export the key. None of its balance goes through the ledger or gets swept to treasury. Sending on-chain requires gas IN the wallet (TRX on TRON, ETH on Ethereum). Unlike the crypto product, here the gas is on you. Fund the wallet address with a bit of the native coin and retry. `GET .../balance` shows the available gas and the required minimum. No. It is shared custody: the wallet stays operational in CBPay (receives and sends normally) and is flagged `exported`. You now also hold the key, so keep it safe. Never. These wallets are created exempt from the treasury sweep: their on-chain balance is exclusively yours. It only moves when you send or when you configure auto-forward. **Company** accounts have no limit: this is typical for segregating per client, project or business unit, using `label` to tell them apart. **Person** accounts can hold 1 segregated wallet per network+asset pair (a second one responds `422 wallet_limit_reached`). # Social login (Google, Apple, Microsoft, Meta) Source: https://docs.cbpayapp.com/en/guides/social-login Sign up and sign in with Google, Apple, Microsoft and Facebook, passwordless Your users can sign up and sign in with **Google, Apple, Microsoft or Facebook** — no passwords to create or remember. CBPay uses the **token exchange** model: the "Continue with…" button lives in your front end, the user approves at the provider, your front end receives a credential and passes it to the API; CBPay **verifies it cryptographically** and returns the session. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} sequenceDiagram participant U as User participant F as Your front end participant G as Provider (Google/Apple/MS/Meta) participant API as CBPay API U->>F: click "Continue with Google" F->>G: provider SDK (popup) G-->>F: credential (id_token / access_token) F->>API: POST /v1/auth/oauth {org, provider, credential} API->>G: verify signature and audience API-->>F: CBPay session (access_token) ``` Social login is enabled by your operator (organization), and **each organization uses its own** Google/Apple/Microsoft/Meta apps, so the user sees YOUR brand on the consent screen. Check which providers are active with `GET /v1/auth/oauth/providers`. ## 1. Discover the enabled providers To render the right buttons, your front end asks which providers are active and with which `client_id`: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/auth/oauth/providers?org=cbpay" ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "providers": [ { "provider": "google", "client_id": "1234567890-abc.apps.googleusercontent.com" }, { "provider": "apple", "client_id": "com.yourcompany.cbpay.web" } ] } ``` It is a public endpoint (no token needed): the `client_id` is not secret. ## 2. Get the credential in your front end Each provider hands you a credential through its own SDK. Minimal examples: With [Google Identity Services](https://developers.google.com/identity/gsi/web): ```html theme={"theme":{"light":"github-light","dark":"github-dark"}}
```
With [Sign in with Apple JS](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js): the response object carries `authorization.id_token`, which is what you send as `credential`. ```js theme={"theme":{"light":"github-light","dark":"github-dark"}} const data = await AppleID.auth.signIn(); await fetch("https://api.qbank.cl/platform/v1/auth/oauth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ org: "cbpay", provider: "apple", credential: data.authorization.id_token }) }); ``` Apple returns the user's name **only the first time**; store it in your front end if you need it. The email may be a private relay alias (`...@privaterelay.appleid.com`) — it is valid and stable. With [MSAL.js](https://learn.microsoft.com/entra/identity-platform/msal-overview): after `loginPopup`, the result's `idToken` is the credential. ```js theme={"theme":{"light":"github-light","dark":"github-dark"}} const result = await msalInstance.loginPopup({ scopes: ["openid", "email", "profile"] }); await fetch("https://api.qbank.cl/platform/v1/auth/oauth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ org: "cbpay", provider: "microsoft", credential: result.idToken }) }); ``` With the [Facebook Login SDK](https://developers.facebook.com/docs/facebook-login/web): Facebook is not OIDC, so you send the session's **access\_token**. ```js theme={"theme":{"light":"github-light","dark":"github-dark"}} FB.login(function(response) { if (response.authResponse) { fetch("https://api.qbank.cl/platform/v1/auth/oauth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ org: "cbpay", provider: "facebook", credential: response.authResponse.accessToken }) }); } }, { scope: "email" }); ```
## 3. Exchange the credential for a session ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/auth/oauth \ -H "Content-Type: application/json" \ -d '{ "org": "cbpay", "provider": "google", "credential": "eyJhbGciOiJSUzI1Ni…", "type": "person" }' ``` **New user** → the account is created and returns `201`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account": { "id": "9b1deb4d-…", "type": "person", "email": "ana@gmail.com", "display_name": "Ana" }, "access_token": "eyJhbGciOiJIUzI1Ni…", "expires_at": "2026-07-09T21:00:00Z", "created": true } ``` **Existing user** → signs in and returns `200` with `access_token`, `account_id` and `role` (same as password login). The `type` field (`person` | `company`, default `person`) is only used when creating the account; it is ignored if the account already exists. ### How create vs. sign-in is decided ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart TD A[Verified credential] --> B{Identity already linked?} B -->|yes| C[Sign in to that account] B -->|no| D{Account with that email exists
and provider verified it?} D -->|yes| E[Link the provider and sign in] D -->|no| F[Create new account + session] ``` ## 4. Social login and 2FA If the account has **OTP enabled on login** ([security and 2FA](/en/security-2fa)), social login respects that second step: instead of the session, `POST /v1/auth/oauth` returns `otp_required: true` + `pending_token`, and you complete it with `POST /v1/auth/login/otp` just like password login. ## 5. Link and unlink providers A signed-in user can manage their sign-in methods: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # List linked providers curl https://api.qbank.cl/platform/v1/me/identities \ -H "Authorization: Bearer " # Link another provider (with a fresh credential from that provider) curl -X POST https://api.qbank.cl/platform/v1/me/identities \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "provider": "apple", "credential": "eyJhbGci…" }' # Unlink curl -X DELETE https://api.qbank.cl/platform/v1/me/identities/apple \ -H "Authorization: Bearer " ``` You cannot unlink your **only** sign-in method: if the account has no password and that provider is the only one linked, the API responds `409 last_login_method` (set a password or link another provider first). ## Errors | HTTP | `error` | What it means | | ---- | ------------------------- | ------------------------------------------------------------------------------------------------------------------- | | 400 | `invalid_provider` | Provider outside `google/apple/microsoft/facebook` | | 400 | `provider_not_configured` | Your organization has not enabled that provider | | 401 | `invalid_credential` | The credential is invalid, expired or from another app | | 409 | `email_conflict` | An account with that email already exists; sign in with your current method and link the provider from your session | | 409 | `identity_taken` | That provider is already linked to another account | | 409 | `last_login_method` | You cannot unlink your only sign-in method | ## FAQ No. The consent flow happens in your front end with the provider SDK; you only send the resulting credential to CBPay. There are no callback pages or server-side state. Yes. They can register with email/password and later link Google, or the other way around. All methods point to the same account as long as the email matches and is verified. No automatic linking by email (prevents someone from claiming another person's email). A standalone account tied to that identity is created; the user can add email/password later. No. The provider credential is used once to verify you; every following call uses the CBPay `access_token`. # Account statement Source: https://docs.cbpayapp.com/en/guides/statement The consolidated statement: JSON for your web, downloadable PDF and Excel, ready for your accountant The statement consolidates **every** movement of an account in a period — payouts, payins, crypto deposits and withdrawals, internal transfers, card purchases, balance conversions, banking operations and service charges — into one auditable document. A single endpoint serves it in three formats: | Format | What for | How to request it | | ---------------- | -------------------------------------------------------- | ----------------- | | `json` (default) | Rendering the statement in your web/app | `format=json` | | `pdf` | Formal document with CBPay branding | `format=pdf` | | `xlsx` | Excel with per-section sheets, filters and numeric cells | `format=xlsx` | ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR ledger["Immutable ledger
(every movement with balance_after)"] --> build["Statement assembly
summary + breakdowns + detail"] build --> json["JSON
(web view)"] build --> pdf["Branded PDF
(download)"] build --> xlsx["Multi-sheet Excel
(download)"] build --> check{"Reconciliation:
opening + inflows − outflows
= closing"} ``` ## Requesting the statement ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # JSON for your front end curl "https://api.qbank.cl/platform/v1/reports/statement?from=2026-01-01&to=2026-07-07" \ -H "Authorization: Bearer " # Downloadable PDF (CBPay branding) curl -OJ "https://api.qbank.cl/platform/v1/reports/statement?from=2026-01-01&to=2026-07-07&format=pdf" \ -H "Authorization: Bearer " # Downloadable Excel curl -OJ "https://api.qbank.cl/platform/v1/reports/statement?from=2026-01-01&to=2026-07-07&format=xlsx" \ -H "Authorization: Bearer " ``` * `from` / `to`: `YYYY-MM-DD` dates, inclusive, in your organization timezone. Maximum range: 400 days. * `lang=es|en`: language of the PDF/Excel (default `es`). * Files arrive with `Content-Disposition: attachment` and the name `cartola_cbpay___.pdf/.xlsx`. ## What it contains ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account": { "account_id": "…", "display_name": "Example Company SpA", "type": "company" }, "period": { "from": "2026-01-01", "to": "2026-07-07", "timezone": "UTC" }, "generated_at": "2026-07-07T15:00:00Z", "summary": { "opening_balance": "0.000000", "total_in": "985633.540000", "total_out": "38099.870000", "net_change": "947533.670000", "closing_balance": "947533.670000", "balanced": true, "counts": { "payouts": 51, "payins": 12, "crypto_deposits": 18, "transfers": 4, "movements": 771 }, "fees_by_service": { "payout": "15.300000", "funding": "897.550000" }, "total_fees": "912.850000" }, "breakdown": { "by_product": [ { "product": "payouts", "count": 51, "usdt_in": "0.000000", "usdt_out": "38099.870000", "fees": "15.300000" } ], "by_country": [ { "flow": "payouts", "country": "BO", "currency": "BOB", "count": 14, "local_amount": "28748.58", "usdt_amount": "2902.210000" } ], "by_currency": [ { "currency": "BOB", "payout_local": "28748.58", "payin_local": "700.00" } ], "by_month": [ { "month": "2026-01", "usdt_in": "985633.540000", "usdt_out": "35100.000000" } ] }, "payouts": [ { "created_at": "…", "payout_id": "…", "country": "BO", "beneficiary": "Juan Quispe", "local_amount": "90.00", "fx_rate": "6.91", "usdt_amount": "13.024600", "fee": "0.300000", "fee_percent": "0.200000", "fee_fixed": "0.100000", "total_debit": "13.324600", "status": "completed", "bank_reference": "00761123456" } ], "payins": [ { "…": "…" } ], "card_transactions": [ { "created_at": "…", "transaction_id": "…", "card_id": "…", "kind": "purchase", "merchant": "AMAZON.COM", "amount_usd": "25.00", "spend_asset": "USDT", "spend_amount": "25.000000", "status": "settled" } ], "swaps": [ { "created_at": "…", "swap_id": "…", "from_asset": "USDT", "to_asset": "BTC", "from_amount": "10.000000", "to_amount": "0.00015433", "rate": "0.00001543", "status": "completed" } ], "banking_operations": [ { "created_at": "…", "operation_id": "…", "direction": "out", "type": "wire", "currency": "USD", "amount": "150.00", "counterparty": "Acme Inc", "status": "completed" } ], "assets": [ { "asset": "GOLD", "opening_balance": "0.000000", "total_in": "12.500000", "total_out": "2.000000", "net_change": "10.500000", "closing_balance": "10.500000", "balanced": true, "movements": [ { "type": "adjustment", "amount": "12.500000", "balance_after": "12.500000", "created_at": "…" } ] } ], "crypto_deposits": [ { "chain": "tron", "asset": "USDT", "tx_id": "…", "usdt_gross": "100.000000", "fee": "1.000000", "usdt_credited": "99.000000", "balance_after": "99.000000" } ], "crypto_withdrawals": [ { "…": "…" } ], "transfers": [ { "direction": "sent", "counterparty": "Ana Perez", "asset": "USDT", "amount": "25.000000" } ], "service_charges": [ { "type": "banking_fee", "service": "banking_customer", "fee_model": "fixed", "amount": "-0.500000", "balance_after": "98.500000" } ], "movements": [ { "type": "funding", "amount": "99.000000", "balance_after": "99.000000", "created_at": "…" } ] } ``` Sections: 1. **`summary`** — opening balance, inflows, outflows, closing balance, fees by service and the `balanced` flag of the **USDT balance** (the operating currency). 2. **`assets`** — one reconciled section per non-USDT balance with activity or balance (USDC, BTC, GOLD and, if you use Banking, the `BANK_USD`/`BANK_EUR` mirrors of your bank accounts): opening/closing balance, inflows, outflows, its own `balanced` flag and its movements, in each currency's precision. Empty if you only operate USDT. 3. **`breakdown`** — by product, by country (payouts and payins with local amount and USDT), by fiat currency and by month. 4. **Per-product detail** — payouts (beneficiary, rate and debit), payins (per mode), crypto (with `tx_id` and its `asset`), transfers (with counterparty and `asset`), card purchases (`card_transactions`, with merchant and spending balance), balance conversions (`swaps`), banking operations (`banking_operations`) and service charges (with refunds). 5. **`movements`** — the raw USDT ledger: every movement with its `balance_after`. This is the section an auditor uses to tie everything out (other currencies' movements live inside their `assets` section). **Transparent fees.** On payouts, payins and crypto withdrawals, when the fee combines a percentage and a fixed component, the statement splits them into `fee_percent` and `fee_fixed` (they add up exactly to `fee`). Standalone charges (compliance, wallets, banking, verifications, cards) are always fixed-amount and carry `fee_model: "fixed"` — the PDF/Excel labels them **Fixed Com**. Historical operations predating this field only show the combined `fee`. ## How to reconcile it (for your accountant) The statement satisfies an exact accounting identity, with no rounding: ``` opening_balance + total_in − total_out = closing_balance ``` * `balanced: true` confirms the identity holds against the ledger — both on the USDT summary and inside each `assets` section (every currency reconciles separately; amounts of different currencies are never summed). * Every `movements` row carries the resulting balance (`balance_after`): you can follow the balance line by line from opening to closing. * The closing balance of one period matches the opening of the next. * Fees are never hidden inside amounts: every operation shows gross, fee and net separately, and `fees_by_service` totals them. * In the Excel file, the **Movements** sheet uses real numeric cells: you can sum/pivot without cleaning anything. * The Excel **Payouts** sheet carries a **Bank ref** column (right after the reference/concept column) with the transaction id assigned by the destination bank once it confirms the payment. The statement PDF omits it on purpose (table density) — the individual payout receipt does show it. ## For the administrator (org admin) The CBPay team can generate any of its accounts' statements: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/accounts/{accountID}/reports/statement?from=2026-01-01&to=2026-07-07&format=pdf" \ -H "X-API-Key: " ``` The administrator view also includes additional operational information for the period (detailed in the administration documentation). ## Errors | HTTP | `error` | Cause | | ---- | ---------------- | ----------------------------------------------------------------- | | 400 | `invalid_range` | Missing/invalid dates, `to` before `from`, or range over 400 days | | 400 | `invalid_format` | `format` other than `json`, `pdf`, `xlsx` | | 404 | `not_found` | The account does not exist (org admin only) | ## FAQ On demand — every request builds it live from the ledger for the `from`/`to` range you pass (both required, `YYYY-MM-DD`, organization timezone). Each asset reconciles independently: `opening + credits − debits = closing` for USDT, USDC, BTC, GOLD and the banking mirrors. If any asset does not balance the flag is `false` — report it to your CBPay team. They mirror your banking money inside the statement so the account reconstructs completely. The authoritative balance is always the bank's (`GET /v1/banking/accounts/{id}/balance`); these mirrors are never spendable. JSON (integration), PDF and XLSX — both branded with your organization's identity. Use the `Accept` header or the format parameter of the endpoint. Standalone service charges (verifications, screenings, wallet services) are fixed-only fees, labeled "Fixed Com" in the statement — as opposed to percent+fixed transactional fees. Yes — every operation has a [receipt](/en/guides/receipts) with a public verification code; anyone can validate it without authentication. # Stored cards & subscriptions Source: https://docs.cbpayapp.com/en/guides/stored-cards-subscriptions Save cards with the payer's consent, charge them one-click or without the payer present (MIT) and schedule recurring subscriptions The `card` method supports **stored credentials** (the card brands' COF mandate): your payer saves their card with explicit consent on the first payment, and afterwards you can offer one-click payment without re-typing the number — or charge subscriptions and unscheduled amounts yourself without the payer present. The card number **never exists** in your integration or on the platform: only an opaque processor reference plus display data (brand, last 4 digits, expiry) is stored. Create the `card` payin with `save_card: true` and your payer reference: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "BO", "currency": "BOB", "method": "card", "amount": "700.00", "save_card": true, "payer_reference": "customer-1042", "idempotency_key": "topup-7720" }' ``` The hosted page shows a **"Save this card for future payments"** checkbox. The credential is stored ONLY if the payer ticks it and the 3-D Secure payment is approved. On credit you receive the `card_stored` webhook and the card appears in your list. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/stored-cards?from=2026-07-01&to=2026-07-20&payer_reference=customer-1042" \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "page": 1, "page_size": 50, "stored_cards": [{ "stored_card_id": "5f0f2c9e-…", "payer_reference": "customer-1042", "country": "BO", "currency": "BOB", "brand": "visa", "last4": "2701", "expiry_month": "12", "expiry_year": "2028", "status": "active", "created_at": "2026-07-20T18:00:00Z" }] } ``` Create the `card` payin with `stored_card_id`: the page skips card entry, shows the saved card (`VISA •••• 2701`) and 3-D Secure still runs — the payer only confirms with their bank. The **billing details** the payer entered when saving the card are also kept on file: the page applies them automatically and shows only a masked summary (name, partial email, city) with a "use different details" link in case they want to change them — nothing is retyped. This server-to-server path needs no extra verification: you already know your customer. Don't know which card they have saved (or whether they have one)? Don't pass `stored_card_id`: the payment page lets the payer discover their cards by verifying their email with a code — see [the payer discovers their cards](#the-payer-discovers-their-cards-on-the-payment-page). ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payins \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "BO", "currency": "BOB", "method": "card", "amount": "350.00", "stored_card_id": "5f0f2c9e-…", "idempotency_key": "topup-7721" }' ``` Charge the card directly — subscriptions (`recurring: true`) or unscheduled amounts your customer agreed to: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/stored-cards/5f0f2c9e-…/charges \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "amount": "45.00", "description": "Monthly subscription", "recurring": true, "idempotency_key": "sub-2026-07-cust1042" }' ``` Response `201` — an approved charge credits your balance automatically (`payin_credited` webhook, same path as any card payin): ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "3c5b002c-…", "status": "pending", "reference": "3c5b002c-…", "transaction_id": "7846012604…", "note": "charge approved; the balance is credited automatically (payin_credited webhook)" } ``` An issuer decline responds `422` with the payin in `failed` and a `failure_reason`. A retry with the same `idempotency_key` returns the original payin and **never charges twice**. To revoke a saved card (at the payer's request or on suspicion): `DELETE /v1/stored-cards/{stored_card_id}` — charges stop working immediately and you receive `stored_card_revoked` (`422 stored_card_revoked` if you try to charge it afterwards). Charges without the payer present travel **without 3-D Secure** by definition of the mandate: the chargeback risk is yours. Charge only what your customer explicitly agreed to — the platform persists the seed's consent evidence (checkbox, IP and timestamp) for disputes. ## The payer discovers their cards on the payment page Every card payment page — the `payment_url` of a `card` payin and the card option of the universal checkout — asks for the **payer's email as the first field**. If that email has saved cards with you, the page sends a **verification code** (branded with your organization's identity) and only once they enter it correctly does it reveal their cards: brand, last 4 digits and expiry, never the full number. Picking one pays with 3-D Secure without re-typing it; they can also choose "use another card" and pay with a new one. If you already sent it in `customer.email` (or a `payer_reference` holding an email), the page shows it pre-filled. If the email has no saved cards, the new-card form carries on — nothing is revealed. When cards are found, the page emails a code and asks for it. The **"Remember this device"** checkbox (checked by default) trusts the device for **30 days**: later payments with that email in that browser show the cards without asking for a code. With the email verified, the payer sees their masked cards, picks one and only completes 3-D Secure. The **verified** email becomes the payer identity of the charge — it wins over any email typed into the form. Trust is per device and lasts 30 days; each payer can have up to 10 remembered devices (the oldest is forgotten past the cap). If a payer loses a device, support can revoke their remembered devices and they will get a code again on their next payment. ## Subscriptions (scheduled recurring charges) Instead of charging manually every month, let **the platform run the schedule**: create a subscription on the saved card — the first period is charged on creation (unless `start_at` is in the future) and the rest fire automatically per `interval`. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/subscriptions \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "stored_card_id": "5f0f2c9e-…", "amount": "45.00", "interval": "monthly", "description": "Monthly plan", "idempotency_key": "plan-cust1042-monthly" }' ``` Response `201` (`first_charge` is present when the first period was charged on creation): ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "subscription_id": "7a1c9e2d-…", "stored_card_id": "5f0f2c9e-…", "amount": "45.00", "currency": "BOB", "interval": "monthly", "status": "active", "period": 1, "next_charge_at": "2026-08-20T18:00:00Z", "first_charge": { "outcome": "approved", "payin_id": "3c5b002c-…" } } ``` * `interval`: `daily`, `weekly`, `monthly` or `yearly`. The day of month is kept and clamped to the last day in short months (a plan on the 31st charges on Feb 28/29 and returns to the 31st in March). * `start_at` (optional, future RFC3339): defers the first charge (trial / start date); without it, it charges on creation. * **Dunning**: on an issuer decline the platform retries every 24h up to 3 times; exhausted, the subscription becomes `past_due` and you get the `subscription_status_changed` webhook. `resume` reactivates it with a fresh attempt. * Each successful charge credits your balance like any card payin (`payin_credited` webhook, carrying `subscription_id` to link it to the plan). Lifecycle management: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Pause (stops charging; resuming does NOT catch up missed periods) curl -X POST https://api.qbank.cl/platform/v1/subscriptions/7a1c9e2d-…/pause -H "Authorization: Bearer " # Resume curl -X POST https://api.qbank.cl/platform/v1/subscriptions/7a1c9e2d-…/resume -H "Authorization: Bearer " # Cancel (terminal) curl -X POST https://api.qbank.cl/platform/v1/subscriptions/7a1c9e2d-…/cancel -H "Authorization: Bearer " # List / read curl "https://api.qbank.cl/platform/v1/subscriptions?from=2026-07-01&to=2026-07-31&status=active" -H "Authorization: Bearer " ``` Revoking the saved card (`DELETE /v1/stored-cards/{id}`) automatically cancels its subscriptions (`cancel_reason: card_revoked`). ## Subscription states | Status | Meaning | What to do | | ---------- | ------------------------------------------------------------- | ----------------------------------------------- | | `active` | Charges every period on `next_charge_at` | Nothing — the scheduler runs it | | `paused` | Frozen; missed periods are **not** charged retroactively | `POST .../resume` when ready | | `past_due` | 3 dunning retries (24 h apart) failed | Fix the card/balance and `resume` to reactivate | | `canceled` | Terminal — by `cancel` or because the stored card was revoked | Create a new subscription if needed | ## Errors | HTTP | Code | What to do | | ---- | -------------------------- | ---------------------------------------------------------------------------- | | 400 | `idempotency_key_required` | Send `idempotency_key` (body or `Idempotency-Key` header) | | 400 | `invalid_amount` | `amount` must be a positive decimal string | | 400 | `invalid_interval` | Use `daily`, `weekly`, `monthly` or `yearly` | | 400 | `invalid_request` | The currency must match the stored card corridor | | 404 | `not_found` | The stored card / subscription does not exist or is not yours | | 409 | `idempotency_conflict` | Same key with a different payload — use a new key | | 409 | `subscription_state` | The current state does not allow that action (e.g. resuming a canceled plan) | | 422 | `stored_card_revoked` | The card credential was revoked; ask the payer to save it again | | 422 | `core_rejected` | The charge was declined by the rail — the message carries the reason | The general error catalog lives in [Errors](/en/errors). ## FAQ Never. Saving a card stores an opaque network token — the PAN never touches the platform. Revoking the credential invalidates the token. Merchant-initiated transactions (MIT) run without 3DS by card-network mandate: the payer authenticated with 3DS on the initial consented payment, and every MIT references that transaction. They are canceled automatically (`card_revoked`). The payer must save the card again and you create a new subscription. No — there is no catch-up: periods elapsed while paused advance the counter without charging. Resuming charges from the next due period only. The scheduler retries up to 3 times, 24 h apart. If all fail the plan moves to `past_due` and you receive `subscription_status_changed` — no more charges until you `resume`. Synchronously at creation, unless you pass a future `start_at` (trial): then the first charge waits for that date. To show their saved cards without letting anyone who knows their email see them: the list is only revealed after the email is verified with the code (or on an already remembered device). If the email has no cards, the page carries straight on to the new-card form. No: with "Remember this device" (checked by default) the browser stays trusted for 30 days and later payments with that email show the cards without a code. After that — or on another device — they verify again. # Swaps Source: https://docs.cbpayapp.com/en/guides/swaps Convert between your USDT, USDC, BTC and GOLD balances instantly, with a prior quote and the execution rate of the moment **Swaps** convert balance between your four currencies — `USDT`, `USDC`, `BTC` and `GOLD` — **synchronously and instantly**, without the money ever leaving your account. Any pair works (including direct `BTC` ↔ `GOLD`). The rate you see in the quote is the rate you execute at: **no separate fees** — quoted = received. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR quote["GET /v1/swaps/quote
(indicative, free)"] --> swapNode["POST /v1/swaps
(rate of the moment)"] swapNode -->|"atomic in the ledger"| balances["BTC balance down
GOLD balance up"] swapNode --> history["GET /v1/swaps
(history + detail)"] ``` ## 1. Quote (optional, free) ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/swaps/quote?from_asset=USDT&to_asset=BTC&amount=1000" \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "from_asset": "USDT", "to_asset": "BTC", "from_amount": "1000.000000", "to_amount": "0.01568419", "rate": "0.00001568", "indicative": true } ``` The quote is **indicative**: the swap executes at the rate of the `POST` moment (BTC and GOLD prices move). Stablecoin pairs (`USDT` ↔ `USDC`) are stable. ## 2. Execute the swap ```bash USDT → BTC theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/swaps \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "from_asset": "USDT", "to_asset": "BTC", "amount": "1000", "idempotency_key": "swap-2026-07-10-a" }' ``` ```bash BTC → GOLD (direct) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/swaps \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "from_asset": "BTC", "to_asset": "GOLD", "amount": "0.01", "idempotency_key": "swap-2026-07-10-b" }' ``` ```bash USDT → USDC theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/swaps \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "from_asset": "USDT", "to_asset": "USDC", "amount": "500", "idempotency_key": "swap-2026-07-10-c" }' ``` `amount` is in the **source** currency (`from_asset`), with up to its decimals (6 for USDT/USDC/GOLD, 8 for BTC). `201` response — the swap is synchronous, your balance changes instantly: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "swap_id": "8a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", "account_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "from_asset": "USDT", "to_asset": "BTC", "from_amount": "1000.000000", "to_amount": "0.01568419", "rate": "0.00001568", "status": "completed", "idempotency_key": "swap-2026-07-10-a", "created_at": "2026-07-10T15:00:00Z" } ``` Replaying with the same `idempotency_key` → `200` with the original swap and `idempotency_hit: true` — **never re-executed**. ## 3. Query and history ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # History with pagination, dates and currency filter (matches both legs) curl "https://api.qbank.cl/platform/v1/swaps?from=2026-07-01&to=2026-07-10&asset=BTC&page=1&page_size=50" \ -H "Authorization: Bearer " # Detail curl https://api.qbank.cl/platform/v1/swaps/{swap_id} \ -H "Authorization: Bearer " ``` In your movements (`GET /v1/movements`) and in the [statement](/en/guides/statement) the swap shows as `swap_out` in the source currency and `swap_in` in the destination, each reconciling in its own section. ## Rules and limits * **Same account**: the money never leaves your account — it only changes currency. That is why no OTP is required. * **Any pair** between `USDT`, `USDC`, `BTC` and `GOLD` (source ≠ destination). * **Execution rate of the moment**: BTC and GOLD use the live execution price; if it is unavailable or stale the swap is rejected with `503 pricing_unavailable` (it never executes on an old price). * **Volatile-currency limits** (BTC/GOLD, shared with payouts and card purchases): per-operation cap and 24h rolling volume cap per account (`GET /v1/settlement` shows yours). USDT ↔ USDC has no limit. * Requires your approved [identity verification](/en/guides/kyc) and the `swaps` service enabled. ## Errors | HTTP | `error` | Cause | Solution | | ---- | -------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------- | | 400 | `invalid_asset` | Currency outside USDT/USDC/BTC/GOLD | Check `from_asset`/`to_asset` | | 400 | `invalid_pair` | Source and destination are the same currency | Pick different currencies | | 400 | `invalid_amount` | Invalid amount or too many decimals | Respect the source currency's decimals | | 400 | `amount_too_small` | The amount does not reach the destination's minimum unit | Increase the amount | | 400 | `swap_asset_disabled` | One of the currencies is disabled for your organization | Contact your operator | | 400 | `idempotency_key_required` | Missing idempotency key | Send it in the body or header | | 402 | `insufficient_funds` | Not enough balance in the source currency | Fund or lower the amount | | 403 | `verification_required` / `service_disabled` | Unverified account or service off | Complete onboarding / contact your operator | | 422 | `settlement_limit_exceeded` | The swap exceeds the per-operation cap for volatile currencies | Split the operation | | 422 | `settlement_daily_limit_exceeded` | You exceeded your 24h volume for volatile currencies | Retry later | | 503 | `pricing_unavailable` | Execution price unavailable or stale | Retry in a few minutes | ## FAQ The quoted rate is your account's **execution** rate: it includes the cost of providing instant, guaranteed conversion (immediate liquidity, no slippage, without leaving your account). Same philosophy as payout and payin rates: what is quoted is exactly what you receive, with no surprise fees afterwards. No — it is indicative. BTC and GOLD move, so execution uses the price at the POST moment. Between stablecoins (USDT ↔ USDC) the difference is negligible. For the final figure, check the `to_amount` in the swap response (already credited). There is no undo: an executed swap is final (your balance already changed). You can swap back any time, at that moment's rate. Volatile-currency limits are shared across swaps, payouts paid from BTC/GOLD and card purchases from BTC/GOLD — they all count against the same 24h rolling volume of your account. Check your caps at `GET /v1/settlement`. GOLD represents grams of fine gold and BTC bitcoin: price exposure without leaving the ecosystem. You can pay payouts, fees and card purchases straight from those balances (multi-asset settlement), and swap back to USDT/USDC whenever you want. # Transaction tracking link Source: https://docs.cbpayapp.com/en/guides/tracking Every receipt now has a shareable public tracking link — Wise-style status timeline, PDF download and three languages, no sign-in required ## What it is Every receipt the platform issues (payout, payin, refund, internal transfer, swap, crypto withdrawal or deposit, banking operation, card purchase) has a **public tracking link**, in the style of Wise: ``` https://business.cbpayapp.com/t/{code} ``` Anyone who has the link can open the page — **no sign-in, no API key** — and see the live status of that transaction with a step-by-step timeline, the full receipt detail and the option to download the PDF receipt. The `{code}` is the **same HMAC-signed code** that already backs receipt verification (`GET /verify/receipts/{code}` and the QR printed on every PDF). The link *is* the capability: it cannot be guessed or forged, and it only ever exposes that one transaction. ## Where the link comes from You never build the URL yourself — the platform hands it to you: 1. **`verify_url` in receipt payloads.** When a transaction reaches a final state, its receipt includes `verify_url`. With the tracker enabled, that URL now points to `https://business.cbpayapp.com/t/{code}`. 2. **Receipt emails.** The branded receipt email your customer receives carries the same link ("Verify online" / tracking button). 3. **The QR on every PDF receipt** encodes the same code — scanning it opens the tracker. Forward the link as-is to your customer, your support desk or your finance team. Everyone with the link sees the same page. ## Get the link for an existing transaction (API) Receipts and emails always carry the link, but you don't need to download anything to get it: call `GET /v1/track-link` with the transaction's `kind` and `id` to power a **"Share link"** button in your own UI. | Parameter | Type | Description | | --------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `kind` | string | Transaction family: `payout`, `payin`, `payin_refund`, `transfer`, `crypto_withdrawal`, `crypto_deposit`, `swap`, `card_purchase`, `banking_operation`, `wallet_send`, `wallet_deposit` | | `id` | string | Transaction identifier — the same `id` returned by the product endpoints and webhooks of that `kind` | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -G "https://api.qbank.cl/platform/v1/track-link" \ -H "Authorization: Bearer $CBPAY_TOKEN" \ --data-urlencode "kind=payout" \ --data-urlencode "id=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" ``` Response `200 OK`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "track_url": "https://business.cbpayapp.com/t/P9b1deb4d3b7d4bad9bdd2b0d7b3dcb6d8f4a2c1e5b7", "code": "P9b1deb4d3b7d4bad9bdd2b0d7b3dcb6d8f4a2c1e5b7" } ``` The `code` is the same HMAC-signed code printed as a QR on every receipt, so the link is **deterministic**: calling the endpoint twice for the same transaction always returns the same URL. **Who can call it.** The transaction's own account (API key or member session), org admins and platform admins — the same read scope as receipts. A transaction outside your scope (or an unknown `kind`) answers `404 not_found`, never `403`: existence is never leaked. A missing `kind` or `id` answers `400 invalid_payload`. ## What the page shows * **Status badge** — a public, human-readable status (`completed`, `processing`, `failed`) with the operation detail (recipient, reference, amounts, exchange rate). * **Timeline** — a fixed step sequence per operation type (for example, a payout: *Initiated → Processing → In transit → Completed*). Only real timestamps are shown: the first step carries the creation time and the reached final step carries the last update; intermediate steps never display fabricated dates. * **PDF receipt** — the same branded, verifiable receipt, generated on the fly, downloadable directly from the page. * **Your branding** — your organization's name, logo, website and accent color (white-label by design). * **Blockchain explorer link** — for crypto transactions with an on-chain hash, a link to the public explorer. * **Support block** — "Problems with this transfer?" pointing to your organization's website. ## Public JSON API (build your own tracker) The same data the hosted page renders is available as JSON — useful if you want to embed tracking inside your own portal instead of redirecting to ours: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/public/track/P9b1deb4d3b7d4bad9bdd2b0d7b3dcb6d8f4a2c1e5b7?lang=en" ``` No authentication. Response for a completed payout: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "kind": "payout", "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "status": "completed", "status_class": "ok", "subtitle": "Venezuela — Pago Móvil", "fields": [ { "label": "TRANSACTION ID", "value": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "mono": true }, { "label": "DATE AND TIME", "value": "August 8, 2026 at 15:29 UTC" }, { "label": "BENEFICIARY", "value": "María Pérez" }, { "label": "BANK", "value": "Banco de Venezuela" }, { "label": "PHONE", "value": "0414-1234567" }, { "label": "MESSAGE", "value": "Invoice 1042" } ], "amounts": [ { "label": "AMOUNT RECEIVED", "value": "1250000.00 VES" }, { "label": "EXCHANGE RATE", "value": "950.00" }, { "label": "TOTAL DEBIT", "value": "1315.79 USDT" } ], "created_at": "2026-08-08T15:29:00Z", "timeline": [ { "key": "initiated", "state": "complete", "at": "2026-08-08T15:29:00Z" }, { "key": "processing", "state": "complete", "at": null }, { "key": "in_transit", "state": "complete", "at": null }, { "key": "completed", "state": "complete", "at": "2026-08-08T15:31:12Z" } ], "branding": { "name": "CBPay", "logo_url": "https://cdn.cbpayapp.com/branding/cbpay/logo.svg", "website": "https://www.cbpayapp.com", "accent": "#FBC140" }, "receipt_pdf_url": "https://api.qbank.cl/platform/v1/public/track/P9b1deb4d3b7d4bad9bdd2b0d7b3dcb6d8f4a2c1e5b7/receipt.pdf", "whats_next": "payout.done", "support": { "website": "https://www.cbpayapp.com" } } ``` | Field | Description | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `kind` | `payout` · `payin` · `payin_refund` · `transfer` · `swap` · `crypto_withdrawal` · `crypto_deposit` · `wallet_send` · `wallet_deposit` · `banking_operation` · `card_purchase` | | `status` | **Public** status: the platform status lowercased — see the anti tipping-off table below. Review states are never exposed. | | `status_class` | `ok` (completed, credited, confirmed, settled, captured, approved) · `failed` (failed, declined, canceled, rejected, reversed, expired) · `pending` (everything else) — drives the badge color. | | `fields` / `amounts` | Label/value rows. **Labels arrive already translated** according to `?lang=`; your front end only translates the page chrome and the timeline steps. `mono: true` marks long values (IDs, hashes) to render in a smaller monospace font. | | `timeline[].key` | i18n key of the step (e.g. `initiated`, `in_transit`, `credited`). | | `timeline[].state` | `complete` · `in_progress` · `upcoming` · `failed`. | | `timeline[].at` | RFC3339 real timestamp or `null`. Only the first step (creation) and the reached terminal step carry a date — **timestamps are never fabricated**. | | `whats_next` | Contextual i18n key telling the recipient what happens next: `.done` (ok), `.failed` (failed) or `.` while in progress (e.g. `payout.in_transit`, `crypto_withdrawal.confirming`). | | `explorer_url` | Public blockchain explorer URL — only present for crypto transactions with a real on-chain hash. | | `receipt_pdf_url` | Direct URL of the PDF receipt (same signed code). | ### Public status (anti tipping-off) Sensitive internal states are generalized before they reach the public page — an operation under compliance review must not be distinguishable from one that is simply being processed: | Internal state | Status | Class | | ----------------------------------------------------------------------- | ------------------- | --------- | | `in_review`, `held`, `review`, `on_hold`, `compliance_hold` | `processing` | `pending` | | `completed`, `credited`, `confirmed`, `settled`, `captured`, `approved` | (status lowercased) | `ok` | | `failed`, `declined`, `canceled`, `rejected`, `reversed`, `expired` | (status lowercased) | `failed` | | Any other transient state (`pending`, `matched`, `broadcasting`, …) | (status lowercased) | `pending` | ### Timeline sequences The step sequence is **fixed per operation family** — the recipient always sees the same steps for the same product: | Family | Steps | | ----------------------------------------------- | --------------------------------------------------------- | | Payout | `initiated` → `processing` → `in_transit` → `completed` | | Payin — announced transfer | `initiated` → `matched` → `processing` → `completed` | | Payin — QR / collect / card / dedicated account | `initiated` → `processing` → `completed` | | Payin refund | `initiated` → `processing` → `completed` | | Internal transfer · swap | `initiated` → `completed` | | Crypto withdrawal / wallet send | `initiated` → `broadcasting` → `confirming` → `completed` | | Crypto deposit / wallet deposit | `detected` → `confirming` → `credited` | | Banking operation | `initiated` → `processing` → `completed` | | Card purchase | `authorized` → `settled` | While the operation advances, steps before the current one are `complete`, the current one is `in_progress` and the rest are `upcoming`. When an operation **fails**, the step where it stopped is marked `failed`, the previous steps stay `complete` and the later ones stay `upcoming`. When it **completes**, every step is `complete`. ## PDF receipt endpoint ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -OJ "https://api.qbank.cl/platform/v1/public/track/P9b1deb4d.../receipt.pdf?lang=zh" ``` Returns the branded PDF receipt (`Content-Type: application/pdf`, `Content-Disposition: attachment`), generated on the fly with the same renderer as the authenticated endpoints — including full Chinese rendering (Noto Sans SC font). The PDF endpoint shares the same per-IP rate limit as the JSON one. ## Privacy and security | Measure | Detail | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Link = capability** | The `code` is HMAC-signed (80 bits of entropy per transaction). It cannot be guessed or enumerated, and it unlocks exactly one transaction. | | **Anti-enumeration** | An invalid, malformed or nonexistent code always returns the same uniform `404` — an attacker cannot tell whether a code exists. | | **Rate limiting** | Both endpoints share a per-IP throttle; abuse answers `429`. | | **No indexing** | Every response carries `X-Robots-Tag: noindex` and `Cache-Control: no-store`; the page itself sets `noindex`/`nofollow` meta tags. Tracking pages never appear in search engines and are never cached. | | **Neutral link previews** | When the link is pasted into a chat, the preview card shows only your brand name — never amounts, counterparties or statuses. | | **Anti tipping-off** | Compliance-review states are never shown publicly (table above). | | **Provider-agnostic** | The page never reveals which rails or providers process the payment. | The link is a capability: **whoever has the link can see the transaction**. Share it only with the intended recipient, the same way you would share the PDF receipt itself. ## Languages The page and the PDF are fully trilingual — **English, Spanish and Simplified Chinese**: * The hosted page renders in the visitor's preferred language (stored preference, English by default) and passes it to the API as `?lang=en|es|zh`. * The JSON API translates `fields`/`amounts` labels server-side with the same parameter. * The PDF renders entirely in the requested language, Chinese included. ## Legacy verification endpoint (unchanged) `GET /platform/v1/verify/receipts/{code}` **stays alive** — nothing to migrate: * A **browser** opening it (request with `Accept: text/html`) receives a `302` redirect to the tracker page. * An **API client** receives the same JSON payload as always. New receipts and receipt emails generate tracker URLs directly; receipts issued before this change keep working forever through the redirect. ## Errors | HTTP | `error` | When | What to do | | ---- | ------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | 404 | `not_found` | The code is invalid, malformed or does not exist (uniform by design) | Re-copy the full link from the receipt or email — a truncated code is the usual cause. | | 429 | `too_many_attempts` | Per-IP rate limit hit | Wait a moment and retry; don't poll the endpoint in a loop. | See the [errors reference](/en/errors) for the global error shape. ## FAQ No. The signed code in the URL is the credential. That's what makes the link safe to share with your customer by email, chat or SMS. No. Codes are HMAC-signed with 80 bits of entropy per transaction, invalid codes are indistinguishable from valid-but-nonexistent ones (uniform 404), and per-IP rate limiting blocks brute force. `processing` covers every transient state, including internal compliance review. The public page intentionally does not distinguish review states — the operation will move to `completed` or `failed` when resolved. Never. Only real timestamps are rendered: when the transaction was created and when it reached its current terminal step. Intermediate steps show no date. No. It keeps returning JSON for API clients and now redirects browsers to the tracker page. Both behaviors are permanent. The tracker is the public face of the same signed code and is enabled platform-wide. If you prefer not to expose the hosted page, simply don't share the URL — the JSON endpoint keeps working either way. How receipts are generated, their PDF layout and the verification QR. Global error catalog and response shape. # Internal transfers Source: https://docs.cbpayapp.com/en/guides/transfers Move USDT, USDC, BTC or GOLD between CBPay accounts, free and instant Internal transfers move balance between two **CBPay accounts**, atomically in the ledger and **always free of charge** — the money never leaves the ecosystem. They work with all four currencies (`USDT`, `USDC`, `BTC`, `GOLD`) and always **between balances of the same currency**: the `asset` you send is the `asset` the destination receives, with no conversion. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} sequenceDiagram participant A as Source account participant CB as CBPay (ledger) participant B as Destination account A->>CB: POST /v1/transfers (idempotency_key) CB->>CB: Atomic movement:
transfer_out (A) + transfer_in (B) CB-->>A: 201 completed (synchronous) CB-->>B: Webhook transfer_received ``` They work between **any combination of accounts**: | From | To | Fee | | ------- | ------- | --- | | Person | Person | 0 | | Person | Company | 0 | | Company | Person | 0 | | Company | Company | 0 | ## Create a transfer The destination is identified by `to_account_id`, `to_email`, **`to_phone`** (verified phone) or **`to_contact_id`** (a [contact](/en/guides/contacts) from your book): ```bash By phone (verified) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/transfers \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "to_phone": "+56987654321", "amount": "25.000000", "description": "Lunch", "idempotency_key": "lunch-2026-07-10-a" }' ``` ```bash By contact theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/transfers \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "to_contact_id": "3f8a1b2c-…", "amount": "10.000000", "idempotency_key": "t-991" }' ``` ```bash By email (person → person) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/transfers \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "to_email": "carlos@example.com", "amount": "25.000000", "description": "Expense split", "idempotency_key": "split-2026-07-06-a" }' ``` ```bash By account_id (person → company) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/transfers \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "to_account_id": "ae8cf540-22a9-414d-82cc-8ac04732be4f", "amount": "120.500000", "description": "Monthly service payment", "idempotency_key": "serv-2026-07-a" }' ``` ```bash Company → person (payroll) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/transfers \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "to_email": "employee@example.com", "amount": "850.000000", "description": "July salary", "idempotency_key": "payroll-2026-07-emp01" }' ``` ```bash In another currency (GOLD, grams of gold) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/transfers \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "to_email": "carlos@example.com", "asset": "GOLD", "amount": "2.500000", "description": "Gold gift", "idempotency_key": "gold-2026-07-09-a" }' ``` The request shape is identical for every combination (person or company, in any direction) — only the calling credential changes. `asset` is optional and defaults to `USDT`; it accepts `USDT`, `USDC`, `BTC` or `GOLD`, and the destination receives **in that same currency**. Response `201` — the transfer is **synchronous and immediate**: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "transfer_id": "77b1…", "from_account_id": "…", "to_account_id": "…", "asset": "USDT", "amount": "25.000000", "description": "Expense split", "status": "completed", "created_at": "2026-07-06T20:10:00Z" } ``` Replay with the same `idempotency_key` — `200` with the original transfer: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "transfer_id": "77b1…", "amount": "25.000000", "status": "completed", "idempotency_hit": true } ``` The recipient can be notified via the `transfer_received` webhook, and both sides see the movement in their history (`transfer_out` / `transfer_in`). Every transfer saves the recipient as a [contact](/en/guides/contacts) automatically (send `"save_contact": false` to skip it). For safety, `to_phone` only resolves accounts with an **OTP-verified** phone; if more than one account shares the number it answers `422 recipient_ambiguous`. ## Querying transfers List your account's transfers (sent and received), with pagination and date filters: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/transfers?from=2026-07-01&to=2026-07-07&page_size=50" \ -H "Authorization: Bearer " ``` Or fetch one by ID (visible only to the two parties): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/transfers/77b1… \ -H "Authorization: Bearer " ``` Each row carries `direction` (`sent` or `received`) from your perspective. ## Rules * Only between **active** CBPay accounts; internal system accounts cannot receive. * Always the **same currency on both sides**: there is no conversion between balances (`USDT`→`USDT`, `GOLD`→`GOLD`, …). * You cannot transfer to yourself (`400 self_transfer`). * Requires `idempotency_key` (body or `Idempotency-Key` header); replays return `200` with `idempotency_hit: true`. * `amount` accepts up to the currency's decimals: 6 for `USDT`/`USDC`/ `GOLD`, 8 for `BTC`. ## Errors | HTTP | `error` | Cause | | ---- | ----------------------- | --------------------------------------------------------------------------- | | 400 | `recipient_required` | Missing `to_account_id`, `to_email`, `to_phone` and `to_contact_id` | | 400 | `invalid_amount` | Invalid amount, too many decimals or unsupported `asset` | | 400 | `invalid_phone` | `to_phone` could not be normalized to E.164 | | 400 | `self_transfer` | Source and destination are the same account | | 402 | `insufficient_funds` | Not enough available balance in that currency | | 404 | `recipient_not_found` | The email/ID does not match a CBPay account, or no verified phone matches | | 422 | `recipient_ambiguous` | More than one account shares that phone (use `to_account_id` or `to_email`) | | 422 | `contact_not_linked` | The contact has no linked CBPay account | | 422 | `recipient_unavailable` | The destination account is blocked/closed | ## FAQ No — transfers between accounts of your organization are free and instantaneous. No — both sides move the **same** asset (USDT to USDT, USDC to USDC…). To change asset, convert first with [Swaps](/en/guides/swaps). No — transfers are instantaneous and irreversible. If you sent to the wrong account, coordinate the return with the counterparty. `to_phone` only resolves **verified** phone numbers of your organization. An unverified or unknown number answers 404; if more than one account matches you get `recipient_ambiguous` (422) — use `to_alias` or the account ID instead. Alternative recipients: the account's immutable alias and its profile QR token (`GET /v1/me/qr`). All resolve within your organization only. It settles a [checkout link](/en/guides/checkout) by internal transfer: the destination is forced to the link's account and the amount must cover the quoted due (`checkout_amount_mismatch`, 422, otherwise). # Introduction Source: https://docs.cbpayapp.com/en/introduction What CBPay is and what you can build with the API CBPay — move your money at the speed of the world CBPay is a multi-currency payment platform for Latin America. Every account holds **four independent virtual balances** — `USDT` (the operating currency), `USDC`, `BTC` and `GOLD` (grams of gold) — and operates against them: Send money to local bank accounts in Chile, Peru, Mexico, Venezuela, Bolivia, Brazil, Paraguay, Ecuador and Argentina — including paying scanned PIX QRs. Collect in local currency (QR, transfers, dedicated accounts, pull collections) and get credited automatically. A universal payment link: your payer picks their country, method or crypto on a hosted page and you settle in the asset you choose. Issue cards that spend from any balance in real time, accept card payments, [save cards on file and schedule recurring charges](/en/guides/stored-cards-subscriptions). Register verified merchants and generate amount-bound crypto QR charges for physical points of sale. Fund and withdraw USDT/USDC over TRON and Ethereum, and native BTC over Bitcoin — every account is born with its deposit wallets. Convert between your USDT, USDC, BTC and GOLD balances at your account's rate, instantly. Move balance to any other CBPay account — by ID, alias, QR or verified phone — instantly and free of charge. Real bank accounts in your name: receive, hold and send money over international rails (SEPA, SWIFT, ACH), including third-party accounts. Dedicated on-chain wallets with their own balance, isolated from the ledger — create, import and export them. Person and company verification, plus standalone [AML screening](/en/guides/aml) and [crypto address screening](/en/guides/screenings). Full statement per period (JSON, PDF, Excel) with guaranteed accounting balance, [receipts](/en/guides/receipts) per operation and an [analytics summary](/en/guides/analytics) ready to chart. Every event reaches your **signed webhooks** ([guide](/en/webhooks)). ## How it works Fiat operations revolve around the USDT balance — money comes in on one side, converts, and goes out the other. The USDC, BTC and GOLD balances move via [swaps](/en/guides/swaps), on-chain deposits and withdrawals, internal transfers, payout settlement (`settlement_asset`) and automatic payin conversion (`default_payin_asset`): ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR subgraph moneyIn [Money in] payin["Fiat payin
(QR, transfer, pull)"] deposit["On-chain USDT
deposit"] transfIn["Internal transfer
received"] end subgraph balance [Your CBPay account] usdt(("USDT balance
available + held")) end subgraph moneyOut [Money out] payout["Fiat payout
(bank, Yape, PIX, QR...)"] withdrawal["On-chain USDT
withdrawal"] transfOut["Internal transfer
sent"] end payin -->|"FX at your rate − fee"| usdt deposit -->|"− funding fee"| usdt transfIn -->|"free"| usdt usdt -->|"FX at your rate + fee"| payout usdt -->|"+ withdrawal fee"| withdrawal usdt -->|"free"| transfOut banking["Banking: real bank accounts
(own balance, separate from USDT)"] usdt -.->|"fixed fees only"| banking ``` 1. CBPay gives you access: email/password registration or a direct API key. 2. You fund your account: with a fiat payin or an on-chain USDT deposit. 3. You operate: payouts, transfers, withdrawals — everything debits and credits your USDT balance with FX conversion at execution time. 4. You stay informed: every movement lands in an immutable history (`GET /v1/movements`) and events reach your webhooks. ## Base URLs and environments CBPay runs two fully isolated environments with the exact same API: | Environment | Base URL | API keys | Money | | ----------- | -------------------------------------- | ------------- | ---------------------------------------------------------- | | **Test** | `https://cryptobank.qbank.cl/platform` | `pk_test_...` | Simulated — every rail served by a deterministic simulator | | **Live** | `https://api.qbank.cl/platform` | `pk_...` | Real and irreversible | All paths in this documentation are relative to those base URLs. Build against **test** first and go live by swapping the URL and the key — details, magic values and the go-live checklist in [environments and testing](/en/environment-testing). Amounts are always **decimal strings** (e.g. `"10.500000"`), never floating point numbers. Each currency uses its own precision: 6 decimals for `USDT`/`USDC`/`GOLD` and 8 for `BTC`. ## Next steps Follow the [quickstart](/en/quickstart) to register and make your first call. Read [money model](/en/concepts/money-model) and [fees](/en/concepts/fees). Start with [payouts](/en/guides/payouts) or [payins](/en/guides/payins). # Quickstart Source: https://docs.cbpayapp.com/en/quickstart From zero to your first payout — with the cycle closed by webhook — in six steps This is the full path of a first integration: register → balance → rates → payout → webhook. By the end you will have closed the entire cycle: ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR reg["1-2. Register and
authenticate"] --> saldo["3. Balance
(fund with payin or crypto)"] saldo --> tasas["4. Rates and
fees"] tasas --> payout["5. First payout
(processing)"] payout --> wh["6. Webhook
payout_status_changed"] wh --> fin(("Cycle
closed")) ``` Before you start, the data you will need everywhere: | Item | Value | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | **Base URL (live)** | `https://api.qbank.cl/platform` | | **Base URL (test)** | `https://cryptobank.qbank.cl/platform` — simulated money, `pk_test_` keys | | **Authentication** | `Authorization: Bearer ` header (or `X-API-Key`) | | **Organization slug** | `cbpay` (for register and login) | | **Balance currencies** | 4 independent balances: USDT (operating), USDC, BTC and GOLD — amounts always as strings (`"52.618258"`) | | **Environments** | Two isolated environments, same API: build in **test** first, then swap URL + key to go live — [guide](/en/environment-testing) | Run this quickstart against the **test environment** first (`https://cryptobank.qbank.cl/platform`): accounts are born verified and funded with demo history, every payout completes in seconds, and nothing real moves. The steps below show live URLs — they work identically on test. If CBPay already created your account and handed you a `pk_...` API key, skip to step 3. Common questions are answered upfront in the [FAQ](/en/faq). Create your account (person or company — same endpoint, `type` changes): ```bash Person theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/auth/register \ -H "Content-Type: application/json" \ -d '{ "org": "cbpay", "type": "person", "email": "ana@example.com", "password": "a-secure-password", "display_name": "Ana Perez", "country": "CL" }' ``` ```bash Company theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/auth/register \ -H "Content-Type: application/json" \ -d '{ "org": "cbpay", "type": "company", "email": "legal@andina.cl", "password": "a-secure-password", "display_name": "Comercial Andina SpA", "tax_id": "76.543.210-8", "country": "CL" }' ``` The response includes your `access_token` (24-hour session): ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account": { "id": "…", "type": "person", "kyc_status": "none", "…": "…" }, "access_token": "eyJhbGciOiJIUzI1NiIs…", "expires_at": "2026-07-08T00:00:00Z" } ``` Send the token in the `Authorization` header: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/me \ -H "Authorization: Bearer " ``` For server-to-server integrations, issue a **permanent API key** with `POST /v1/api-keys` — shown exactly once. More in [authentication](/en/authentication). ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/balances \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "…", "balances": [ { "asset": "USDT", "available": "0.000000", "held": "0.000000" }, { "asset": "USDC", "available": "0.000000", "held": "0.000000" }, { "asset": "BTC", "available": "0.00000000", "held": "0.00000000" }, { "asset": "GOLD", "available": "0.000000", "held": "0.000000" } ] } ``` To operate you need funds: create a [payin](/en/guides/payins) or deposit USDT on-chain via [crypto funding](/en/guides/crypto). Before a payout, check the current FX rate and your effective fees: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/rates \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "base": "USD", "rates": { "chile": { "currency": "CLP", "rate": "950.25" }, "mexico": { "currency": "MXN", "rate": "17.50" }, "bolivia": { "currency": "BOB", "rate": "6.91" } }, "fees": [ { "service": "payout", "country": "CL", "percent": "0", "fixed": "0.50" } ], "updated_at": "2026-07-07T12:00:00Z" } ``` The rates already include your FX margin, so you can estimate the cost before creating: `usdt_amount ≈ local_amount / rate` (rounded up) and `total_debit = usdt_amount + fixed`. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "CL", "currency": "CLP", "method": "bank_transfer", "amount": "50000", "beneficiary": { "name": "Juan Soto", "rut": "12345678-9", "bank_code": "012", "account_type": "checking", "account_number": "001122334455" }, "description": "Supplier payment", "idempotency_key": "my-payment-0001" }' ``` Response `202 Accepted` — the payout is `processing` and the final state arrives via [webhook](/en/webhooks) (`payout_status_changed`): ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payout_id": "…", "status": "processing", "local_amount": "50000", "fx_rate": "950.25", "usdt_amount": "52.618258", "fee": "0.500000", "total_debit": "53.118258" } ``` `beneficiary` fields depend on the country and method. Check `GET /v1/payouts/methods` and `GET /v1/payouts/banks?country=CL` for each corridor's requirements — the full reference lives in the [country examples in the payouts guide](/en/guides/payouts#examples-by-country). The payout's final state arrives via push. Subscribe your HTTPS endpoint (in development use a [tunnel](/en/environment-testing#testing-webhooks-in-local-development)): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/webhooks/subscriptions \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "event_type": "payout_status_changed", "callback_url": "https://yourapp.com/webhooks/cbpay", "secret": "a-long-random-secret" }' ``` Minutes later you will receive the closure of the step-5 payout: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payout_id": "…", "status": "completed", "local_amount": "50000", "usdt_amount": "52.618258", "total_debit": "53.118258", "status_code": "" } ``` **Always** verify the delivery's HMAC signature (`X-Webhook-Signature`) — recipe with code in [webhooks](/en/webhooks). Had the payout failed, `status: failed` arrives with the full refund already applied. ## What's next? Funding, dispersing, collecting, reconciling and banking — the five E2E flows with diagrams. Debits, holds, refunds and the immutable ledger. How to test safely and the go-live checklist. Real integrator questions, answered — and the full API Reference lives in its own tab, with an interactive playground. # Add a member Source: https://docs.cbpayapp.com/api-reference/account/add-a-member /openapi.yaml post /v1/members Adds a login member. Only available for company accounts. # Create an API key Source: https://docs.cbpayapp.com/api-reference/account/create-an-api-key /openapi.yaml post /v1/api-keys Issues a server-to-server API key bound to the calling account. The plaintext token (`pk_...`) is returned exactly once and cannot be retrieved again. # Get my account Source: https://docs.cbpayapp.com/api-reference/account/get-my-account /openapi.yaml get /v1/me # Get the platform branding Source: https://docs.cbpayapp.com/api-reference/account/get-the-platform-branding /openapi.yaml get /v1/branding Effective branding of the platform you operate on (name, colors, logos in base64) so a white-label front end can theme itself from the API instead of hardcoding assets. `logo_url`/`symbol_url` are public CDN URLs for the same logos — prefer them over the base64 payloads. Read-only, no PII. `timezone` is the IANA timezone of the organization (default `America/New_York`) — every `from`/`to` date filter and every daily bucket of the platform is interpreted in that zone. # List members Source: https://docs.cbpayapp.com/api-reference/account/list-members /openapi.yaml get /v1/members Login members of the calling account (company accounts). # Update my profile Source: https://docs.cbpayapp.com/api-reference/account/update-my-profile /openapi.yaml patch /v1/me Once your identity verification is approved, `display_name`, `tax_id` and `country` are filled from the verified identity and locked (`409 identity_locked`); `phone` stays editable with its own verification flow. # Execute a pull collection with the payer's data (public) Source: https://docs.cbpayapp.com/api-reference/checkout/execute-a-pull-collection-with-the-payers-data-public /openapi.yaml post /pay/{token}/collect Runs the pull charge of a materialized collect option (`c2p` / `debito_inmediato`) with the data the payer typed on the checkout page — bank, document, phone or account, and the OTP (generated in the payer's banking app for C2P, or requested via `POST /pay/{token}/collect/otp` for immediate debit, sending back its `otp_reference`). The amount is ALWAYS the one frozen at materialization — the payer never sends it. If the rail confirms synchronously the link is settled in the same call (`paid: true`); a rejection does NOT kill the link — the payer can fix the data or pick another method. Retrying with the same data never charges twice. Strictly rate limited per IP. # Materialize a checkout payment option (public) Source: https://docs.cbpayapp.com/api-reference/checkout/materialize-a-checkout-payment-option-public /openapi.yaml post /pay/{token}/methods/{method} Creates (lazily) the payment option the payer picked on a universal checkout link and returns its instructions — the payment URL for card/hosted methods, the QR for QR methods, the announce reference for bank transfers, or an exclusive crypto deposit address with the exact quoted amount plus a scannable QR (`qr_payload` + `qr_png_base64` — always the raw address for BTC/TRON/ETH, readable by external wallets and exchanges; BIP-21/EIP-681 URIs are rejected by apps like Binance). Fiat methods require `?country=XX`; the local amount is quoted and FROZEN at this moment. When the country offers the method in more than one currency (see `options[]` of the quote — e.g. QR in BOB and USD, or card corridors) it also requires `¤cy=YYY`; each currency is an independent materialization. Bank transfers in Mexico issue a dedicated CLABE exclusive to the link (`destination` with `dedicated: true`): the payer transfers the exact amount with NO reference — the deposit routes to the link automatically. If the dedicated account cannot be issued, the payload degrades to the classic path (merchant account + mandatory `reference` in the transfer description). Pull methods (`collect: true` in the quote, e.g. `c2p` and `debito_inmediato` in Venezuela) freeze the quote and return the form the payer must fill (`banks[]` catalog, `requires_otp_request`); the charge itself runs through `POST /pay/{token}/collect`. Re-POSTing the same country+currency+method returns the SAME materialization; picking a method never blocks the others while nobody has paid. No credentials; rate limited per IP. # Quote a checkout link before choosing (public) Source: https://docs.cbpayapp.com/api-reference/checkout/quote-a-checkout-link-before-choosing-public /openapi.yaml get /pay/{token}/quote Indicative quotes for a universal checkout link BEFORE materializing — the catalog of countries with live fiat methods (card excluded — see `cards`), the card options listed separately per country+currency in `cards[]` with the quoted due amount (a corridor may accept several charge currencies, e.g. BOB and USD), the due amount per crypto option (conversion cost included when the pay asset differs from the settlement asset), and the CBPay direct payment dues per enabled balance. Each country lists its corridors in `options[]` — one row per method+currency (a country may offer the same method in several currencies, e.g. QR in BOB and USD) with `collect: true` on pull methods that ask for the payer's data on the page (see `POST /pay/{token}/collect`). With `?country=XX` it also returns the quoted local amount per option for that country. Materializing freezes the definitive quote. No credentials; rate limited per IP. # Request the OTP of a pull collection (public) Source: https://docs.cbpayapp.com/api-reference/checkout/request-the-otp-of-a-pull-collection-public /openapi.yaml post /pay/{token}/collect/otp For pull methods whose rail sends the payer a one-time key on demand (`requires_otp_request: true` in the materialization, e.g. Venezuela immediate debit). Triggers the OTP delivery to the payer and returns the `otp_reference` that must accompany the final `POST /pay/{token}/collect`. The pull option must be materialized first; the amount is ALWAYS the one frozen there. Strictly rate limited per IP (each call is a real SMS/push to the payer). # Universal checkout page (public) Source: https://docs.cbpayapp.com/api-reference/checkout/universal-checkout-page-public /openapi.yaml get /pay/{token} Public branded HTML page behind the `checkout_url` returned by `POST /v1/payins` with `method: "checkout"`. The payer opens it without credentials, picks a country and fiat method, a crypto option (deposit address with scannable QR) or pays directly from the CBPay app (merchant QR + alias always visible), and the page updates itself when the payment is confirmed. The token is cryptographically derived; an invalid one answers 404. Rate limited per IP. # Universal checkout state (public) Source: https://docs.cbpayapp.com/api-reference/checkout/universal-checkout-state-public /openapi.yaml get /pay/{token}/state JSON state of a universal checkout link — for the page's own polling and for integrators rendering their own payment front end over the same link. Shows the status, the settlement asset and amount, the winning method once paid, the frozen fiat materializations (`fiat_methods`), the live progress of every crypto deposit address (amount due vs received) and the `conversion_status` of the auto-conversion. No credentials; rate limited per IP. # Confirm a KYB document upload Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/confirm-a-kyb-document-upload /openapi.yaml post /v1/kyb/submissions/{submissionID}/documents/confirm # Confirm a KYC document upload Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/confirm-a-kyc-document-upload /openapi.yaml post /v1/kyc/submissions/{submissionID}/documents/confirm Step 3 of the document flow. Attaches the uploaded file to the submission and queues its OCR validation; the result arrives via the kyc_document_validated webhook. # Create a KYB link for a customer Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/create-a-kyb-link-for-a-customer /openapi.yaml post /v1/kyb/links Company accounts only: generates a hosted KYB link for one of YOUR end customers (businesses). Bills the fixed `kyb_verification` fee when configured (refunded if creation fails); `idempotency_key` is required. `country` picks the wizard corridor: us, cl, ve, br, mx, co, pe, bo, py, ar or generic (send `generic_country` ISO alpha-2, optional `currency`). # Create a KYB submission with API data Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/create-a-kyb-submission-with-api-data /openapi.yaml post /v1/kyb/submissions Company accounts only: verifies one of YOUR end customers (a business) sending the data directly. Body: external_customer_id, country?, business (the company fields), ubos?, directors?, signers?, bank_info?, metadata?. Bills the fixed `kyb_verification` fee; `idempotency_key` required. Re-sending while open updates the same submission without charging again. # Create a KYC link for a customer Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/create-a-kyc-link-for-a-customer /openapi.yaml post /v1/kyc/links Company accounts only: generates a hosted KYC link for one of YOUR end customers. The wizard covers form, documents and video liveness. Bills the fixed `kyc_verification` fee when configured (refunded if creation fails); `idempotency_key` is required — a retry with the same key returns the original link and never double-charges. # Create a KYC submission with API data Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/create-a-kyc-submission-with-api-data /openapi.yaml post /v1/kyc/submissions Company accounts only: verifies one of YOUR end customers sending the data directly (no wizard). Countries in ISO alpha-3, dates YYYY-MM-DD, id_type passport | id_card | drivers_license. Documents are optional at creation (upload them via the presign flow); no liveness is required at creation — the submission carries liveness_pending: true, close it with a liveness link. Re-sending with the same external_customer_id while the submission is open updates it without charging again. Bills the fixed `kyc_verification` fee; `idempotency_key` required. # Create a liveness link Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/create-a-liveness-link /openapi.yaml post /v1/kyc/submissions/{submissionID}/liveness_link Hosted page where your customer completes ONLY the video liveness check (camera gestures + server-side face match against the uploaded identity document). Free — the service was billed when the submission was created. If an open link exists the same one is returned; if the check already passed, 400 liveness_already_completed. # Download my verification report (PDF/JSON) Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/download-my-verification-report-pdfjson /openapi.yaml get /v1/me/verification/report Compliance report of your own onboarding verification, generated by the platform: verified identity, lifecycle, documents + OCR, liveness (one entry per session - the onboarding gate check plus any later evidence recaptures, each with its own session_id and purpose) and an AGGREGATED AML section (sanctions / PEP / adverse media status, never match detail). Use format=pdf (default) or format=json, and lang=en|es|zh. Free - the verification itself was already billed. The PDF prints a public verification code + QR that anyone can check at /verify/reports/{code}. # Download the signed compliance report (PDF) Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/download-the-signed-compliance-report-pdf /openapi.yaml get /v1/kyb/submissions/{submissionID}/report Signed compliance report of a KYB verification — evidence for your own auditors. Free (the service was billed when the verification was created). # Get a KYB link Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/get-a-kyb-link /openapi.yaml get /v1/kyb/links/{linkID} # Get a KYB submission Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/get-a-kyb-submission /openapi.yaml get /v1/kyb/submissions/{submissionID} Aggregated state: status, risk_band, pending_documents, rejection_reason, changes_requested_comments and aml_decision. # Get a KYC link Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/get-a-kyc-link /openapi.yaml get /v1/kyc/links/{linkID} # Get a KYC submission Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/get-a-kyc-submission /openapi.yaml get /v1/kyc/submissions/{submissionID} Aggregated state without internal sensitive data: status, risk_band, pending_documents (documents compliance requested), rejection_reason, changes_requested_comments, liveness_pending and documents_received. # Get the liveness link and check state Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/get-the-liveness-link-and-check-state /openapi.yaml get /v1/kyc/submissions/{submissionID}/liveness_link # List KYB document OCR results Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/list-kyb-document-ocr-results /openapi.yaml get /v1/kyb/submissions/{submissionID}/documents # List KYB links Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/list-kyb-links /openapi.yaml get /v1/kyb/links # List KYB submissions Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/list-kyb-submissions /openapi.yaml get /v1/kyb/submissions # List KYC document OCR results Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/list-kyc-document-ocr-results /openapi.yaml get /v1/kyc/submissions/{submissionID}/documents # List KYC links Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/list-kyc-links /openapi.yaml get /v1/kyc/links # List KYC submissions Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/list-kyc-submissions /openapi.yaml get /v1/kyc/submissions # My onboarding verification state Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/my-onboarding-verification-state /openapi.yaml get /v1/me/verification Effective verification state of the calling account — `kyc_status`, the required kind (kyc for persons, kyb for companies) plus the latest self-onboarding link and submission. # Presign a KYB document upload Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/presign-a-kyb-document-upload /openapi.yaml post /v1/kyb/submissions/{submissionID}/documents Same 3-step flow as KYC documents. Categories: legalPresence, ownershipStructure, controlStructure, companyDetails. # Presign a KYC document upload Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/presign-a-kyc-document-upload /openapi.yaml post /v1/kyc/submissions/{submissionID}/documents Step 1 of the document flow: returns a temporary upload URL. Then PUT the binary to upload_url with the same Content-Type (step 2) and confirm (step 3). Categories: identity, proofOfResidence. Types: application/pdf, image/png, image/jpeg; 15 MB max; the URL expires in 15 minutes. # Request my onboarding verification link Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/request-my-onboarding-verification-link /openapi.yaml post /v1/me/verification/link Returns the account's own identity-verification link (person accounts get a KYC link, company accounts a KYB link — derived from the account type). The hosted wizard covers the full flow: form, document uploads and video liveness. Free. If an open link already exists it is returned with 200; an already-approved account gets 409 already_verified. Until the account's verification is approved, every outgoing-money action answers 403 verification_required (funding and reads stay available). # Verification report of a KYB you ran (PDF/JSON) Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/verification-report-of-a-kyb-you-ran-pdfjson /openapi.yaml get /v1/kyb/submissions/{submissionID}/verification-report Full compliance report of a verification you ran on your customer (company accounts only): verified profile, lifecycle, documents + OCR, liveness (one entry per session - the onboarding gate check plus any later evidence recaptures, each with its own session_id and purpose; for a KYB, every related party can carry more than one session too) and the FULL AML screening section with match detail - you are the data controller of your third parties. If the submission turns out to be your own onboarding, the AML section is aggregated instead. Use format=pdf (default) or format=json, and lang=en|es|zh. Free - reading an already-billed verification. If the submission has no linked AML screening yet, one is triggered on demand (idempotent, no fee); when unavailable the report ships with the gap declared in the partial field. # Verification report of a KYC you ran (PDF/JSON) Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/verification-report-of-a-kyc-you-ran-pdfjson /openapi.yaml get /v1/kyc/submissions/{submissionID}/verification-report Full compliance report of a verification you ran on your customer (company accounts only): verified profile, lifecycle, documents + OCR, liveness (one entry per session - the onboarding gate check plus any later evidence recaptures, each with its own session_id and purpose; for a KYB, every related party can carry more than one session too) and the FULL AML screening section with match detail - you are the data controller of your third parties. If the submission turns out to be your own onboarding, the AML section is aggregated instead. Use format=pdf (default) or format=json, and lang=en|es|zh. Free - reading an already-billed verification. If the submission has no linked AML screening yet, one is triggered on demand (idempotent, no fee); when unavailable the report ships with the gap declared in the partial field. # Verify a report's authenticity (public) Source: https://docs.cbpayapp.com/api-reference/kyc-kyb/verify-a-reports-authenticity-public /openapi.yaml get /verify/reports/{code} Public endpoint (no credentials) behind the QR printed on every verification report. The code is cryptographically signed - a tampered or made-up one answers 404 with valid=false. The response only exposes the kind (KYC/KYB), the CURRENT decision status and its date - never the subject's identity, documents or AML results. Browsers get a branded HTML page; API clients get JSON. Rate limited per IP. # Activate my verifiable public Qscore seal Source: https://docs.cbpayapp.com/api-reference/qscore/activate-my-verifiable-public-qscore-seal /openapi.yaml post /v1/qscore/my-seal Activates the public Qscore seal for the authenticated company account. Eligibility (checked live): band A or B with an evaluation no older than 90 days — `seal_not_eligible` otherwise; the product is company-only (`seal_companies_only` for person accounts). Requires no request body and is idempotent by design: activating when the seal is already active answers 200 with the current seal and `idempotency_hit: true` — no duplicate is ever created. An activation notice email is sent to the account holder (org branding). # Check a Qscore seal's current validity (public) Source: https://docs.cbpayapp.com/api-reference/qscore/check-a-qscore-seals-current-validity-public /openapi.yaml get /verify/qscore/seal/{code} Public endpoint (no credentials) behind the Qscore seal a company activates with `POST /v1/qscore/my-seal`. The code is cryptographically signed: a tampered or made-up one answers 404 with `valid: false`. Eligibility is evaluated LIVE on every call: the band (A or B) is shown only while the seal is active AND the subject still qualifies (band A/B with an evaluation no older than 90 days). A seal that no longer qualifies answers `seal_status: not_current` — anti-oracle: the band it fell to, the numeric score and the reason are never revealed. The numeric score NEVER appears on any public surface. Browsers get a branded HTML page (`Accept: text/html`); API clients get JSON. Rate limited per IP. # Create a batch scoring job Source: https://docs.cbpayapp.com/api-reference/qscore/create-a-batch-scoring-job /openapi.yaml post /v1/qscore/batches Creates an asynchronous batch scoring job (portfolio scoring): the subjects are validated upfront, the batch is queued, and a background worker issues one full Qscore report per valid item. Send `subjects` (JSON array, 1 to 5000 entries) **or** `subjects_csv` (a CSV string with header `doc_id[,subject_type]`) — exactly one of the two. Rows with an invalid document id (`invalid_doc_id`) or a document duplicated inside the batch (`duplicate_in_batch`, reported with its normalized form) are rejected at creation and reported in `rejected_items`; the batch only processes the valid ones. An omitted or unrecognized `subject_type` is never an error: for `CL` it is inferred from the RUT series (first digit 5–9 → `company`, anything else → `person`). If every row is rejected, the request fails with `no_valid_items` and nothing is created. Idempotency is mandatory: retrying with the same `idempotency_key` returns the original batch with `idempotency_hit: true` and never duplicates the batch or the charges. Each item is billed the standalone report fee (`risk_report_person` / `risk_report_company`) when processed, and a terminally failed item is automatically refunded. `estimated_fee_usdt` is the upfront estimate for the valid items. When the batch finishes you receive one `risk_batch_completed` webhook and one email — the individual reports do not emit their own webhook or email. # Create a consent link Source: https://docs.cbpayapp.com/api-reference/qscore/create-a-consent-link /openapi.yaml post /v1/qscore/consents Creates an authorization link for the subject (the Destacame-style consent flow): you get a shareable `consent_url` and, if you pass `email`, the platform emails the branded link to the holder. The holder opens the public page, connects their bank through open finance and authorizes; a granted consent unlocks the positive banking data of the subject in their Qscore reports. `country` and `doc_id` identify the holder (the document is validated with its check digit); `subject_type` is inferred from the document when omitted. `purpose` is required by data protection law and `self_access` is not allowed here (the holder self-report lives in `POST /v1/qscore/my-report`). `expires_in_days` defaults to 7 (maximum 30). Idempotency is mandatory: retrying with the same `idempotency_key` returns the original consent with `idempotency_hit: true` and never duplicates the link nor re-sends the email. # Create a credit report Source: https://docs.cbpayapp.com/api-reference/qscore/create-a-credit-report /openapi.yaml post /v1/qscore/reports Issues a new credit report for a subject (a person or a company), billed as a standalone fee (`risk_report_person` / `risk_report_company`) at the moment of issuance. Idempotency is mandatory: retrying with the same `idempotency_key` returns the original report with `idempotency_hit: true` and never charges twice. The report is computed synchronously: the response is usually the finished report (status `ready`) or a failed one (status `failed` with `error_code`/`error_message`). The `purpose` field is mandatory (data protection law) and `doc_id` is validated and normalized per country (e.g. Chilean RUT with check digit). # Current score of a subject Source: https://docs.cbpayapp.com/api-reference/qscore/current-score-of-a-subject /openapi.yaml get /v1/qscore/subjects/{docID}/score Returns only the latest score of a subject — the cheap lookup for recurring checks without issuing (or paying for) a new report. Returns `404 no_score` if no report has been computed yet for that subject. For the SC band (no data), `score` is absent. # Download the report PDF Source: https://docs.cbpayapp.com/api-reference/qscore/download-the-report-pdf /openapi.yaml get /v1/qscore/reports/{id}/pdf Downloads the branded PDF of a credit report in the language it was issued (`lang`), with the `verify_code` printed for public verification. Only available once the report is `ready` — otherwise `404 pdf_not_ready`. The PDF is generated on the fly per request; store it if you need an immutable copy. # Download your own report PDF Source: https://docs.cbpayapp.com/api-reference/qscore/download-your-own-report-pdf /openapi.yaml get /v1/qscore/my-report/pdf Downloads the branded PDF of the most recent self report of the authenticated account holder, in the language it was issued (`lang`), with the `verify_code` printed for public verification at `GET /verify/qscore/{code}`. Only available once the report is `ready` — otherwise `404 pdf_not_ready`. The response is a binary download (`Content-Disposition: attachment; filename="qscore_self_.pdf"`). # Embeddable SVG badge with the live seal state (public) Source: https://docs.cbpayapp.com/api-reference/qscore/embeddable-svg-badge-with-the-live-seal-state-public /openapi.yaml get /verify/qscore/seal/{code}/badge.svg Public endpoint (no credentials) that renders the embeddable Qscore seal as an SVG image (180x64, dark background). While the seal is active and the subject still qualifies, the badge shows the current band letter (A or B) in green with the label "VERIFICADO"; otherwise the same URL renders a grey badge labeled "NO VIGENTE" — it never shows a stale band and never breaks the host page. `Cache-Control: public, max-age=300` (viewers may cache the image for up to 5 minutes). Answers 404 with an empty body only for invalid or tampered codes. # Export batch results as CSV Source: https://docs.cbpayapp.com/api-reference/qscore/export-batch-results-as-csv /openapi.yaml get /v1/qscore/batches/{id}/results.csv Downloads the batch results as a CSV file with a UTF-8 BOM (opens correctly in Excel). Header: `doc_id,subject_type,status,score, band,verify_code,report_id,error_code`. `verify_code` is the public verification code of the report (usable at `/verify/qscore/{code}`). Failed rows carry their `error_code` and no score. # Generate your own credit report (self) Source: https://docs.cbpayapp.com/api-reference/qscore/generate-your-own-credit-report-self /openapi.yaml post /v1/qscore/my-report Generates (or reuses) the Qscore credit report of the authenticated account holder — the ARCO / data-protection right of access to one's own credit information. Unlike `POST /v1/qscore/reports`, this endpoint is FREE (no fee is charged) and the subject identity is derived from the verified account: the `tax_id` validated during KYC/KYB. The request does NOT accept a `doc_id` — requesting a third party's report through this endpoint is impossible by design (anti-oracle). Frequency limit: one NEW self report every 30 days. If a `ready` self report already exists within that window, the endpoint returns `200` with the existing report and `idempotency_hit: true` (reused); otherwise it generates a new one and returns `201`. A double submit on the same day returns the already-created report (deterministic idempotency per account + subject + day). Self reports are excluded from the subject's query count, so pulling your own report never penalizes your score. The report is computed synchronously: the response is the finished report (status `ready`) or a failed one (status `failed` with `error_code`/`error_message`). # Get a batch scoring job Source: https://docs.cbpayapp.com/api-reference/qscore/get-a-batch-scoring-job /openapi.yaml get /v1/qscore/batches/{id} Returns the batch detail: live counters, `started_at` / `completed_at`, and `error_code` / `error_message` when the batch itself failed. Ownership isolation: a batch id from another account returns 404. # Get a consent link Source: https://docs.cbpayapp.com/api-reference/qscore/get-a-consent-link /openapi.yaml get /v1/qscore/consents/{id} Returns the consent detail. Ownership is strict: a consent id from another account returns 404. A `pending` consent whose TTL already elapsed is lazily marked `expired` on read. # Get a credit report Source: https://docs.cbpayapp.com/api-reference/qscore/get-a-credit-report /openapi.yaml get /v1/qscore/reports/{id} Returns the full detail of a credit report owned by your account. When `status` is `ready` the full normalized `report` object is embedded; when it is `failed`, `error_code`/`error_message` explain why. A report owned by another account returns `404 not_found` (ownership isolation). # Get a dispute Source: https://docs.cbpayapp.com/api-reference/qscore/get-a-dispute /openapi.yaml get /v1/qscore/disputes/{id} Returns the detail and current status of an ARCO dispute owned by your account. A dispute owned by another account returns `404 not_found` (ownership isolation). # Get a monitoring subscription Source: https://docs.cbpayapp.com/api-reference/qscore/get-a-monitoring-subscription /openapi.yaml get /v1/qscore/subjects/{docID}/monitoring Returns the current monitoring subscription of your account for a subject, including the last observed score, the record count and the timestamps of the last check (`last_checked_at`) and the last alert (`last_event_at`). Returns `404 not_found` if there is no subscription. # Get my Qscore seal status Source: https://docs.cbpayapp.com/api-reference/qscore/get-my-qscore-seal-status /openapi.yaml get /v1/qscore/my-seal Returns the seal of the authenticated account (`null` if there is none) plus its live eligibility. An active seal includes `verify_code`, `verify_url` and `badge_url`; a revoked seal keeps only `revoked_at` (its URLs stop working permanently). Person accounts always get `seal: null` with `eligibility.reason: "companies_only"`. # Get your latest self credit report Source: https://docs.cbpayapp.com/api-reference/qscore/get-your-latest-self-credit-report /openapi.yaml get /v1/qscore/my-report Returns the most recent self report of the authenticated account holder (any status), without generating a new one. Returns `404 not_found` if the holder has never generated a self report. When `status` is `ready` the full normalized `report` object is embedded; when it is `failed`, `error_code`/`error_message` explain why. # List batch items Source: https://docs.cbpayapp.com/api-reference/qscore/list-batch-items /openapi.yaml get /v1/qscore/batches/{id}/items Returns the items of the batch, paginated. `status` filters by item status (`pending`, `ready`, `failed`). Each `ready` item exposes its `report_id`, `score` and `band`; each `failed` item exposes `error_code` / `error_message`. # List batch scoring jobs Source: https://docs.cbpayapp.com/api-reference/qscore/list-batch-scoring-jobs /openapi.yaml get /v1/qscore/batches Lists the batch scoring jobs of your account (newest first), paginated. `page` starts at 1; `page_size` defaults to 50 (maximum 200). The `from` and `to` date filters (organization timezone, both inclusive) are mandatory. Batches owned by other accounts are never visible (ownership isolation: a batch id from another account returns 404). # List consent links Source: https://docs.cbpayapp.com/api-reference/qscore/list-consent-links /openapi.yaml get /v1/qscore/consents Lists the consent links of your account (newest first), paginated. The `from` and `to` date filters (organization timezone, both inclusive) are mandatory; `page` starts at 1 and `page_size` defaults to 50 (maximum 200). Consents owned by other accounts are never visible (ownership isolation). # List credit reports Source: https://docs.cbpayapp.com/api-reference/qscore/list-credit-reports /openapi.yaml get /v1/qscore/reports Returns the credit reports of your account, newest first. The `from` and `to` date filters (organization timezone, both inclusive) are mandatory. Reports owned by other accounts are never visible (ownership isolation: a report id belonging to another account returns `404 not_found`). # List monitoring subscriptions Source: https://docs.cbpayapp.com/api-reference/qscore/list-monitoring-subscriptions /openapi.yaml get /v1/qscore/monitoring Lists the monitoring subscriptions of your account (newest first), paginated. `page` starts at 1; `page_size` defaults to 50 (maximum 200). # List subject disputes (ARCO) Source: https://docs.cbpayapp.com/api-reference/qscore/list-subject-disputes-arco /openapi.yaml get /v1/qscore/subjects/{docID}/disputes Lists the ARCO disputes opened for a subject (data subject rights: rectification and cancellation of credit data). Filterable by `status` (`open`, `under_review`, `resolved_corrected`, `resolved_rejected`). The dispute lifecycle is operated from the org admin panel or by your organization via `PATCH /v1/org/qscore/disputes/{id}`; each change is notified to the requester by email. A resolved dispute never deletes the disputed record: the correction is a new record that supersedes it. # Look up a subject Source: https://docs.cbpayapp.com/api-reference/qscore/look-up-a-subject /openapi.yaml get /v1/qscore/subjects/{docID} Returns the subject record for a document id (no new report is issued and nothing is billed). Subjects are created automatically the first time a report is requested for that document. The `docId` path parameter is the document id as issued (e.g. a Chilean RUT like `12.345.678-5`); it is validated and normalized per country. If the subject already has scores, the latest one is summarized in `last_score` / `last_band` / `last_score_at` (`last_score` is absent for the SC band). A subject that does not exist returns `404 not_found`. # Open a dispute (ARCO) Source: https://docs.cbpayapp.com/api-reference/qscore/open-a-dispute-arco /openapi.yaml post /v1/qscore/subjects/{docID}/disputes Opens an ARCO dispute on a specific record of a subject (e.g. a debt that does not belong to them or was already paid), in compliance with data subject rectification rights. The subject must already exist (it is created by the first report). `record_source` identifies the data source family of the disputed record and `record_ref` the record inside that source (both appear in the report as `source` / `ref` of each tradeline or adverse event). The dispute starts in `open` status and its lifecycle is managed by your organization. # Revoke a consent link Source: https://docs.cbpayapp.com/api-reference/qscore/revoke-a-consent-link /openapi.yaml post /v1/qscore/consents/{id}/revoke Revokes the consent (status `revoked`) and emits the `risk_consent_revoked` webhook. A consent the holder already decided (`granted`, or already `revoked`/`expired`) cannot transition again and returns `409 already_decided`. # Revoke my Qscore seal Source: https://docs.cbpayapp.com/api-reference/qscore/revoke-my-qscore-seal /openapi.yaml delete /v1/qscore/my-seal Revokes the active seal of the authenticated account. Immediate and PERMANENT for that seal: its public URL and badge switch to "not current" (the URL keeps answering — it never breaks a page that embedded it). It cannot be undone, but the account can activate a NEW seal (with a different code) at any time, as long as it is still eligible. A revocation notice email is sent to the account holder (org branding). # Subscribe to a subject (monitoring) Source: https://docs.cbpayapp.com/api-reference/qscore/subscribe-to-a-subject-monitoring /openapi.yaml put /v1/qscore/subjects/{docID}/monitoring Creates or updates the monitoring subscription of your account to a subject (person or company). Monitoring is free, but requires your organization to have already purchased a `ready` report for that subject — the subscription baseline is seeded from that purchased data. By design (anti-oracle), a non-existent subject and an existing subject without a purchased report receive the SAME `403 report_required` response: this endpoint never reveals whether a document exists in the bureau. The subscription is idempotent per (account, subject): re-creating returns the existing subscription with its baseline preserved. `only_material` (default false) limits the `new_records` trigger to materially negative records (open collections, protested instruments, bankruptcies, lawsuits). `monitor_since_score` (1..999) arms the `score_drop_below` trigger: an alert fires when the score crosses that threshold downwards. # Unsubscribe a subject Source: https://docs.cbpayapp.com/api-reference/qscore/unsubscribe-a-subject /openapi.yaml delete /v1/qscore/subjects/{docID}/monitoring Deactivates the monitoring subscription of your account for a subject (`active: false`). No more `risk_monitoring_alert` webhooks are sent; you can subscribe again with `PUT`. Returns `404 not_found` if there is no subscription. # Verify a Qscore report's authenticity (public) Source: https://docs.cbpayapp.com/api-reference/qscore/verify-a-qscore-reports-authenticity-public /openapi.yaml get /verify/qscore/{code} Public endpoint (no credentials) behind the verification code printed on every Qscore credit report. The code is cryptographically signed: a tampered or made-up one answers 404 with `valid: false`. The response shows only non-sensitive facts: that the report exists, its score BAND (never the numeric score) and its issue date — never the subject's identity nor credit detail. Browsers get a branded HTML page; API clients get JSON. Rate limited per IP. # Download the banking operation receipt (PDF) Source: https://docs.cbpayapp.com/api-reference/receipts/download-the-banking-operation-receipt-pdf /openapi.yaml get /v1/banking/operations/{operationID}/receipt Branded PDF receipt of a banking operation (amount, currency, counterparty, status) with the signed verification QR. Available for operations created after the traceability mirror went live. # Download the card purchase receipt (PDF) Source: https://docs.cbpayapp.com/api-reference/receipts/download-the-card-purchase-receipt-pdf /openapi.yaml get /v1/cards/{cardID}/transactions/{transactionID}/receipt Branded PDF receipt of a card transaction, with the merchant, USD amount, debited balance and the signed verification QR. # Download the crypto deposit receipt (PDF) Source: https://docs.cbpayapp.com/api-reference/receipts/download-the-crypto-deposit-receipt-pdf /openapi.yaml get /v1/crypto/deposits/{depositID}/receipt Branded PDF receipt of a credited on-chain deposit. The `depositID` is the `deposit_id` field returned by `GET /v1/crypto/transactions`. # Download the crypto withdrawal receipt (PDF) Source: https://docs.cbpayapp.com/api-reference/receipts/download-the-crypto-withdrawal-receipt-pdf /openapi.yaml get /v1/crypto/withdrawals/{withdrawalID}/receipt Branded PDF receipt of the on-chain withdrawal, with the network, destination address, tx hash and the signed verification QR. # Download the payin receipt (PDF) Source: https://docs.cbpayapp.com/api-reference/receipts/download-the-payin-receipt-pdf /openapi.yaml get /v1/payins/{payinID}/receipt Branded PDF receipt of the collection, with the reference, amounts, status badge and the signed verification QR. Non-final states carry a diagonal watermark. # Download the payout receipt (PDF) Source: https://docs.cbpayapp.com/api-reference/receipts/download-the-payout-receipt-pdf /openapi.yaml get /v1/payouts/{payoutID}/receipt Branded PDF receipt of the payout, with the beneficiary data, amounts, status badge and the signed verification QR. Non-final states carry a diagonal watermark. Only the payout's owner (or the org admin) can download it. # Download the receipt PDF from the tracking link (public) Source: https://docs.cbpayapp.com/api-reference/receipts/download-the-receipt-pdf-from-the-tracking-link-public /openapi.yaml get /v1/public/track/{code}/receipt.pdf Public endpoint (no credentials) that generates the receipt PDF on the fly with the same renderer as the authenticated endpoints, in the requested language (`lang=es|en|zh`; Chinese renders with the Noto Sans SC font). Same signed code as the JSON tracker. Responses carry `X-Robots-Tag: noindex` and `Cache-Control: no-store`. Shares the per-IP rate limit. # Download the segregated wallet deposit receipt (PDF) Source: https://docs.cbpayapp.com/api-reference/receipts/download-the-segregated-wallet-deposit-receipt-pdf /openapi.yaml get /v1/segregated-wallets/{walletID}/deposits/{depositID}/receipt Branded PDF receipt of an on-chain deposit received by a segregated wallet, with the network, origin address, tx hash and the signed verification QR. # Download the segregated wallet send receipt (PDF) Source: https://docs.cbpayapp.com/api-reference/receipts/download-the-segregated-wallet-send-receipt-pdf /openapi.yaml get /v1/segregated-wallets/{walletID}/sends/{sendID}/receipt Branded PDF receipt of an on-chain send from a segregated wallet, with the network, destination address, tx hash and the signed verification QR. # Download the swap receipt (PDF) Source: https://docs.cbpayapp.com/api-reference/receipts/download-the-swap-receipt-pdf /openapi.yaml get /v1/swaps/{swapID}/receipt Branded PDF receipt of the balance conversion, with both legs, the applied rate and the signed verification QR. # Download the transfer receipt (PDF) Source: https://docs.cbpayapp.com/api-reference/receipts/download-the-transfer-receipt-pdf /openapi.yaml get /v1/transfers/{transferID}/receipt Branded PDF receipt of the internal transfer (both parties can download it; the counterparty is shown by display name). # Get the public tracking link of a transaction Source: https://docs.cbpayapp.com/api-reference/receipts/get-the-public-tracking-link-of-a-transaction /openapi.yaml get /v1/track-link Returns the shareable public tracking link of any transaction in your read scope, without downloading the receipt PDF — the building block for a "Share link" button in your UI. The `code` is the same HMAC-signed, deterministic code printed as a QR on every receipt, so calling the endpoint twice for the same transaction always returns the same link, and anyone who receives the link can follow the operation on the public tracking page (`GET /v1/public/track/{code}`, Wise-style: timeline, receipt PDF download, three languages). Available to the account itself (API key or member session), to org admins and to platform admins — the same read scope as receipts. A transaction outside your scope (or an unknown `kind`) answers 404, never 403: existence is never leaked. # Track an operation by its public link (public) Source: https://docs.cbpayapp.com/api-reference/receipts/track-an-operation-by-its-public-link-public /openapi.yaml get /v1/public/track/{code} Public endpoint (no credentials) behind the shareable tracking link printed on every receipt and receipt email. The code is cryptographically signed: a tampered or made-up one answers a uniform 404. Review statuses are generalized to `processing` (anti tipping-off). The `timeline` uses a fixed step sequence per operation family and only carries REAL timestamps (created/updated) — never fabricated ones. `lang` switches the labels (`en` default via the hosted page, `es`, `zh`). Every response carries `X-Robots-Tag: noindex` and `Cache-Control: no-store`. Shares the verify endpoint's per-IP rate limit. # Verify a receipt's authenticity (public) Source: https://docs.cbpayapp.com/api-reference/receipts/verify-a-receipts-authenticity-public /openapi.yaml get /verify/receipts/{code} Public endpoint (no credentials) behind the QR printed on every receipt. The code is cryptographically signed: a tampered or made-up one answers 404 with `valid: false`. The response shows the operation's REAL current status and amount — never the beneficiary's personal data. Browsers get a branded HTML page; API clients get JSON. Rate limited per IP. # Changelog Source: https://docs.cbpayapp.com/en/changelog History of API and documentation changes Every change to the CBPay API and this documentation, most recent first. Breaking changes are announced in advance and flagged as **Breaking**. ### v2.59 **Changed** * **USD bank-rail payouts: the beneficiary may live in any country** ([payouts guide](/en/guides/payouts)): for `ach`, `wire` and `swift` transfers the beneficiary's `country_code` is no longer fixed to `US` — for example, an ACH to a US bank account for someone living in Germany. The receiving bank stays in the US for `ach`/`wire` (`bank_country: "US"` — domestic rails do not pay banks abroad); for `swift` the bank may be in any country. `state` is required only when the beneficiary lives in the US. Annex-B jurisdictions (CU/IR/KP/SY) stay blocked for both the beneficiary and the bank country. * **Supporting document required on every USD bank-rail transfer**: USD payouts via `ach`, `wire` and `swift` always require a supporting document (invoice/receipt) uploaded first via `POST /v1/payouts/documents`, regardless of the beneficiary's country — previously the requirement depended on the country. A missing document returns `400 supporting_document_required`. ### v2.58 **Changed** * **Card payins with a settlement delay confirm as `credited` right away** ([fees](/en/concepts/fees#card-payin-settlement-delay)): with `settlement_hours > 0`, a paid card payin now flips to `status: credited` **at payment time** — the `payin_credited` webhook fires immediately and a card-paid checkout link closes as paid. What waits is only the **balance**: it lands in your ledger at `settle_at` (the settlement worker runs every minute) or earlier if your org admin releases it manually. Previously the payin stayed `pending` and `payin_credited` only fired at settlement. * **New payin fields**: while the balance is scheduled, the create/GET/list responses carry `settle_at` (RFC 3339) and `settlement_pending: true`; once the balance lands they carry `settled_at` instead. * **`payin_settlement_scheduled` payload**: the webhook now reports `status: "credited"` (was `pending`), matching the immediate confirmation. **Added** * **New error `settlement_pending`** (`422`): refunding a card payin whose balance is still scheduled for settlement is declined until the funds are released (at `settle_at`, or earlier by an org-admin release). Details in [refunds](/en/guides/refunds). ### v2.57 **Added** * **New webhook event `payin_settlement_scheduled`**: when your organization has a settlement delay configured for card payins (`settlement_hours > 0`), a paid card payin stays `pending` with a future `settle_at` until the settlement worker releases the funds. From today, the moment the payment is confirmed you receive `payin_settlement_scheduled` exactly once (idempotent — a retry never re-emits it) with the full quote: `usdt_gross`, `fee`, `usdt_net` (the amount that will be credited at maturity), `settle_at` and `receipt_url`. Previously the only signal was the payin sitting in `pending` with no confirmation. At maturity the worker credits the balance and emits `payin_credited` as usual. Subscribe with `event_type: "payin_settlement_scheduled"` like any account event. ### v2.56 **Changed** * **Neutral balance summary on banking balances**: `GET /v1/banking/accounts/{bankAccountID}/balance` and `GET /v1/banking/third-parties/{thirdPartyID}/accounts/{bankAccountID}/balance` now return three optional top-level fields next to the (unchanged) `balance` object: `available` and `held` as flat decimal strings (e.g. `"1250.00"`, `"0.00"`) and `currency` (ISO 4217, e.g. `"USD"`). They are the recommended way to read the amount — previously the amount only existed nested inside the rail-native `balance` object, whose shape varies per rail. The `balance` object keeps the full account detail (display name, requisites to receive money). ### v2.55 **Added** * **Configurable card payin settlement** ([fees](/en/concepts/fees#card-payin-settlement-delay)): the `payin_card` fee configuration now accepts `settlement_hours` (integer ≥ 0, default `0` = immediate credit, exactly as before). With a delay configured, an approved card charge leaves the payin `pending` with a new `settle_at` timestamp (RFC 3339) in the create, detail and list responses — the balance credit, the `payin_credited` webhook, the checkout link closing and the auto-conversion all happen at `settle_at` (a worker settles due payins every minute). A payin whose deadline already passed when it gets approved or assigned credits immediately. Sending `settlement_hours` for any other service (or a negative value) answers `400 invalid_settlement_hours`. * **Banking rail fees** ([fees](/en/concepts/fees#banking-rail-fees)): five new transactional fee services — `banking_deposit`, `banking_transfer_ach`, `banking_transfer_swift`, `banking_transfer_wire` and `banking_transfer_sepa` — percent + fixed, charged in the operation currency (`BANK_USD` / `BANK_EUR`). Deposits are charged when credited (capped at the deposit amount, so a small deposit never goes negative); outbound transfers are charged at dispatch with a fail-closed `balance >= amount + fee` check and the fee is refunded if the transfer is definitively rejected. A rail with no specific configuration falls back to the legacy `banking_operation` service; a rail configured at 0% + 0 fixed is explicitly free and never falls back. ### v2.54 **Changed** * **Date filters now use your organization's timezone**: every `from`/`to` date filter (`YYYY-MM-DD`) on platform listings (payouts, payins, crypto withdrawals, banking transfers, expenses, statement, analytics, revenue) now interprets the day in your organization's timezone instead of UTC. `from` is midnight of that day in your zone (inclusive) and `to` is midnight of the next day in your zone (exclusive). The timezone is an organization setting (`timezone`, IANA name) managed by the platform — the default is `America/New_York` — and the current value is exposed in `GET /v1/branding` as `timezone`. Daily buckets of analytics and revenue also group by your organization's civil day. No request shape changed: only the calendar day a `YYYY-MM-DD` filter covers. ### v2.53 **Added** * **Verifiable public Qscore seal** ([seal guide](/en/guides/qscore-seal)): company accounts with band A or B and a fresh evaluation (no older than 90 days) can activate a public, verifiable seal — `POST /v1/qscore/my-seal` (idempotent by design: a replay answers 200 with the current seal), check it with `GET /v1/qscore/my-seal`, then share the public page or embed the live SVG badge (`badge_url`). The page and the badge re-evaluate eligibility on every view: if the score drops below band B or the evaluation ages past 90 days, the seal switches to "not current" — it never reveals the reason or the numeric score (anti-oracle). The holder can revoke at any time with `DELETE /v1/qscore/my-seal`; revocation is permanent for that seal and a new one can be activated right away. New public endpoints: `GET /platform/verify/qscore/seal/{code}` (JSON, or a branded HTML page for browsers) and `GET /platform/verify/qscore/seal/{code}/badge.svg` (embeddable live badge). New error codes: `seal_companies_only`, `seal_not_eligible` and `no_active_seal`. No new webhooks. ### v2.52 **Added** * **Qscore consent links (holder authorization)** ([consent links guide](/en/guides/qscore-consents)): ask a subject to authorize read access to their banking data with a shareable link — `POST /v1/qscore/consents` (idempotent, optionally emailed to the holder with your branding), then track with `GET /v1/qscore/consents` and `GET /v1/qscore/consents/{id}`, or cancel with `POST /v1/qscore/consents/{id}/revoke`. The holder decides on a public page (no login): they connect their bank through the secure widget and the verified holder identity must match the subject's document exactly (an account under a different document never grants consent). Once granted, CBPay derives positive banking facts (accounts, balances, 90-day income/expense activity) into the subject's credit file — Qscore reports re-derive them on every generation so the data stays fresh. New webhooks: `risk_consent_granted` and `risk_consent_revoked`. New error codes: `purpose_required`, `invalid_purpose`, `invalid_doc_id`, `invalid_subject_type`, `invalid_email`, `already_decided`, `link_inactive` and `holder_mismatch`. ### v2.51 **Added** * **Qscore batch scoring (portfolio scoring)** ([Batch scoring guide](/en/guides/qscore-batch)): upload a batch of subjects with `POST /v1/qscore/batches` — a JSON array or a CSV string, up to 5,000 subjects per batch — and the platform issues one full Qscore report per subject asynchronously. Rows are validated upfront (invalid document ids, unsupported subject types and in-batch duplicates are reported in `rejected_items` and never processed); each item is billed the standalone report fee when processed and automatically refunded if it fails terminally. When the batch finishes you receive one `risk_batch_completed` webhook and one email with the counters — the individual reports do not emit their own webhook or email. Read the per-item results with `GET /v1/qscore/batches/{batchID}/items` or download them with `GET /v1/qscore/batches/{batchID}/results.csv`. New error codes: `no_valid_items` and `too_many_items`. ### v2.50 **Added** * Qscore company reports may now include a `peer_benchmark` block: the score's position within its industry segment (same country, same ISIC industry). It reports the segment code and label (`segment_code`/`segment_label`), the number of comparable companies (`peers`), the `percentile` (share of peers with a lower score) and the segment's `median_score`. The block is published only when at least 5 comparable companies exist, and only for company reports. ### v2.49 **Added** * **Qscore — your own credit report, free** ([Qscore guide](/en/guides/qscore)): the holder of a verified account can now pull THEIR OWN Qscore credit report — the ARCO / data-protection right of access — with `POST /v1/qscore/my-report` (optionally `{"lang":"es"|"en"|"zh"}`), read the latest one with `GET /v1/qscore/my-report` and download the branded PDF with `GET /v1/qscore/my-report/pdf`. Unlike a purchased report, the self report is **free** (no fee), the subject identity comes from the verified `tax_id` of the account (the request never accepts a `doc_id` — pulling a third party's report through these endpoints is impossible by design), one NEW report can be generated every 30 days (within the window the existing one is returned with `idempotency_hit: true`), and self reports are excluded from the subject's query count, so checking your own report never penalizes your score. The `risk_report_ready` webhook of a self report carries `purpose: "self_access"`. The commercial endpoint `POST /v1/qscore/reports` now rejects `purpose: "self_access"` with `400 invalid_purpose`. New error codes: `kyc_required`, `no_tax_id`, `invalid_tax_id` ([errors](/en/errors)). ### v2.48 **Changed** * **Banking and card applications can now be held for review** ([banking guide](/en/guides/banking), [cards guide](/en/guides/cards), [transaction reviews](/en/guides/transaction-reviews)): if your organization enabled application review, `POST /v1/banking/customer`, `POST /v1/banking/third-parties` and `POST /v1/cards` can answer **`202 Accepted`** with `{"status":"in_review","kind":"...","review_id":"..."}` instead of creating the resource right away — nothing is sent for processing until compliance approves the review. The opening/issuance fee is charged when the application is held and **refunded automatically** if it is rejected. A retry with the same `idempotency_key` returns the same review (`idempotency_hit: true`) and never double-charges. Track the outcome with the `txn_review_status_changed` webhook or in [Transaction reviews](/en/guides/transaction-reviews) (kinds `banking_application` / `card_application`). ### v2.47 **Added** * **Qscore — continuous subject monitoring** ([Qscore guide](/en/guides/qscore)): once you own a `ready` report for a subject, subscribe with `PUT /v1/qscore/subjects/{docID}/monitoring` and the platform re-evaluates the subject every \~5 minutes, emitting the new webhook `risk_monitoring_alert` when the score drops below your `monitor_since_score` threshold (`score_drop_below`), when new bureau records appear (`new_records`) or when records are removed (`records_removed`) — with `only_material: true` only the material triggers fire. Manage subscriptions with `GET /v1/qscore/subjects/{docID}/monitoring`, `GET /v1/qscore/monitoring` (every monitored subject of the account) and `DELETE /v1/qscore/subjects/{docID}/monitoring` (deactivates with `active: false` — the history is never deleted). Monitoring is free but requires a purchased report for the subject: without one the API answers `403 report_required`, the same response a non-existent subject gets (by design, so subject existence cannot be probed). New error code: `report_required` ([errors](/en/errors)). ### v2.46 **Added** * **Bank reference of a payout, now visible on every surface** ([payouts guide](/en/guides/payouts)): every payout exposes `bank_reference` — the transaction id assigned by the destination bank/rail — in the responses of `POST /v1/payouts`, `GET /v1/payouts` and `GET /v1/payouts/{payoutID}`, in the `payout_status_changed` webhook payload, on the PDF receipt, in the payouts CSV export and in the statement (JSON and Excel). The field is empty (`""`) while the payout is in flight and populated once it is `completed` — it is the reference the beneficiary can use to cross-check the payment with their bank. ### v2.45 **Added** * **Qscore — API-first credit bureau** ([Qscore guide](/en/guides/qscore)): buy credit reports on people and companies (`POST /v1/qscore/reports`), Chile first. Each report aggregates negative records, labor and pension background, unpaid leaves and official gazette publications into a 0–1000 score with bands A–E (`SC` when there is no data), reason codes, a branded PDF and a public verification code. `GET /v1/qscore/subjects/{docID}/score` reads the current score of a subject you already reported on, `GET /v1/qscore/reports/{reportID}/pdf` downloads the PDF, and the ARCO rectification flow runs through `POST /v1/qscore/subjects/{docID}/disputes` + `GET /v1/qscore/disputes/{disputeID}`. Reports are billed per document type (`risk_report_person` / `risk_report_company`) and require the declared `purpose` under data protection law. New webhooks: `risk_report_ready` and `risk_score_changed`. Requires the `risk` service flag. * **Public report verification**: `GET /v1/verify/qscore/{code}` validates the code printed on a Qscore report and returns the non-sensitive facts (subject class, band, issue date) — no PII. * New error codes: `purpose_required`, `invalid_purpose`, `invalid_doc_id`, `invalid_subject_type`, `no_score`, `pdf_not_ready` ([errors](/en/errors)). ### v2.44 **Fixed** * `payin_received` events are no longer generated for internal bank reversals of the operator's treasury account (the reversal of an own outbound transfer was never a customer payment). ### v2.43 **Changed** * **Cities catalog quality pass** ([AML catalogs guide](/en/guides/aml)): the cities served by `GET /v1/aml/catalogs/cities?country=` were regenerated for correctness and coverage. Names keep their local spelling (accents, `ñ`, `ü` — "Alhué", "Coyoacán", "São Paulo"), local names replace English exonyms ("Ciudad de México", not "Mexico City"), and urban divisions are now complete — every comuna of Santiago, the districts of Lima, the alcaldías of Mexico City, municipalities across the region. Source noise was removed (US census suffixes like "Abanda CDP", residual administrative prefixes, duplicate spellings). Same response shape and error codes — no integration change. ### v2.42 **Added** * **Tracking link from the API** ([tracking guide](/en/guides/tracking)): the new authenticated `GET /v1/track-link?kind=&id=` returns the shareable public tracking link of any transaction in your read scope — `{ "track_url", "code" }` — without downloading the receipt PDF. It is the building block for a "Share link" button in your UI. The `code` is the same deterministic HMAC code printed on every receipt, so the link is always the same for a given transaction. Available to the account itself, to org admins and to platform admins; a transaction outside your scope answers a uniform `404 not_found`. ### v2.41 **Added** * **Public tracking link for every transaction** ([tracking guide](/en/guides/tracking)): every receipt (payout, payin, refund, internal transfer, swap, crypto withdrawal or deposit, banking operation, card purchase) now carries a shareable public link — `https://business.cbpayapp.com/t/{code}`, with the same signed code printed on the receipt — that opens a Wise-style tracking page with the live timeline, the receipt PDF download and language selection (EN/ES/ZH). The page is `noindex`, never cached, and shows review-type states as `processing` (no tipping-off). * **Public tracking endpoints**: `GET /v1/public/track/{code}?lang=` returns the public JSON state of a transaction and `GET /v1/public/track/{code}/receipt.pdf?lang=` regenerates the receipt PDF on the fly. Both are unauthenticated, rate-limited per IP and answer a uniform `404 not_found` to invalid or tampered codes. * **Receipts in Chinese**: receipt PDFs and the tracking page now fully support `lang=zh`. **Changed** * **Receipt `verify_url`s open the tracker**: the `verify_url` of new receipts and of receipt emails now points to the tracking page. The legacy `GET /v1/verify/receipts/{code}` stays live — browsers are redirected (302) to the tracker while API clients keep receiving the same JSON verdict as before. ### v2.40 **Added** * **Bank directory lookup** ([payouts guide](/en/guides/payouts)): the new `GET /v1/payouts/bank-directory/lookup` autocompletes the beneficiary bank from an embedded public bank directory — pass exactly one of `routing_number` (9 digits, US only) or `swift` (8 or 11 characters; an `XXX` suffix is normalized to the head office) and it resolves the bank name, city, state and address block, so payout and counterparty forms can prefill `bank_name` and the optional `bank_*` fields while the sender types. A `404 bank_not_found` simply means the code is not in the directory: keep the form manual. Static data with `Cache-Control: public, max-age=86400`. * **Postal code lookup** ([AML guide](/en/guides/aml)): the new `GET /v1/aml/catalogs/postal-code?country=US&code=33130` resolves a US ZIP code to its `city` and `state`, so address forms can autofill both fields while the user types the ZIP. Only US has a dataset today; a `404 postal_code_not_found` means the ZIP is unknown (or the country has no dataset) and the fields stay manual. **Fixed** * **Cities catalog quality cleanup**: `GET /v1/aml/catalogs/cities` now dedupes cities by their folded form (diacritic-free, case-insensitive), unifies the Hawaiian ʻokina, drops two corrupted glued entries, collapses internal whitespace, and serves `Content-Type: application/json; charset=utf-8`. ### v2.39 **Added** * **Cities catalog per country** ([AML guide](/en/guides/aml)): the new `GET /v1/aml/catalogs/cities?country=US` returns the cities of one ISO 3166-1 alpha-2 country grouped by subdivision — `states` keys are the same ISO 3166-2 codes as `country_subdivisions` in the main catalog, and `country_cities` lists the cities whose region could not be mapped (neither field is ever `null`). A country without coverage answers `200` with empty lists (fall back to a free-text city field); a malformed code gets `400 invalid_country` and an unknown one `404 country_not_found`. Static data with `Cache-Control: public, max-age=86400` — one call per country, then filter by state on the client. * **US/USD payins now publish two deposit rails** ([payins guide](/en/guides/payins)): the announced `bank_transfer` corridor serves a **domestic wire** instruction (ABA `routing_number`) and an **international SWIFT** instruction (BIC + correspondent bank) side by side — the announcement, the payin reads and the `GET /v1/payins/deposit-instructions` preview all expose `deposit_instructions` (domestic) and `deposit_instructions_swift` (international), each with its own copy-paste QR, so the payer picks the rail their bank supports. Both blocks carry the new `holder_address` and `notes` fields when configured (the QR prints them as "Holder address" and "Note" lines). Fail-closed: a US/USD corridor missing its SWIFT variant answers `422 deposit_instructions_unavailable` at announcement time. ### v2.38 **Added** * **Automatic rejection by review deadline** ([transaction reviews guide](/en/guides/transaction-reviews)) — if your organization configured a review deadline for the transactional firewall, a review nobody decides within that window (counted from its last status change — uploading evidence resets the clock) is **automatically rejected** by an hourly sweep: the operation is cancelled, any held funds return to your balance, and you receive the same email and `txn_review_status_changed` webhook (`status: "rejected"`) as with a manual rejection. The review detail carries the standard deadline notice in `decision_note`. ### v2.37 **Added** * **New payin corridor: United States (USD) via announced bank transfer (ACH / wire)**. The account announces the deposit with `POST /v1/payins` `{method: "bank_transfer", country: "US", currency: "USD", amount, idempotency_key, payer_name?}` and the response carries the unique `CB…` reference, `status: "pending"` and the complete `deposit_instructions` block — which now supports the US banking fields `routing_number` (ABA), `swift` (BIC), `bank_address`, `intermediary_bank_name` and `intermediary_bank_swift`. The client transfers from their bank with the `CB…` reference in the transfer memo; when the deposit reaches the collection account it is matched by reference and credited in USDT (`status: "credited"` with `usdt_credited` and `fx_rate`). A bank credit with no announcement or reference stays `unassigned` — fail-closed, it is never auto-credited. You can preview the destination account without announcing via `GET /v1/payins/deposit-instructions?country=US¤cy= USD&method=bank_transfer`. The United States joins the corridors that require the organization to have deposit instructions configured: announcing without them fails with `422 deposit_instructions_unavailable`. US tab with full request/response examples, status table, errors and FAQ in the [Payins guide](/en/guides/payins); the `deposit_instructions` block and the preview endpoint are updated in the API reference. ### v2.36 **Added** * **Transactional firewall**: when your organization has the transactional firewall enabled, money operations (payouts, crypto withdrawals, payins and banking transfers) can be held `in_review` awaiting a human decision — the API answers `202 Accepted` with a `review_id`. New account endpoints `GET /v1/me/txn-reviews` (list and detail), `POST /v1/me/txn-reviews/{reviewID}/files` (upload the requested documents) and `GET .../files/{fileID}/download` (download your own files), plus the new `txn_review_status_changed` webhook (neutral payload — internal reasons never travel). New guide: [Transaction reviews](/en/guides/transaction-reviews). ### v2.35 **Added** * **New payout corridor: United States (USD)** with three methods — `ach` (next-day ACH to checking or savings accounts), `wire` (domestic wire) and `swift` (international USD wire via SWIFT). The rail requires the beneficiary's **complete identity and postal address on every transfer** (`name`, `email`, `account_number`, `country_code`, `address`, `city`, `postal_code`, `bank_name` and `bank_code` — ABA routing number for `ach`/`wire`, SWIFT BIC for `swift`; `account_type` `CHECKING`/`SAVING` for `ach`), with the receiving bank's address block and `bank_phone` as recommended optionals. `wire` and `swift` have a **USD 25.00 minimum**. The first payout to a brand-new beneficiary may stay `processing` with `status_code: "pending_aml"` while the rail reviews the beneficiary, and executes automatically on approval; a rail rejection ends the payout `failed` with `status_code: "counterparty_rejected"` and an automatic refund. Per operation you can override the rail's payment-purpose declarations in `options` (`purpose`, `crypto_activity`, `payment_gateway`). Corridor table, per-method field table, real examples and FAQ in [Payouts](/en/guides/payouts); named request examples `us_ach`, `us_wire` and `us_swift` in the API reference. ### v2.34 **Added** * The [MCP Server](/en/mcp) page now mentions the dedicated MCP server for the **organization administration** documentation (`mcp-admin.cbpayapp.com`, being deployed), so organization administrators know there is an equivalent assistant feed for the org-admin API — this public server keeps covering the account-level API. ### v2.33 **Added** * **More transparency in document validations**: the document list of a submission (`GET /v1/{kyc,kyb}/submissions/{id}/documents`) now exposes on each validation its `id`, the `effective_outcome` (the verdict currently in force, which may come from a manual review by the operator) and the `manual_review` block with `outcome` and `reviewed_at` when a validation was reviewed by hand. The submission detail also carries `documents_gate`, a summary of the validation status (`ok`, `matched`, `total` and the categories still unresolved). No flow changes: these are additive, read-only fields. ### v2.32 **Added** * **Automatic KYC/KYB decisions**: verification submissions are now decided by an automatic engine before reaching a human reviewer. A **100% clean** file (documents verified, liveness passed, no sanctions or PEP hits, low-risk geography) is **approved in minutes** with no manual queue. Grey areas (homonym AML matches, PEP signals, partial document reads, medium risk, high-risk countries) always go to a **human reviewer**, and clearly invalid files (confirmed severe sanctions, false or expired documents) are **automatically rejected**. Final decisions now carry `decision_source` (`auto` or `admin`) in the `kyc_verification_status_changed` and `kyb_verification_status_changed` webhooks so you can tell how each file was decided. Details in [KYC and KYB](/en/guides/kyc). * **New `HOLDREVIEW` magic value in the testing environment**: a KYC/KYB submission whose subject name contains `HOLDREVIEW` stays in human review instead of being auto-decided, so you can exercise the manual queue end to end. The companion magic `MANUALREVIEW` keeps every signal clean but never settles the submission on its own, so the automatic approve/reject paths of the decision engine can be tested deterministically. See [testing environment](/en/environment-testing). ### v2.31 **Added** * **Enriched banking operation fields**: `GET /v1/banking/operations` and `GET /v1/banking/operations/{id}` now expose optional `direction` (`in` / `out`), net `amount`, `currency`, `counterparty` and `reference` fields whenever the bank reports them — including inbound deposits and bank fees discovered automatically in the operations list. The `banking_operation_status_changed` webhook is unchanged by design (lightweight + fetch detail). Details in [banking](/en/guides/banking). ### v2.30 **Changed** * **Sanitized error messages across the API.** An error `message` never exposes provider names, infrastructure details, URLs, raw upstream bodies (JSON/HTML), or internal configuration — not in API responses, webhooks, or stored status fields. Business rejections from the payment processor keep their actionable reason (for example, why a document or account was rejected); infrastructure failures are replaced by the fixed generic message `"the payment provider could not process the request"` — retry those operations with the same `idempotency_key`. No shape changes: only message contents changed. ### v2.29 **Added** * **Per-purchase card fees**: card transactions can now carry a transactional fee (percent + fixed) configured per account through two new services — `card_purchase_virtual` and `card_purchase_physical`. The fee is **estimated at authorization** (included in the balance hold), **recalculated at settlement** with the configuration in force at that moment, and **refunded pro-rata** on partial or total reversals and adjustments. Card transactions now expose `fee_asset`, `fee_amount` and `fee_refunded_amount` (omitted when no fee is configured — accounts without configuration see no change), the purchase receipt shows the fee line, and the ledger records the movements as `card_fee` / `card_fee_refund`. Details in [cards](/en/guides/cards#per-purchase-fee-lifecycle). ### v2.28 **Added** * **Saved cards with payer verification on the payment page**: every card payment page (the `payment_url` of a `card` payin and the card option of the universal checkout) now asks for the **payer's email as the first field** and, when that email has saved cards with you, emails a **verification code** before showing them — the list is never revealed without verification. With **"Remember this device"** (checked by default) the payer skips the code for **30 days** on that browser. Picking a card pays with 3-D Secure without re-typing it. Details in [stored cards](/en/guides/stored-cards-subscriptions#the-payer-discovers-their-cards-on-the-payment-page). **Changed** * **The universal checkout no longer asks for the payer's email**: the card option materializes and redirects straight to the payment page, where the saved-cards discovery now lives. The public endpoint `GET /pay/{token}/saved-cards` was **removed** (it answers 404) — the card list no longer leaves any surface without verification. **No contract changes**: `POST /v1/payins` (`card` and `checkout`) is unchanged, and server-to-server `stored_card_id` needs no code (you already know your customer). ### v2.27 **Added** * **Disable and reactivate webhook subscriptions**: new `PATCH /v1/webhooks/subscriptions/{subscriptionID}` with `{ "status": "active" | "disabled" }` — a `disabled` subscription stops receiving new events without being deleted (deliveries already queued are still sent) and you can reactivate it at any time. Idempotent: repeating the current status is a no-op `200`. See the [webhooks guide](/en/webhooks#disabling-and-reactivating-a-subscription). ### v2.26 **Fixed** * **Verification status protected against late events from older attempts**: when an account retries identity verification (for example, after a rejection), a late status event from an earlier attempt can no longer change the account's verification status or trigger the decision email — only the most recent attempt decides it. Every attempt keeps its full history in the admin panel. ### v2.25 **Changed** * **Administration error codes documented**: added the `global_treasury_access_disabled` and `invalid_value` error codes to the [error catalog](/en/errors). These come from organization administration surfaces (the CBPay Admin panel), not from the account-level API — see the new "Organization admin panel" section. ### v2.24 **Added** * **Deposit instructions for announced bank transfers** ([payins guide](/en/guides/payins)): creating a payin with `method: "bank_transfer"` on supported corridors now returns a `deposit_instructions` block with the exact destination account — `bank_name`, `account_number`, `account_type`, `holder_name`, `holder_tax_id`, `holder_email`, `reference_required`, a copy-paste `qr_payload` (multi-line text with the account, the holder and your reference/amount) and a branded `qr_png_base64`. The same block is echoed back on the payin's detail and list responses. The new `GET /v1/payins/deposit-instructions?country=¤cy=&method=` previews the destination account before you create the payin. See the guide's FAQ for why the bank QR is scan-to-copy, not auto-fill. ### v2.23 **Added** * **Automatic KYC/KYB decision emails** (self onboarding): when your verification lands on approved, rejected, or changes requested, you get a branded email with the organization letting you know the outcome. Does not apply to third-party verifications (for example, your company verifying a customer or vendor) — that flow keeps using the `kyc_status_changed`/`kyb_status_changed` webhook you already integrated. The email never includes the detailed reason for a rejection, for security and privacy reasons. ### v2.22 **Changed** * **Liveness now supports multiple sessions per subject** ([identity verification](/en/guides/kyc)): the verification report's `liveness[]` array can carry more than one entry per person — the onboarding `gate` check plus one or more later evidence `media_recapture`s. Each session now carries its own `session_id` and `purpose` (`gate` or `media_recapture`). In a KYB, `parties[].liveness` (singular) is kept for compatibility and always points to that party's `gate` session, while the new `parties[].liveness_sessions[]` carries every session for that party. Media metadata stays URL-free (`has_selfie`, `has_video`, `frame_gestures`, hashes) — no contract change there. ### v2.21 **Changed** * **Verification report with a navigable cover** ([identity verification](/en/guides/kyc)): the PDF opens with an index of **clickable** cards (icon, title and page number) that jump to their section. Every section carries its icon and accent bar, matching the visual language of the AML report. * **Clickable links**: adverse media entries in the AML annex carry a “view source” chip and the public verification URL in the closing block is clickable. For safety, **only `http` and `https` links are embedded** — any other scheme is dropped and the text stays unlinked. * **Photos keep their real aspect ratio**: identity document and liveness photos are rendered without stretching, with their caption underneath. * **No blank pages, no orphan headings**: each section header reserves the height of its first block, so it never sits alone at the bottom of a page. * The aggregated adverse media status now reads **“Review”** (was “In review”). ### v2.20 **Added** * **Visual evidence in the verification report** ([identity verification](/en/guides/kyc)): when the provider publishes liveness media (selfie / frames) or identity document photos, the **PDF embeds the photos** best-effort. If media is missing or the link expired, the photo section is omitted. The report JSON only declares metadata (`has_selfie`, gestures, `has_video`, hashes) — **never signed URLs**. * **Full AML annex at the PDF closing**: when a screening exists, the report reuses the same closing as the standalone AML report (attribution, coverage stats, source blocks and legal notice). Without a screening it keeps the generic disclaimer. ### v2.19 **Added** * **Complete verification report, nothing discarded** ([identity verification](/en/guides/kyc)): the KYC/KYB report went from summary to full file. On top of what it already carried, the JSON and the PDF now include the **declared economic profile** (source of funds, purpose of the relationship, expected volumes and income, expected chains), the **risk attestations** (money services, third-party funds, high-risk activities, prohibited countries), the **bank account masked at the source** (the full number never enters the report), the extended company identity (incorporation, jurisdiction, ISIC industry, website, countries of operation, registered address) and the **remainder of the file**: every verified field that does not fit a structured section is still emitted in the fields block. * **Related parties with their own AML screening (KYB)**: every UBO, control person and signer in the file is emitted as a `parties[]` entry with its identity, its ownership, the documents and liveness proof that belong to it, and **its own AML screening with continuous monitoring enabled**. No charge: this is diligence, not a billable product. The `(source, index)` pair is the stable identity of the party, so its screening is always the same no matter how many times you download the report. If a party has no screening yet at download time, the report comes back with `"partial": ["party_aml_unavailable"]` and the missing one runs in the background. * **AML screening with per-match detail**: the AML section of the full report (third parties and admin reads) now carries indicators, aliases, sanctions lists with source and validity, PEP positions, RCA links and adverse media — the same level of detail as the AML report. On your own onboarding report the section stays **aggregated** (`clear` / `under_review`, no matches), and the same applies to the screening of your related parties. * **Document validation in the document detail**: every document in the report exposes `validated_at` and, when applicable, the rejection reason, alongside category, filename, status, outcome and score. ### v2.18 **Fixed** * **Amounts always as plain decimals** ([payins](/en/guides/payins)): the `local_amount` field on payins (and the `amount` on charge events) is always returned as decimal text — for example `"5000000"` — never in scientific notation. On large deposits in zero-decimal currencies (CLP, PYG, COP) a credit could be recorded as `"5e+06"`: the amount was unreadable for your integration and the deposit failed to match its announced transfer, so it was never credited. The fix also covers historical records: reading them through the API now returns them normalized, without altering any accounting data. ### v2.17 **Added** * **Downloadable KYC/KYB verification report** ([identity verification](/en/guides/kyc)): every KYC/KYB submission now has a platform-generated verification report, with `?format=pdf|json` and `?lang=en|es|zh`. Third parties: `GET /v1/kyc/submissions/{submissionID}/verification-report` and `GET /v1/kyb/submissions/{submissionID}/verification-report` (company account, full report: verified identity, lifecycle, documents + OCR, liveness and AML screening with matches). Own onboarding: `GET /v1/me/verification/report` (same structure with an aggregated AML section). Free of charge — it reads the verification already paid for. * **Public report verification code**: the PDF prints an HMAC code + QR and anyone can validate the document at `GET /verify/reports/{code}` (JSON or HTML page, no personal data: only kind, current decision status, issue date and issuing organization). * **New error codes**: `invalid_format` (400, `format` other than `pdf`/`json`) and `verification_not_found` (404, the account has not submitted any verification yet); `invalid_language` also applies to this report. ### v2.16 **Fixed** * **QR collections now always credit on their own** ([payins](/en/guides/payins)): a paid QR could stay `pending` while the money arrived as an unassigned deposit, because the bank transfer does not carry the collection's reference and amount-based reconciliation is reserved for announced transfers. The deposit that settles a collection (QR, checkout link or card) now travels with the link to the paid charge and is routed one-to-one to its payin — no heuristics. The credited payin declares it with `match_method: charge_link`, the strongest reconciliation signal of all. * **`match_method` documented enum**: the reference listed `single_candidate` and `dedicated_instrument`, which do not exist in the API. The real values are `amount_single_candidate` and `dedicated_clabe`; `charge_link` and `manual_assign` (an admin routed the deposit by hand) were added to the spec. ### v2.15 **Fixed** * **Announced transfers now honour idempotency** ([payins](/en/guides/payins)): `POST /v1/payins` with `method: "bank_transfer"` accepted `idempotency_key` and ignored it, so a retry (timeout, double click) opened a second announcement. Two live announcements with the same amount are exactly the case matching refuses to resolve, so the real deposit landed `unassigned`. A retry with the same key — body field or `Idempotency-Key` header — now replays the original announcement (same `reference`, HTTP `200` with `idempotency_hit: true`). A POST without a key reuses a live identical announcement (same account, currency, amount and payer) instead of duplicating it. To collect two real payments of the same amount from the same payer, send a different key for each announcement. Reusing a key that was already used with ANOTHER payin method (QR, checkout, card) now replies `409 idempotency_conflict` instead of returning an object that does not match the request. ### v2.14 **Added** * **Payer identification on announced transfers** ([payins](/en/guides/payins)): `POST /v1/payins` with `method: "bank_transfer"` accepts the optional `payer_name`, `payer_document` and `payer_account`. If you do not send `payer_document` and the account is a verified individual, the holder's document is used by default, so a deposit that arrives without a reference is still matched by the payer the bank reports. The response says which identity is in play with `payer_source` (`declared`, `account_identity` or `none`). * **Matching audit on the payin**: `GET /v1/payins/{id}` exposes `match_method` (`reference`, `payer_document`, `payer_account`, `payer_name`, `single_candidate`…) and the `payer` block the rail reported, so you can see exactly why a deposit landed in that account. **Changed** * **Announced transfers no longer match "the oldest one" by amount**: if two or more pending announcements share amount and currency and nothing identifies the payer, the deposit stays `unassigned` instead of crediting the wrong account (fail-closed). The amount-only match survives only when there is exactly ONE candidate. Documented in the new [How an announced transfer is matched](/en/guides/payins) section. ### v2.13 **Added** * **Handshake quota on the event stream** ([errors](/en/errors)): opening `GET /v1/events` too many times in a row now answers `429 rate_limited`. It is a different limit from `too_many_streams`: `rate_limited` counts connection *attempts* per IP (600 per hour, plenty for reconnects), while `too_many_streams` caps how many streams you keep *open* at the same time (5 per account). Both retry the same way: wait and reconnect with your `Last-Event-ID`, nothing is lost. ### v2.12 **Added** * **Real-time event stream** ([guide](/en/realtime-events)): `GET /v1/events` opens a Server-Sent Events connection with everything that happens in your account — the same events as the webhooks, delivered to the browser without waiting for a poll. Reconnect with the `Last-Event-ID` header and the server replays what you missed, filter with `?types=` and ask for the absolute current state with `?snapshot=true`. * **Queryable event history**: `GET /v1/events/history` (with `from`/`to`, pagination and filters by type) and `GET /v1/events/{eventID}` read the same log that feeds the stream, kept for 90 days. * **Three new events** ([webhooks](/en/webhooks)): `balance_adjusted` (an admin credited or debited your balance), `account_status_changed` (your account was suspended or reactivated) and `member_security_event` (logins, password or 2FA changes, revoked sessions). They arrive both by webhook and through the stream. * **New error codes** ([errors](/en/errors)): `too_many_streams`, `stream_unavailable` and `streaming_unsupported`. ### v2.11 **Added** * **Card payin refunds** ([guide](/en/guides/refunds)): `POST /v1/payins/{payinID}/refunds` refunds a card payin in full or in part and debits the amount from your balance right away. Refunds require an idempotency key (retrying with the same key never refunds twice) and an OTP code when requested with your session. `GET /v1/payin-refunds` lists your refunds with filters by account, status, kind and date range, `GET /v1/payin-refunds/{id}` returns the detail and `GET /v1/payin-refunds/{id}/receipt` generates the PDF receipt with a verifiable code. * **Refund state on the payin**: refunded payins expose `refund_status` (`partial` or `full`), `refunded_amount` (accumulated USDT debited) and `refunded_local` (accumulated amount returned to the cardholder). * **`payin_refunded` webhook** ([webhooks](/en/webhooks)): notifies every refund, void and chargeback with its kind, status, amounts and the resulting balance. **Changed** * **Fees and FX margin are not refunded**: refunding a payin debits the gross amount credited; what we charged to process the payment stays. A chargeback notified by the issuer is applied automatically and may leave your balance negative until you fund it. * **New error codes** ([errors](/en/errors)): `payin_not_refundable`, `refund_not_supported`, `refund_exceeds_payin` and `invalid_amount`. ### v2.10 **Changed** * **Public service status page redesigned** ([guide](/en/service-status)): the page returned by `status_page_url` now shows a country flag, a payment-method icon, a day-by-day availability bar for the last 90 days, a summary card with the overall state and average uptime, and an incident timeline with reasons written in plain language. It picks up your organization's logo, colors and website, still ships with no JavaScript and no external resources (so you can embed it or share it with your customers), and the JSON at `/v1/status/{token}` is unchanged. ### v2.09 **Added** * **International cards in US dollars** ([guide](/en/guides/payins)): `POST /v1/payins` with `country: "US"`, `currency: "USD"` and `method: "card"` returns a hosted checkout `payment_url` with 3-D Secure branded with your organization, to charge Visa, Mastercard, American Express, Discover and Diners cards issued anywhere. The contract is the same as the Bolivian card page (optional `customer`, `success_url`/`failure_url`, `expires_at`, limited attempts and an idempotent retry returning the same URL), and so is card storage: `save_card` plus `payer_reference` store the card with the payer's consent for [later charges and subscriptions](/en/guides/stored-cards-subscriptions). 3-D Secure runs inside the page (if the issuer asks for a challenge the payer completes it right there) and card data is typed into the processor's secure fields: it never touches your integration. The corridor is enabled per account — `GET /v1/payins/methods` is the source of truth for what you can collect today. ### v2.08 **Added** * **Real-time service status** ([guide](/en/service-status)): every method in `GET /v1/payouts/methods` and `GET /v1/payins/methods` now carries an additive `availability` field (`operational` / `degraded` / `down`), a new broadcast webhook `corridor_status_changed` notifies every availability transition, and each organization gets a public, brand-themed status page (HTML + JSON at `/status/{orgToken}` and `/v1/status/{orgToken}`) with 90-day uptime and incident history. The status page URL is exposed in `GET /v1/branding` as `status_page_url`. ### v2.07 **Added** * **Docs Knowledge Pack for AI/MCP**: this documentation is now also published as a structured, versioned pack at [`/mcp-pack/manifest.json`](https://docs.cbpayapp.com/mcp-pack/manifest.json) (OpenAPI specs in 3 languages, per-page guides in pure Markdown, error and webhook catalogs, testing guide with the simulator magic values, end-to-end recipes and RAG-ready chunks). It is the official source feeding the documentation [MCP server](/en/mcp). **Removed** * **Compiled Markdown `CBPAY_DOCUMENTACION.md` retired**: the single Spanish-only document is obsolete — its replacement is the Docs Knowledge Pack (trilingual, with complete specs) and the MCP server. ### v2.06 **Added** * **Anchor FAQ on every product guide**: payouts, payins, checkout, transfers, crypto, banking, cards, statement, QR payout and stored cards * subscriptions now close with frequently asked questions and a direct link to the [error catalog](/en/errors). * **Error and webhook catalogs completed**: error codes and webhook event types that existed in the API but were missing from the reference pages are now documented. No contract changes. ### v2.05 **Changed** * **Docs overhaul, phase 5 (API reference only, no code change)**: the API reference now groups operations under three new tags — **Checkout** (public `/pay/{token}` pages and quotes), **Stored cards** (`/v1/stored-cards` and saved-card lookups) and **Subscriptions** (`/v1/subscriptions`). These operations were previously stacked under the generic **Payins** tag; every path and contract is unchanged. ### v2.04 **Added** * **Dedicated product guides**, extracted from the payins/payouts monoliths: [Checkout](/en/guides/checkout), [Stored cards & subscriptions](/en/guides/stored-cards-subscriptions) and [QR payout](/en/guides/qr-payout). The original sections keep their headings and link to the new guides, so historical anchors still resolve. * **New end-to-end flows** in [Integration flows](/en/flows): checkout, saved cards and subscriptions, QR POS charges and balance swaps, each with its sequence diagram. **Changed** * **Products navigation reorganized by product family**: Money in, Money out, Balances & account, Identity & compliance, and Experience — instead of a flat 17-page list. * [Profile & security](/en/guides/profile) and [Security and 2FA (OTP)](/en/security-2fa) now cross-link and state their roles: the profile guide owns the user's 2FA factors; the OTP page owns the per-action challenge flow. ### v2.03 **Added** * **Test environment surfaced across the whole site**: every product guide now opens with the test and live base URLs (shared snippet), and the [FAQ](/en/faq), [quickstart](/en/quickstart) and [introduction](/en/introduction) correctly describe the test environment (`https://cryptobank.qbank.cl/platform`, `pk_test_` keys) — earlier copies incorrectly said no sandbox existed. Full details in [Environments and testing](/en/environment-testing). **Changed** * **Introduction product catalog completed**: checkout, cards and subscriptions, QR POS, swaps, segregated wallets, Bitcoin and analytics are now listed with their guides. **Fixed** * Spec descriptions that predated multi-asset balances: tag descriptions realigned (Swaps, AML screening, Cards) and legacy wording like "credited to the USDT balance" corrected to the settlement-asset semantics (`default_payin_asset`, `settlement_asset`). ### v2.02 **Changed** * **Auto-conversion into `default_payin_asset` now executes at the real price, with no swap spread** ([money model](/en/concepts/money-model)): the payin already paid its fee and rate when it credited, so the automatic conversion into your configured balance adds no extra cost — there is no double conversion. The per-operation/24h limits of volatile assets (BTC/GOLD) still apply. Manual swaps (`POST /v1/swaps`) keep their regular spread. ### v2.01 **Added** * **New error code `reserved_idempotency_key` (400)** on `POST /v1/swaps` ([errors](/en/errors)): idempotency keys prefixed `payin-convert:` or `checkout-swap:` are reserved for system auto-conversions (payin default balance and checkout) and are rejected. Use any other key for your swaps. ### v2.00 **Added** * **Default balance for payins (`default_payin_asset`)** ([money model](/en/concepts/money-model)): choose which balance your incoming payments end up in. `PUT /v1/settlement` now accepts `default_payin_asset` (USDT, USDC, BTC or GOLD) and `GET /v1/settlement` exposes it. The payin still credits in USDT (pricing and fees untouched) and the net amount auto-converts into your asset through the swap engine (same spread and limits as a swap). If the conversion fails it stays `conversion_status: pending_retry` and retries automatically. `GET /v1/payins`, the detail and the `payin_credited` webhook expose `settlement_asset` and `conversion_status` when a conversion applies. **Changed** * A checkout link created **without** `settlement_asset` now uses the account's `default_payin_asset` (previously always USDT). ### v1.99 **Added** * **Dedicated fee for card payments (`payin_card`)** ([fees](/en/concepts/fees)): payments credited via card (direct payin with `method: card`, checkout links paid with card, and recurring charges on stored cards) can carry their own percentage fee, configurable **per currency** (e.g. one rate for BOB and another for USD). If your account has no `payin_card` configured, the regular `payin` fee keeps applying — nothing changes without explicit configuration. Check your effective fees in `GET /v1/fees` (rows now include the `currency` field). ### v1.98 **Added** * **`GET /v1/banking/accounts/{bankAccountID}`** ([banking guide](/en/guides/banking)): live details of one of your bank accounts — name, currency, status and the receiving requisites (wire and local rails) under `data`. Use it to render the deposit instructions of a specific account without walking the list. **Changed** * **Bank account listing**: the API now exposes only the accounts enabled for your operation per the corridor configuration. Accounts that are not enabled no longer appear in `GET /v1/banking/accounts` and their by-id lookups return `404`. ### v1.97 **Fixed** * **Checkout page — saved card by default**: when the entered email has saved cards, the primary button now pays with the saved card (its label changes to "Pay with VISA ···· 1234") instead of starting a new-card payment. Using a different card is now an explicit action ("Use another card"). Previously, pressing the primary button while a saved card was listed led to the payment page asking for all card details again. ### v1.96 **Added** * **New corridor: Argentina** 🇦🇷 ([payouts guide](/en/guides/payouts) · [payins guide](/en/guides/payins)): * **Payouts** in **ARS** and **USD** via `bank_transfer` to any 22-digit **CBU or CVU** (bank accounts and virtual wallets; USD is CBU-to-CBU only). Beneficiary takes `name`, `tax_id` (CUIT/CUIL) and `account_number` — no `bank_code` needed. * **Payins** in **ARS** with a **dedicated CVU account** per account (`POST /v1/payins/deposit-accounts` with `country: "AR"`): every incoming transfer is credited automatically, no references needed. CVUs are receive-only: direct debit attempts are rejected automatically. * Available now in the **test environment** (staging) with the simulator; production activation will be announced once bank certification completes — the catalog (`GET /v1/payouts/methods` and `GET /v1/payins/methods`) is always the source of truth. ### v1.95 **Added** * **Billing on file with the saved card** ([payins guide](/en/guides/stored-cards-subscriptions)): the billing details the payer enters when saving their card (name, address, city, email, phone) are stored alongside the credential. When paying again with that card, the secure page applies them automatically — the payer retypes nothing — and shows only a **masked summary** (name, partial email and city) with a "use different details" link in case they want to change them. The full details never reach the browser: the server applies them at authorization time. **Changed** * **Cardholder email required with a saved card**: on the public checkout page, paying with a saved card now requires presenting the same cardholder email it was saved with — a mismatch responds `404` (anti-enumeration protection for personal data). ### v1.94 **Fixed** * **Checkout page — 1-click card payment** ([payins guide](/en/guides/checkout)): continuing with a card on the public page now redirects straight to the secure payment page — the intermediate button that required a second click was removed. Picking a saved card from the list starts the payment immediately. * **Saved card on checkout**: picking a saved card now always reaches the secure page with the credential applied (it shows brand and last 4 digits and never asks for the number again). Previously, the email re-validation could silently drop the selection and the page asked for every field again. Also, switching the choice on the same link (saved ↔ new card) regenerates the right payment session instead of reusing the previous one. ### v1.93 **Fixed** * **Banking webhooks for third parties** ([webhooks](/en/webhooks), [banking guide](/en/guides/banking)): the `banking_customer_status_changed` webhook now also fires when a **third party** registered by your account changes verification state (previously only your own profile's events arrived). The payload adds `customer_kind` (`self` | `third_party`) and, for third parties, `third_party_id` (the same id as `GET /v1/banking/third-parties/{id}`). ### v1.92 **Fixed** * **Checkout charge amounts in the payin history** ([payins guide](/en/guides/checkout)): `GET /v1/payins` and `GET /v1/payins/{payin_id}` now always include the denomination of checkout and QR POS payins — `settlement_asset` + `asset_amount` (plus `conversion_status` when applicable) — in every status, including pending and expired. Previously the amount only appeared once credited, so pending rows showed no amount. Additionally, a charge settled in crypto or via the CBPay app now exposes its `usdt_credited` even without an `fx_rate`. CSV/XLSX exports add the `settlement_asset` and `asset_amount` columns. ### v1.91 **Added** * **Subscriptions (scheduled recurring charges)** ([payins guide](/en/guides/stored-cards-subscriptions#subscriptions-scheduled-recurring-charges)): the platform runs the schedule for charges on a saved card. `POST /v1/subscriptions` (`interval` daily/weekly/monthly/yearly, optional `start_at` for a trial, required `idempotency_key`) charges the first period on creation and fires the rest automatically. Full resource `GET /v1/subscriptions` (+`/{id}`, filters status/stored\_card\_id/payer\_reference) and lifecycle `POST .../pause` · `/resume` · `/cancel`. Dunning on declines (daily retry ×3 ⇒ `past_due`), no catch-up on resume, and automatic cancellation when the card is revoked. Each successful charge credits like a card payin (`payin_credited` with `subscription_id`). New webhook `subscription_status_changed`. **Added** * **Saved cards and recurring charges** ([payins guide](/en/guides/stored-cards-subscriptions)): the `card` method now supports stored credentials (the card brands' COF mandate). `POST /v1/payins` accepts `save_card` (consent checkbox on the hosted page), `payer_reference` (your customer ID) and `stored_card_id` (pay with a saved card without re-typing the number; 3-D Secure still runs). New resource `GET /v1/stored-cards` (+`/{id}`, `DELETE` to revoke) and **merchant-initiated charges** without the payer present: `POST /v1/stored-cards/{id}/charges` (`recurring` for subscriptions; `idempotency_key` required — a retry never charges twice). The card number never exists on the platform: display data only (brand, last 4, expiry). New webhooks `card_stored` and `stored_card_revoked`; new error `422 stored_card_revoked` ([errors](/en/errors)). ### v1.89 **Added** * **Compliance controls on outgoing payments** ([payouts guide](/en/guides/payouts), [errors](/en/errors)): payouts, crypto withdrawals with a beneficiary name and collect charges now go through additional compliance controls **before money moves**. Documented errors: `403 compliance_hold` (the operation was held and NOT created — no debit; by policy the exact reason is not disclosed, contact support with the timestamp) and `503 compliance_check_unavailable` (the check could not be evaluated; the operation was NOT created — retry with the **same** `idempotency_key`). ### v1.88 **Fixed** * **AML screening person shapes** ([guide](/en/guides/aml)): the screening engine requires `date_of_birth` as a `{year, month, day}` object (the plain `"YYYY-MM-DD"` string returns `422`), `nationality` as an **array** of ISO-3166 codes and `personal_identification[]` as `{ "issuing_country", "number" }` without a `type` field. Guide and spec examples updated with the live-verified shapes. ### v1.87 **Added** * **QR Crypto POS — amount-bearing crypto QR charges for processors** ([guide](/en/guides/qr-pos)): company accounts operating physical POS terminals register their merchants as verified merchants (approved third-party KYB/KYC) and generate crypto charges (USDT, USDC, BTC) with an exclusive address and QR per sale. Early payment detection for the POS (`confirming` within seconds), credit with automatic conversion to the settlement asset, per-merchant attribution on charges/webhooks, a reconciliation summary (`GET /v1/pos/summary`) with the informative per-merchant commission and the net to distribute, and refunds over the crypto withdrawal rail with a hard cap (never more than received). New routes under `/v1/pos/*` (QR Crypto POS tag in the API Reference); partial payments accumulate and late payments into an expired charge are still credited. ### v1.86 **Fixed** * **Bitcoin crypto QR**: the checkout QR now carries the raw bech32 address (same as TRON/ETH). Exchange apps like Binance rejected the BIP-21 URI (`bitcoin:…?amount=…`) as "invalid QR"; the exact amount remains shown next to it with a copy button. * **Checkout page**: white-label favicon (org symbol) and panel copy no longer splits Spanish words mid-word ("momento" → "moment"/"o") — the aggressive `word-break` now applies only to monospace addresses. ### v1.85 **Added** * **Dedicated CLABE per checkout link (Mexico)**: materializing `bank_transfer` MX on a universal checkout link now issues (or takes from a recyclable pool) a CLABE **exclusive to that link**. The payer transfers the exact amount **with no reference**: the deposit is detected and routed to the link automatically by destination account. The materialization payload carries `destination` with `dedicated: true`; if the dedicated account cannot be issued, it degrades to the classic path (merchant account + mandatory `reference` in the transfer description). CLABEs are recycled with a cooldown once the link resolves (paid or expired). ### v1.84 **Added** * **Pull collections on the universal checkout link (Venezuela)**: the checkout page now offers methods that charge the payer's account directly (`c2p` and `debito_inmediato` in VE). The payer fills in bank, document, phone or account and the OTP on the same page; the amount is always the one frozen at quote time. New public endpoints: `POST /pay/{token}/collect/otp` (requests the key when the rail sends it on demand) and `POST /pay/{token}/collect` (runs the charge; if the rail confirms synchronously the link is settled in the same call). These methods arrive with `collect: true` in the `GET /pay/{token}/quote` catalog. * **Multi-currency fiat per country**: each country in the quote lists its corridors in `options[]` — one row per method+currency (e.g. Bolivia with QR in BOB **and** USD) with its `local_amount` in `country_quote`. Materializing a method offered in several currencies requires `¤cy=YYY` (the `400 currency_required` error now applies to any method, not just cards). * **Destination account on bank transfers**: when the corridor uses a dedicated deposit account (the CLABE in Mexico), the `bank_transfer` materialization includes `destination` (type, account number and beneficiary) besides the reference — the payer knows where to send the transfer without leaving the page. **Changed** * **SVG flags on the payment page**: country and currency flags are now SVG images (consistent across Windows, macOS and mobile; some systems used to render the country code as plain text). On the Card tab the flag is derived from the **charge currency** (USD → United States flag, even when the acquirer sits in another country). **Fixed** * The checkout page no longer replies `429 too_many_attempts` just for being open: the read, materialization, OTP and collect traffic limits are now independent from each other. ### v1.83 **Added** * **Card tab on the universal payment link**: card payments move out of the Fiat tab into their own tab, listed **by charge currency** (today BOB and USD; currencies from future acquirers show up on their own). `GET /pay/{token}/quote` returns the new `cards[]` catalog (country, currency and `local_amount` per option) and `countries[]` no longer lists `card` among the methods. Materializing a card requires the currency: `POST /pay/{token}/methods/card?country=XX¤cy=YYY` — without it the new `400 currency_required` error is returned. Each currency is an independent materialization with its own hosted payment page. **Changed** * **Payment page with a stronger visual identity**: asset logos (USDT, USDC, BTC, GOLD) next to the amount and on the crypto groups, country flags on the Fiat selector and the card rows, per-method icons, and a **prominent expiry timer** (clock pill; under 1 hour it shows a countdown and under 10 minutes it turns red). **Fixed** * The checkout page no longer scrolls by itself to the active panel every few seconds: the automatic refresh re-renders only when the data changed and never moves the scroll (only manually selecting a method brings the detail into view). ### v1.82 **Changed** * **Universal checkout payment page redesigned**: the options are now organized into three tabs — **CBPay** (merchant QR + alias, with a copy button), **Crypto** (coins grouped by network; new networks show up on their own once enabled) and **Fiat** (country selector + methods with the quoted local amount). Copy buttons on the alias, addresses, amounts and references. No API changes: the URL, the creation contract and the public endpoints (`/state`, `/quote`, `/methods/{method}`) are the same. ### v1.81 **Fixed** * **QR payout — validation before the payout is created**: a `POST /v1/payouts/qr/scan` with an unreadable or dynamic QR now answers `400 invalid_qr_payload` with the concrete reason (it used to return a generic `502`). On the Brazilian confirm, an amount that does not match a fixed-amount PIX QR answers `422` with the payout `failed` and the **refund already applied** — and the QR stays intact so you can retry with the right amount and a new key. * **Static PIX QR is reusable**: the "one QR = one payment" guard no longer applies to Brazilian static PIX QRs (they are paid many times by design); the per-payment protection is your `idempotency_key`, which is mandatory on every Brazilian confirm. ### v1.80 **Added** * **PIX QR payout in Brazil (BR/BRL)**: the two-step flow `POST /v1/payouts/qr/scan` → `POST /v1/payouts/qr/confirm` now accepts **static** Brazilian PIX QRs (including the "copia e cola" code) — send `country: "BR"` and `currency: "BRL"`. The scan decodes the BR Code locally (free of charge) and returns the merchant name, PIX key and amount; the confirm pays through PIX with the same pricing as a regular payout. `amount` is always required: fixed-amount QRs demand an exact match (a mismatch answers `422` with the payout `failed` and an automatic refund — the QR is **not** burned). A static PIX QR is **reusable**: each payment carries its own `idempotency_key`. Dynamic or corrupt QRs answer `400 invalid_qr_payload` — use the `pix` method with the beneficiary's key. Available in the test environment with sample QRs and magic values (`.99` amounts fail) — see [Environment & testing](/en/environment-testing#sample-pix-qrs). Details in the [payouts guide](/en/guides/qr-payout). ### v1.79 **Changed** * **Universal checkout link v2 — multi-country + settlement in the asset you choose** (**Breaking** over yesterday's v1 shape): the charge is now denominated in any of your 4 virtual balances via `settlement_asset` (`USDT` default, `USDC`, `BTC`, `GOLD`) with `amount` IN that asset ("50" USDT, "0.001" BTC, "2" g of gold); sending `currency` returns `400` (existing v1 links keep working). The payer sees **every country with a live pay-in corridor** (pick a country → its methods with the local amount quoted and frozen at materialization), the 4 crypto options with a **scannable QR** (`qr_payload` + `qr_png_base64`; BIP-21 for BTC, raw address for TRON/ETH tokens — readable by Trust Wallet, MetaMask, Binance and external wallets), and the merchant's **CBPay QR + alias** to pay instantly from the app (deeplink `cbpay:pay?to=…&checkout=…`; `POST /v1/transfers` accepts `checkout_token` and validates the due server-side). Every payment **auto-converts** into the `settlement_asset` on credit (same asset skips conversion); `conversion_status` is visible on `/state`. New public endpoint `GET {checkout_url}/quote` with the country catalog, crypto dues and CBPay dues. New errors `country_required`, `country_unavailable`, `settlement_asset_disabled` and `checkout_amount_mismatch`. Details in the [payins guide](/en/guides/checkout). ### v1.78 **Added** * **Rejection detail on failed active collections**: when an active collection (`collect`, C2P or immediate debit) ends up `failed`, the payin now includes a `failure` object with where the rejection originated (`provider` = the payer's bank, `core` = pre-charge validation), plus the concrete code and message — visible in the synchronous `POST` response, in `GET /v1/payins/{id}` and in the webhook. Previously only the generic `failed` status was exposed. See the [payins guide](/en/guides/payins). ### v1.77 **Added** * **Universal checkout link (`checkout`)**: `POST /v1/payins` accepts `method: "checkout"` and returns `checkout_url` — a branded public page where the payer picks how to pay: QR, card, bank transfer or **crypto** (USDT on TRON, USDT/USDC on Ethereum and BTC) with a deposit address exclusive to that charge and accumulation of partial payments. One link \= one charge: the first method that completes the payment wins. State queryable without auth at `GET {checkout_url}/state`; the `payin_credited` of a crypto payment adds `settled_via` and `crypto_amount`. Supports `success_url`, `failure_url`, `expires_in` (10 minutes to 7 days) and idempotency (a retry returns the same link). New errors `already_paid`, `checkout_expired` and `method_unavailable`. Details in the [payins guide](/en/guides/checkout). ### v1.76 **Changed** * **Pre-capture authentication filter on card payments**: card charges are only sent to the processor when the 3-D Secure verification ended with a successful or attempted authentication and complete authentication data; an attempt without real authentication is rejected before any funds move and the payer can retry. The payment page also extended device data collection (\~11 s) to improve issuer approval rates. In the test environment, an amount ending in `.44` simulates an attempt rejected by this filter (full table in [Test environment](/en/environment-testing)). ### v1.75 **Added** * **Card payments (`card`) on payins**: `POST /v1/payins` accepts `method: "card"` (Bolivia, BOB or USD) and returns `payment_url` — a hosted payment page with your organization's branding where the payer enters their card in secure fields and completes their bank's 3-D Secure verification. Optional fields `customer`, `success_url`, `failure_url` and `expires_at`. A confirmed payment arrives via the `payin_received` webhook and credits the balance like any payin; if nobody pays, `payin_expired` closes the charge. Details in the [payins guide](/en/guides/payins). ### v1.74 **Added** * **`account_id` on swaps and address screenings**: the responses of `POST/GET /v1/swaps` and `POST/GET /v1/screenings/addresses` now include `account_id` (the account that owns the operation). Informational for single-account integrations; admin views use it to attribute each record. ### v1.73 **Added** * **Bitcoin on-chain (`btc`/`btc`)**: fourth supported network of the crypto product. Every account is now born with **four deposit wallets** (Bitcoin joins, bech32 address `bc1q…`); BTC deposits credit the BTC balance (\~30 min confirmation, 3 blocks) and on-chain withdrawals accept `chain: "btc"` (bech32, taproot and legacy destinations; the network fee is covered by the operation — the recipient receives the exact amount). [Segregated wallets](/en/guides/segregated-wallets) also support the `btc`/`btc` pair (no gas: the fee comes out of the wallet's balance). Travel Rule applies as on the other networks, valuing the amount in USD. Details in the [crypto guide](/en/guides/crypto). ### v1.72 **Changed** * **Two-step login also honors the phone binding cooldown**: with login 2FA over SMS/WhatsApp and a recently linked, unverified number, the login code is issued over a stronger factor (authenticator app, then login email) instead of the phone — the effective `channel` comes back in the login response. Without an alternative factor the login responds `403 phone_binding_cooldown` until the cooldown expires. The code is never sent to a number linked from the session itself. Details in the [security and 2FA guide](/en/security-2fa). ### v1.71 **Changed** * **OTP challenges with the phone in cooldown fall back to a stronger factor**: with a recently linked number (24 h cooldown), `POST /v1/otp/challenges` no longer blocks if you have the authenticator app enrolled or a verified email — the challenge is issued automatically over that channel (hierarchy totp > email) and the response reports the effective channel. The `403 phone_binding_cooldown` remains only for accounts with no alternative factor. Previously the cooldown blocked every 2FA relaxation (even disabling the email channel) even when stronger factors were available. Details in the [security and 2FA guide](/en/security-2fa). ### v1.70 **Added** * **Verified identity as the profile's source of truth**: when your KYC/KYB onboarding is approved, `display_name` (person = first + last name; company = legal name), `tax_id` and `country` are backfilled automatically from the verified identity. Documented in the [KYC guide](/en/guides/kyc) and the [profile guide](/en/guides/profile). **Changed** * **`PATCH /v1/me` locks identity fields once verified**: with `kyc_status: approved`, changing `display_name`, `tax_id` or `country` answers `409 identity_locked` (new code on the [errors](/en/errors) page). `phone` stays editable with its own verification flow. ### v1.69 **Added** * **AML screening PDF report**: `GET /v1/aml/screenings/{screeningID}/report` downloads any screening in your history as an executive PDF report with your branding — a cover page with the decision and its risk traffic light, indicators (sanctions, watchlists, PEP, adverse media...), consolidated matches, aliases, a glossary and a final backing section with the international data sources consulted. Trilingual via `lang=en|es|zh` (default English). Pure read, no fee. New section in the [AML guide](/en/guides/aml#screening-pdf-report). * **New error code `invalid_language`** (HTTP 400): the PDF report `lang` is not `en`, `es` or `zh`. Documented on the [errors](/en/errors) page. **Fixed** * **Company fields in the AML screening**: examples and the spec documented `tax_id`/`registration_number`/`country_of_incorporation` as flat fields of `customer.company`, but the screening engine rejects them with `422`. The identifier goes in `registration_authority_identification`, the country in `place_of_registration` and `incorporation_date` is a `{year, month, day}` object. Guide and spec corrected (verified in production). ### v1.68 **Added** * **New corridor: Ecuador (USD)** with four payout methods — `bank_transfer`, `deuna` (DeUna wallet), `cash_pickup` (over-the-counter withdrawal, no account needed) and `cnb` (non-bank correspondent). The beneficiary accepts structured names (`given_name`/`first_surname`/...) or automatic splitting from `name`, plus an optional sender block (`sender_name` or its structured fields). Per-method examples in the [payouts guide](/en/guides/payouts) and the spec. * **New error code `channel_unavailable`** (HTTP 503): the corridor's payment channel is temporarily unavailable. Retry later with the same `idempotency_key`. Documented on the [errors](/en/errors) page. ### v1.67 **Added** * **Test accounts are born populated**: every new account in the test environment starts with \~6 months of realistic demo history across all products (payouts, payins, transfers, crypto, swaps, cards, banking, contacts...), with play balances, a reconciled statement and analytics ready to explore. Applies to every creation path (registration, social login, admin creation and the dashboard's test/live switch). **Changed** * **Fully independent environments**: test data is no longer refreshed from a production snapshot — nothing is copied between environments. Updated [environments and testing](/en/environment-testing) guide. ### v1.66 **Added** * **Official MCP server** at `https://mcp.cbpayapp.com`: connect your AI editor or assistant (Cursor, VS Code, Claude, ChatGPT and any MCP client) to this documentation — search, endpoints with real examples and the error catalog, without leaving your editor. Read-only, no authentication. New [MCP Server](/en/mcp) page with one-click install and per-client setup. ### v1.65 **Changed** * **Test environment**: new accounts are now born with `kyc_status: approved` — you can exercise every product immediately, with no onboarding gate. This applies to every creation path (register, social login, admin creation and the dashboard test/live switch); existing test accounts were approved retroactively. **Live** is unchanged: accounts are born unverified and KYC/KYB remains mandatory before money can leave. To test the verification flow in test mode, use third-party KYC/KYB verifications. ### v1.64 **Changed** * `PUT /v1/otp/preferences`: enabling 2FA for the `login` action over a phone channel (`sms`/`whatsapp`) now requires the account phone number already **verified** (complete any SMS/WhatsApp OTP challenge first). If the number is not verified the API responds `409 phone_verification_required`. This safeguard prevents a mistyped number from locking you out of your account when enabling login 2FA. ### v1.63 **Fixed** * `POST /v1/me/passkeys/register/begin` and `DELETE /v1/me/passkeys/{passkeyID}` now accept a request without a body, as the spec documents (optional body). They previously returned `400 invalid_json`. The current password is still required for accounts that have one (`403 invalid_password` if missing or wrong); social-login-only accounts pass with their session. ### v1.62 **Fixed** * `POST /v1/me/totp/enroll` now accepts a request without a body, as the spec documents (optional body). It previously returned `400 invalid_json`. The current password is still required for accounts that have one (`403 invalid_password` if missing or wrong); social-login-only accounts pass with their session. * `PUT /v1/otp/preferences` with channel `email` or `totp` returned a 500 error; fixed — all four channels (`sms`, `whatsapp`, `email`, `totp`) now save correctly. **Changed** * PDF statement: operation statuses are now color-coded (green completed, amber pending, red failed) for quick scanning. ### v1.61 **Added — CSV / Excel export on listings** * The `movements`, `payouts`, `payins` and `transfers` listings now accept the `format=csv` or `format=xlsx` parameter to download the rows as an accounting-ready file (up to 10,000 rows per download; the `from`/`to`, `status` and other filters apply the same). ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -o movements.xlsx "https://api.qbank.cl/platform/v1/movements?from=2026-07-01&to=2026-07-13&format=xlsx" \ -H "Authorization: Bearer pk_…" ``` * Without `format` the response is still the usual paginated JSON — no compatibility changes. ### v1.60 **Breaking — Segregated wallets move to `/v1/segregated-wallets`** * Every segregated-wallet route is renamed from `/v1/wallets*` to `/v1/segregated-wallets*`. Same methods, parameters, response shapes, fees and webhooks — only the path prefix changes. There is **no compatibility alias**: the old `/v1/wallets*` routes now respond `404`. * Mapping (the 15 routes follow the same pattern): | Before | Now | | ------------------------------------------------------------------- | ----------------------------------------------------------------------- | | `POST/GET /v1/wallets` | `POST/GET /v1/segregated-wallets` | | `POST /v1/wallets/import` | `POST /v1/segregated-wallets/import` | | `GET /v1/wallets/{id}` (+ `/balance`, `/deposits`, `/transactions`) | `GET /v1/segregated-wallets/{id}` (+ same subroutes) | | `POST/GET /v1/wallets/{id}/sends` (+ `/{sendID}`, `/receipt`) | `POST/GET /v1/segregated-wallets/{id}/sends` (+ same subroutes) | | `GET /v1/wallets/{id}/deposits/{depositID}/receipt` | `GET /v1/segregated-wallets/{id}/deposits/{depositID}/receipt` | | `POST /v1/wallets/{id}/export` · `GET/POST .../auto-forward` | `POST /v1/segregated-wallets/{id}/export` · `GET/POST .../auto-forward` | * The `receipt_url` in responses, webhooks and receipt emails of wallet sends/deposits now points to the new path. * Why: the generic `/v1/wallets` prefix was constantly confused with the **deposit wallets** of the [crypto](/en/guides/crypto) product. Those are untouched and keep living under `/v1/crypto/wallets`. **Added — `type` discriminator on every wallet response** * Deposit wallets (`/v1/crypto/wallets`) now include `type: "deposit"` and `receive_only: true`. * Segregated wallets include `type: "segregated"`. * Use it to tell the two products apart defensively — never by route alone. ### v1.59 **Added — `payin_expired` webhook: automatic closure of unpaid collections** * When an active charge (QR or hosted checkout) expires or fails without receiving the payment, the payin now moves automatically from `pending` to `expired` (or `failed`) — previously it could stay pending forever. * New **`payin_expired`** webhook event carrying the `payin_id`, the final status, the corridor and the reference, so you can close the collection on your side without polling. Subscribable via `POST /v1/webhooks/subscriptions`. * No funds move in any case: to retry the collection, create a new payin. ### v1.58 **Changed — Redesigned receipts, statement and emails** * Every PDF receipt (`GET .../receipt`) ships a banking-grade redesign: header with logo and receipt number, product icon, hero amount, two-column details, a "Verifiable document" strip with the QR code and an institutional footer. The brand symbol appears as a subtle watermark; non-final operations keep the status watermark. * The statement PDF (`GET /v1/reports/statement?format=pdf`) adds summary cards with icons, a verified-reconciliation badge and per-section icons; the Excel export keeps its structure. * Emails (receipts, verification codes and security notices) now share a branded template with your organization's institutional header and footer. * On fiat payout receipts the beneficiary bank is ALWAYS shown by name: if the operation was created with the catalog `bank_code`, it is resolved to the bank's display name automatically. * No API changes: same routes, same shapes. Only the documents and emails look different. ### v1.57 **Added — Refresh tokens for user sessions** * Every login (password, OTP, social, passkey, handoff and register) now returns, alongside the 24-hour `access_token`, a single-use **`refresh_token`** (`rt_…`) to renew the session without re-login: `POST /v1/auth/refresh` issues a fresh pair and rotates the token (30 days per rotation, absolute cap of 90 days from the original login). Details and security rules in [Authentication → Session renewal](/en/authentication#session-renewal-refresh-tokens). * **Strict rotation and theft detection**: exchanging revokes the device's previous access token; presenting an already-exchanged refresh token revokes the entire chain and records a `refresh_token_reuse` event in `GET /v1/me/security/events`. Signing out, revoking sessions or changing the password also invalidates refresh tokens. * New error code: `401 invalid_refresh_token`. API keys `pk_` are unchanged: they never expire and don't use refresh. ### v1.56 **Added — Test environment (sandbox) with simulated money** * New **test** environment at `https://cryptobank.qbank.cl/platform`: the same API, with every corridor served by a deterministic internal simulator — always available, no third-party dependency. Operations complete on their own within seconds and **magic values** (`.99`/`.77` amounts, `REJECT` beneficiary, OTP `000000`, etc.) force every other outcome. Full guide in [Environments and testing](/en/environment-testing). * **Per-environment API keys**: test issues and accepts only `pk_test_` keys; live only `pk_`. A key from the other environment returns `401` — impossible to cross environments by mistake. * Every response carries the **`CBPay-Environment`** header (`test` | `live`) and `GET /healthz` exposes `livemode`. * **One-click test/live switch**: `POST /v1/auth/environment-handoff` (live) issues a single-use 60-second token exchanged at `POST /v1/auth/handoff` (test) for a test-environment session, with automatic mirror-account provisioning. ### v1.55 **Added — AML screening audit history** * `GET /v1/aml/screenings` and `GET /v1/aml/screenings/{screeningID}` list and retrieve every AML screening (person, company and rescreen) stored locally for audit — subject, risk, fee and full result. * `POST /v1/aml/screenings` and `POST /v1/aml/rescreen` now require `idempotency_key` (the operations charge a fee). Replaying with the same key returns the original record with `idempotency_hit: true` and never double-charges. * `PATCH /v1/aml/monitoring` stores each enable/disable toggle in the same history (`kind: monitoring`); `idempotency_key` is required when the state changes (enable charges a fee). ### v1.54 **Added — Travel Rule on on-chain withdrawals (FATF R.16)** * Crypto withdrawals above the configured threshold (default 1,000 USD) now require declaring the beneficiary before moving funds: `wallet_type: "self_hosted"` + `beneficiary_name` for own wallets, or `travel_address` + `beneficiary_name` for destinations at another institution (the data exchange happens inline and the payment address is provided by the receiving institution). Below the threshold nothing changes. * The withdrawal response includes `travel_rule_status` (`not_required` / `self_hosted_attested` / `approved`). * New error codes: `travel_rule_required`, `travel_rule_beneficiary_required`, `travel_rule_address_mismatch`, `travel_rule_rejected`, `travel_rule_pending`, `travel_rule_incomplete_approval`, `travel_rule_unavailable`. Details in the [crypto guide](/en/guides/crypto) and the [errors page](/en/errors). ### v1.53 **Added — Banking series in the balance history** * `GET /v1/balances/history` now includes in `assets` the daily series of the banking accounts (`BANK_USD`, `BANK_EUR`), each in its own currency (2 decimals), ready to chart as one more filter next to USDT/USDC/BTC/GOLD. They remain outside the `total_usd` aggregate, which only covers the operational balances. [Analytics](/en/guides/analytics) guide updated. ### v1.52 **Added — Country filter on payouts and payins** * `GET /v1/payouts` and `GET /v1/payins` accept the `country` filter (ISO 3166-1 alpha-2, e.g. `?country=MX`), combinable with `status`, `from`/`to` and pagination. [Payouts](/en/guides/payouts) and [payins](/en/guides/payins) guides updated. * The `fees` block of `GET /v1/rates` now returns the **effective** fee configuration (organization defaults resolved against your account's overrides). Accounts without overrides used to see `fees: []` even when operations had a cost; use this block to quote the exact fee before creating an operation. ### v1.51 **Changed — Wallet limits per account type** * **Deposit wallets**: every account — person and company — holds exactly **one deposit wallet per supported pair** (`tron`/`usdt`, `eth`/`usdt`, `eth`/`usdc`), provisioned free of charge on registration. `POST /v1/crypto/wallets` now exists only to restore a missing pair; with the pair already provisioned it responds `422 wallet_limit_reached` for every account type (previously companies could create more). * **Segregated wallets**: now also available to person accounts, capped at **1 per network/asset pair** (the second attempt responds `422 wallet_limit_reached`). Company accounts remain unlimited. The `403 company_required` error no longer applies to segregated wallets. * [Crypto](/en/guides/crypto) and [segregated wallets](/en/guides/segregated-wallets) guides, the [persons and companies](/en/concepts/persons-companies) page and [errors](/en/errors) updated. ### v1.50 **Added — Continuous transaction monitoring (compliance controls)** * The platform now monitors every operation in real time with bank-grade compliance controls. For the vast majority of clients this is invisible: no flow changes and no perceptible latency. * New error codes documented in [errors](/en/errors): `403 compliance_hold` (operation held by compliance), `403 geo_restricted` (unsupported jurisdiction) and `503 compliance_check_unavailable` (check temporarily unavailable — the operation did not go out; retry with the same idempotency key). ### v1.49 **Added — Documentation in 3 languages (English by default)** * This documentation is now fully available in **English** (default language), **Spanish** and **Simplified Chinese**. Switch languages with the selector at the top of the site. * The API Reference is also available in all three languages (same endpoints and examples; only the descriptions change). * The Postman collection and the compiled Markdown guide stay up to date from any language of the site. ### v1.48 **Added — Wallet screening (AML risk for blockchain addresses)** * New product: `POST /v1/screenings/addresses` evaluates any blockchain address against global on-chain intelligence — sanctions, exposure to illicit funds — and returns a `Low`/`Medium`/`High`/`Severe` risk level with the full evidence. Fixed fee per scan (`address_screening`, with automatic refund on failure) and mandatory idempotency. History via `GET /v1/screenings/addresses` (+`/{id}`). * **Free automatic protection**: on-chain withdrawals evaluate the destination before signing (severe risk ⇒ rejected with a full refund) and incoming deposits evaluate the sender before crediting (severe ⇒ held for compliance review; high ⇒ credited with an alert). * New webhooks: `crypto_deposit_held` and `crypto_deposit_alert`. * New guide: [Wallet screening](/en/guides/screenings). ### v1.47 **Added — Public assets over CDN (avatars, branding, charge QR codes)** * **Avatars over CDN**: `avatar_url` (in `PUT /v1/me/avatar`, `GET /v1/resolve` and contacts) is now an **absolute public URL** that loads without authentication once the image is published to the CDN; `GET /v1/avatars/{accountID}` answers with a `302` redirect to that URL (legacy avatars are still served directly). * **Branding URLs**: `GET /v1/branding` adds `logo_url` and `symbol_url` — public CDN URLs for the logos, so the front end can theme itself without decoding base64 (the `*_png_base64` fields remain). * **Payin QR codes**: QR charges (`POST /v1/payins`, method `qr`) expose `qr_image_url`, the QR PNG published to the CDN, alongside the usual base64 `qr_image`. Perfect for a direct `` tag. Nothing breaks: every existing field is preserved; the URLs are additive. Guides: [Profile](/en/guides/profile) and [Payins](/en/guides/payins). ### v1.46 **Added — Compliance catalogs** * New `GET /v1/aml/catalogs`: every catalog you need to build compliance and verification forms (genders, legal entity forms per country, income/wealth sources, industry standards, ISO-3166 countries and subdivisions). This data was previously unavailable through the API. **Changed** * The `asset_prices` block of `GET /v1/rates` and `GET /v1/rates/history` no longer includes the internal `source` field; use `settlement_grade` and `updated_at` to know whether a price is executable and how fresh it is. Guide: [AML screening](/en/guides/aml). ### v1.45 **Added — Every account is born with its deposit wallets** * When an account is created (person or company), its three crypto deposit wallets are provisioned automatically and **free of charge**: `tron`/`usdt`, `eth`/`usdt` and `eth`/`usdc`. Right after registration, `GET /v1/crypto/wallets` already returns the three addresses (provisioning runs in the background; querying at the very second of registration may take a few moments). * `POST /v1/crypto/wallets` is now for **additional** wallets (companies); persons already hold each combination's slot since registration. Accounts created before this change were backfilled with any missing wallets. **Changed** * Public account registration is now rate limited per IP (`429 too_many_attempts`). Guide: [crypto](/en/guides/crypto). ### v1.44 **Added — Full traceability: banking in the statement, itemized fees and wallet custody** * **Richer statement**: new `card_transactions` (card purchases), `swaps` (balance conversions) and `banking_operations` sections. If you use Banking, your bank accounts reconcile as `BANK_USD`/`BANK_EUR` mirror balances inside the `assets` section. * **Itemized fees**: payouts, payins and crypto withdrawals now split the fee into `fee_percent` and `fee_fixed` (they add up exactly to `fee`); standalone charges carry `fee_model: "fixed"` and are labeled **Fixed Com** in the PDF/Excel. * **New receipts**: `GET /v1/banking/operations/{id}/receipt`, `GET /v1/wallets/{walletID}/sends/{sendID}/receipt` and `GET /v1/wallets/{walletID}/deposits/{depositID}/receipt`. The `banking_operation_status_changed` webhook now includes `receipt_url`. * **Segregated wallet custody**: `custody` field (`cbpay` | `client`) on every wallet; the platform syncs the complete on-chain activity and emits the `wallet_external_movement` webhook (movement signed outside, expected under `client` custody) and `wallet_key_compromise_suspected` (critical alarm). * **Analytics**: `sections.banking.volume` (money moved through your bank accounts, which also adds to `gross_volume`), `sections.verifications.fees_by_kind` (KYC vs KYB spending, separately), `sections.adjustments` and `deposits.wallet_fees_usd`. * **Guaranteed per-wallet accounting** (`cbpay` custody): lifetime reconciliation in the statement and `funding_sources` (deposit→send FIFO attribution) on each send's detail. `BANK_*` mirror balances also show up in `GET /v1/balances` with `custody: "banking"`. Guides: [statement](/en/guides/statement), [banking](/en/guides/banking), [segregated wallets](/en/guides/segregated-wallets), [receipts](/en/guides/receipts) and [analytics](/en/guides/analytics). ### v1.43 **Added — Historical series for your dashboard** * **`GET /v1/rates/history`**: the evolution of your account's FX rates (payout and payin side per point), with `day` or `hour` granularity and a signed `change_pct` per currency — ready for the rate chart with its "+3.4% / −3.0%" badge. Includes the USD reference series for BTC and GOLD. * **`GET /v1/balances/history`**: the daily evolution of your balances — one series per asset with each day's closing balance (no gaps), the aggregated USD series valued at each day's historical price, the period's inflows/outflows and the current snapshot — everything needed for the balance card with a chart. * Rate history starts with a \~90-day backfill of daily rates and is recorded continuously going forward. Full examples in [analytics](/en/guides/analytics). ### v1.42 **Added — PDF receipts with authenticity verification** * Every transactional product has its **branded PDF receipt**: `GET .../receipt` on payouts, payins, transfers, crypto withdrawals and deposits (new `deposit_id` in `GET /v1/crypto/transactions`), swaps and card purchases. Languages `?lang=es|en`. * **`receipt_url`** on every response of those products and on final-state webhooks: the front end never builds the URL by hand. * **Public authenticity verification**: every PDF carries a signed code with a QR that opens `GET /verify/receipts/{code}` (no credentials) — JSON for APIs and a branded web page for browsers, always showing the **real, current** status and amount, never the beneficiary's personal data. * Receipts of **non-completed** operations carry a diagonal watermark ("PROCESSING" / "FAILED"): an in-flight PDF can never pass as proof of payment. * **Automatic email** with the PDF attached when the operation reaches a final state, with per-account opt-out (`PATCH /v1/me` with `receipt_emails: false`). **Added — Branding** * `GET /v1/branding`: the platform's effective branding (logo, colors, name) so a white-label front end can theme itself from the API. **Changed** * The PDF [statement](/en/guides/statement) now renders with the brand's **real logo** and the Inter typeface (previously a typographic wordmark), and the Excel includes the logo on the summary sheet. Full guide in [receipts](/en/guides/receipts). ### v1.41 **Added — Segregated wallets (company accounts only)** * On-chain wallets with **their own balance** (outside the ledger): create (`POST /v1/wallets`), list and get, **import** an external wallet with its key (`POST /v1/wallets/import`), **export** the private key (`POST /v1/wallets/{id}/export`, shared custody) and **send** crypto directly from the wallet (`POST /v1/wallets/{id}/sends`). * Live on-chain queries: `GET .../balance` (includes gas), `.../deposits` and `.../transactions`; configurable **auto-forward** (`GET`/`POST .../auto-forward`). * Send **gas** is on the client: without gas the send returns `422 insufficient_gas`. Import and export require a signed-in user session with 2FA. * New fees: `wallet_import`, `wallet_export`, `wallet_send`. * New webhooks: `wallet_deposit_received`, `wallet_send_status_changed`, `wallet_key_exported`. New service flag: `wallets`. * The [statement](/en/guides/statement) and [dashboard](/en/guides/analytics) include a segregated wallets section. Full guide at [segregated wallets](/en/guides/segregated-wallets). ### v1.40 **Added — Account profile, credentials and security** * **Password**: self-service change (`POST /v1/me/password`, revokes all other sessions) and code-based recovery (`POST /v1/auth/password/forgot` → `POST /v1/auth/password/reset`) via email or verified phone. Forgot always returns 200 (never reveals whether the account exists). * **Login email**: verified change (`POST /v1/me/email/change` → `confirm` with the code sent to the **new** email) and verification of the current one (`POST /v1/me/email/verify`). * **Permanent alias** (`PUT /v1/me/alias`) and **profile QR** (`GET /v1/me/qr`): identify your account to **receive** transfers. Transfers accept `to_alias` and `to_qr_token`; `GET /v1/resolve` previews the recipient before sending. * **Profile photo**: `PUT`/`DELETE /v1/me/avatar` and `GET /v1/avatars/{id}`. * **Self-service 2FA** (`GET`/`PUT /v1/otp/preferences`): enable and pick the channel per action — now also **email** and **authenticator app (TOTP)** in addition to SMS/WhatsApp. Harden freely; weakening requires verification. * **Authenticator app (TOTP)**: `POST /v1/me/totp/enroll` (QR) → `confirm` (returns 10 one-time backup codes), `DELETE`, and `POST /v1/me/totp/recovery-codes` to regenerate them. * **Passkeys (WebAuthn)**: passwordless sign-in with the device's biometrics (Face ID, Touch ID, Windows Hello, security keys). Registration (`/v1/me/passkeys/register/begin|finish`), management (`GET`, `DELETE`) and login (`/v1/auth/passkey/login/begin|finish`). * **Sessions and activity**: `GET /v1/me/sessions` + revoke one or all, and `GET /v1/me/security/events` (account security history). * Email alerts on sensitive events (password or email change, factor added/removed). ### v1.39 **Added — Reusable verified identity (unified KYC/KYB)** * A customer's approved KYC/KYB verification becomes their **single identity** inside CBPay: their data and documents are reused across the other products without re-typing or re-uploading. Guide: [reusable identity](/en/guides/kyc#one-verification-for-everything-reusable-identity). * **Cards**: your account's first issuance auto-fills the cardholder's identity and documents **from your approved verification** — you only send `occupation` and `salary_usd`. Explicit fields still win. * **Compliance report (KYB)**: `GET /v1/kyb/submissions/{id}/report` downloads the verification's signed compliance report (PDF). **Changed — Breaking** * **`POST /v1/banking/third-parties`** now requires the `verification_id` of an **approved** verification of the third party. The `type` comes from the kind (KYC ⇒ INDIVIDUAL, KYB ⇒ COMPANY), identity auto-fills and the already-validated documents are re-delivered to the banking provider (`documents_synced`). Existing third parties keep operating. * **`POST /v1/cards`** for designated persons (company accounts) now requires `cardholder.verification_id` of that person's **approved** KYC; their identity and documents come from the verification. * New errors: `422 verification_required`, `422 verification_not_approved`, `422 verification_kind_mismatch`, `422 verification_invalid`. ### v1.38 **Added — Account summary (analytics) + third-party banking users** * **`GET /v1/analytics/summary`**: in a single call, every series and statistic of your account to build your dashboard — gross volume (in/out), transactions and new users per period (day/week/month, with comparison vs the previous period), global per-country view, and a section for EVERY service (payouts, payins, deposits, withdrawals, transfers, swaps, cards, banking, KYC/KYB, AML, contacts) with their dimensions (country, currency, method, status, chain, merchant). Plus `spending` (what you consumed in fees per service) and USD-valued `balances`. New guide: [Your account summary](/en/guides/analytics). * **Third-party banking users (companies only)**: `POST/GET /v1/banking/third-parties` (+documents, submit, accounts, balance) to register your end clients as separate banking users, with their own identity/KYC and accounts in their name. Isolated per account. * **New limit**: person accounts can hold at most 1 bank account (`409 banking_account_limit`). ### v1.37 **Changed — Bolivia and Venezuela rates** * The USD→BOB and USD→VES rates in `GET /v1/rates` now reflect the market we actually operate your payments with (previously a reference rate was published that did not match the applied value). * If one of those rates is temporarily unavailable, the country is omitted from `GET /v1/rates` and operations in that currency return `422 currency_not_supported` until it is back — we never quote with an incorrect rate. We recommend checking `GET /v1/rates` (or subscribing to the rates webhook) before quoting payments in `BOB` or `VES`. ### v1.36 **Added — Swaps: convert between your balances** * New `swaps` product: convert between `USDT`, `USDC`, `BTC` and `GOLD` instantly, without the money leaving your account — any pair, including direct `BTC` ↔ `GOLD`. `POST /v1/swaps` (synchronous, with `idempotency_key`), `GET /v1/swaps/quote` (free indicative quote) and `GET /v1/swaps` (+`/{id}`) for history. * The quoted rate is your account's execution rate: quoted = received, no separate fees. Live BTC/GOLD prices (if the price is not fresh the swap is rejected with `503 pricing_unavailable`). * Conversions touching BTC/GOLD share the per-operation and 24h volume limits with payouts and card purchases (`GET /v1/settlement`). New guide: [Swaps](/en/guides/swaps). ### v1.35 **Added — Contacts and sending by phone number** * **Contact book** (`/v1/contacts`): full CRUD with search and favorites. Every send (transfer, payout, crypto withdrawal) **saves its destination as a contact automatically** — deduplicated; opt out with `"save_contact": false`. * **Phone address book import** (`POST /v1/contacts/import`, up to 1,000 per request): normalizes phones to E.164 and tells you which contacts **already have CBPay** (`has_cbpay`, matching only within your operator). * **Transfer by phone**: `POST /v1/transfers` accepts `to_phone` (only accounts with an **OTP-verified** phone; ambiguity answers `422 recipient_ambiguous`) and `to_contact_id`. * **Quick send to contacts**: `beneficiary_contact_id` on payouts (uses the contact's saved beneficiary) and `to_contact_id` on crypto withdrawals (uses its saved address). New guide: [Contacts](/en/guides/contacts). ### v1.34 **Added — KYC/KYB identity verification (hosted wizard, OCR documents and video liveness)** * **Mandatory onboarding**: every new account must approve its identity verification (person ⇒ KYC, company ⇒ KYB) before operating. Until then it can only **fund** (payins, crypto deposits, incoming transfers) and read; everything else answers `403 verification_required`. Request your link with `POST /v1/me/verification/link` and check your state with `GET /v1/me/verification` — approval updates your `kyc_status` automatically. Existing accounts were grandfathered as approved. * **Third-party verification (company accounts only)**: generate hosted links (`POST /v1/kyc/links`, `POST /v1/kyb/links`) or send data through the API (`POST /v1/{kyc,kyb}/submissions`), upload documents with presign + OCR and close the liveness check with liveness links. New fixed fees `kyc_verification` / `kyb_verification` billed at creation (mandatory `idempotency_key`, automatic refund on failure). * 7 new webhooks: `kyc/kyb_verification_status_changed`, `kyc/kyb_link_completed`, `kyc/kyb_document_validated`, `kyc_liveness_completed`. Full guide at [KYC and KYB verification](/en/guides/kyc). **Changed (BREAKING) — Screening becomes AML** * `POST /v1/kyc`, `POST /v1/kyc/rescreen` and `PATCH /v1/kyc/monitoring` were **removed**: list screening now lives at `POST /v1/aml/screenings`, `POST /v1/aml/rescreen` and `PATCH /v1/aml/monitoring` (same semantics, same `compliance_*` fees). The `no_kyc` error becomes `no_screening` and screening no longer touches your `kyc_status`. New `aml_screening_updated` webhook and new `aml` service flag (the `kyc` flag now gates identity verification). Guide: [AML screening](/en/guides/aml). ### v1.33 **Fixed — Banks catalog without `method` in countries with several methods** * `GET /v1/payouts/banks?country=VE` answered `400` asking for `method`, and `?country=BO` answered `400 payout_corridor_unsupported`. The catalog **without `method` now returns the union of every payout method's banks for the country** (deduplicated by code), as this documentation promises; passing `method` scopes it to a single channel (parameter now documented in the reference). ### v1.32 **Added — Card purchases from BTC and GOLD (at-the-moment conversion)** * `spending_asset` now also accepts **BTC and GOLD**: purchases convert at the **effective price of the moment of each event** (the same one in the `settlement` block of `GET /v1/rates`). * **Authorization**: the equivalent is reserved plus a small cushion (not a charge; returned at settlement). If the execution price is unavailable, the purchase is declined with `pricing_unavailable` — your balance is never converted with an untrustworthy price. * **Settlement**: the final amount is re-quoted at the capture moment's price and the cushion's excess returns automatically. **Reversal** of an authorization: exact amount returned, no conversion. **Refunds/adjustments** after capture: re-converted at the price of the event's moment (your balance takes the price variation). * BTC/GOLD purchases share the account's volatile-asset limits with payouts: per operation (`settlement_limit_exceeded`) and 24h volume (`settlement_daily_limit_exceeded`). ### v1.31 **Added — Choose which balance your cards spend from (USDT or USDC)** * Each card now has a **spending asset** (`spending_asset`): its purchases debit the account's USDT or USDC balance, 1:1 with the USD and with no conversion fee. USDT by default (identical to the historical behavior). * Set it when creating the card (`spending_asset` in `POST /v1/cards`) or change it any time with `PATCH /v1/cards/{cardID}`. The change only applies to future purchases: in-flight authorizations keep (and refund to) the asset they debited. * Card transactions now expose `spend_asset` and `spend_amount` (the balance and amount actually debited); `amount_usd` / `amount_usdt` remain the USD reference value. Per-card limits are still measured in USD. * New errors: `400 spending_asset_unavailable` (BTC/GOLD are not available for card purchases) and `spending_asset_disabled` authorization declines if your operator disables the asset. ### v1.30 **Changed — Multi-asset settlement hardening** * Payments from BTC/GOLD now have, on top of the per-operation limit, a **rolling 24h per-account volume cap** (`422 settlement_daily_limit_exceeded`). It shows in `GET /v1/settlement` as `volatile_daily_limit_usdt`. * **Card** fees (issuance, cancellation and the monthly fee) are now also debited from your default settlement balance, like every other service. Card **purchases** still settle in USDT. ### v1.29 **Added — Pay payouts and services from any balance (multi-asset settlement)** * Payouts and service fees (KYC, wallet creation, banking) can now be debited from **any of your four balances** (USDT, USDC, BTC, GOLD). Pricing is still quoted in USDT; the total translates to the chosen asset at the effective settlement price of the moment. Details in the [money model](/en/concepts/money-model#choose-which-balance-pays). * New `GET/PUT /v1/settlement`: set your account's **default balance** (`default_settlement_asset`). Per-operation override with `settlement_asset` in `POST /v1/payouts` and in the QR confirm. * The payout response now records `settlement_asset`, `settlement_amount` (the exact amount debited — also the amount refunded on failure, never re-quoted) and `settlement_rate`. * `GET /v1/rates` adds a `settlement` block with the effective price per enabled asset, and `asset_prices` now carries `source`, `updated_at` and `settlement_grade` (whether the price is fit to execute). * New errors: `503 pricing_unavailable` (BTC/GOLD execution price unavailable), `400 settlement_asset_disabled`, `400 invalid_settlement_asset` and `422 settlement_limit_exceeded` (per-operation limit for volatile assets). ### v1.28 **Changed — Short reference for announced bank transfers** * `POST /v1/payins` with `method: "bank_transfer"` now returns a **short 12-character alphanumeric `reference`** (e.g. `CBW4N8R2T6P9`) instead of the UUID: bank concept fields have hard limits (Paraguay/SIPAP caps them at 20 characters with no special characters) and the UUID never fit. * Automatic matching accepts the new reference **and** keeps accepting the UUID from old announcements — existing `pending` payins are unaffected. The amount+currency fallback is unchanged. * `GET /v1/payins` and the detail expose the announce reference in `reference` while the payin is `pending`. ### v1.27 **Added — Payins in Paraguay (announced bank transfer)** * New collection corridor `PY`/`PYG`/`bank_transfer`: announce the deposit with `POST /v1/payins`, your payer transfers (SIPAP or an internal transfer at the receiving bank) with the `reference` in the concept, and the credit arrives automatically in USDT at your `payin_rate`, like in every country. Guide in [payins](/en/guides/payins). * Guaraníes use no decimals: announce the **exact integer amount** (e.g. `"596000"`). The amount+currency fallback match applies as usual. * The corridor shows up in `GET /v1/payins/methods` with `delivery: polling`. ### v1.26 **Added — Multi-currency virtual balances (USDT, USDC, BTC, GOLD)** * Every account now holds **four independent virtual balances**: `USDT` (the operating currency), `USDC`, `BTC` (8 decimals, satoshis) and `GOLD` (grams of fine gold, 6 decimals, custodian-backed). They never mix and are never converted automatically. Details in [money model](/en/concepts/money-model). * **`GET /v1/balances`** always returns all four balances (zeros if you have not used that currency) and `GET /v1/movements` filters by currency with `?asset=`. * **Multi-currency internal transfers**: `POST /v1/transfers` accepts `asset` (`USDT` default, `USDC`, `BTC`, `GOLD`) — always between balances of the **same currency**, with no conversion and no fee. * **On-chain USDC**: create `eth`/`usdc` wallets, deposit and withdraw USDC over Ethereum. Every deposit credits its own asset's balance. Guide in [crypto](/en/guides/crypto). * **Reference prices**: `GET /v1/rates` includes `asset_prices` with each currency's USD reference price (BTC per unit, GOLD per gram) — for valuation only, no conversion and no spread. * **Multi-currency statement**: new `assets` section with each non-USDT balance reconciled independently (opening/inflows/outflows/closing and its own `balanced` flag), also in the PDF and Excel exports. * Payouts, payins, cards and service fees keep operating **exclusively against the USDT balance**. ### v1.25 **Added — Social login (Google, Apple, Microsoft, Meta)** * **Passwordless sign up and sign in** with Google, Apple, Microsoft and Facebook via token exchange: your front end gets the credential with the provider SDK and exchanges it at `POST /v1/auth/oauth` for the CBPay session. Full guide in [social login](/en/guides/social-login). * **New endpoints**: `POST /v1/auth/oauth` (unified login + registration), `GET /v1/auth/oauth/providers` (enabled providers, public), `GET/POST /v1/me/identities` and `DELETE /v1/me/identities/{provider}` (link/unlink providers from the session). * **Integrates 2FA**: if the account enforces OTP on login, social login also returns `otp_required` + `pending_token`. * **Multi-method**: one account can have a password and several providers; auto-linking by email only happens if the provider returns it verified. * New error codes in the [catalog](/en/errors): `invalid_provider`, `provider_not_configured`, `invalid_credential`, `email_conflict`, `identity_taken`, `last_login_method`. **Fixed** * The "Collection updated" stamp on the Postman page now correctly shows how long ago it was updated (it previously left an empty indicator). ### v1.24 **Added — OTP/2FA over SMS and WhatsApp** * **Two-step verification for sensitive actions**: your operator can require a one-time code (over SMS or WhatsApp) before login, payouts, crypto withdrawals, transfers, banking operations, revealing a card, issuing API keys, adding members or changing the phone. Full guide in [security and 2FA](/en/security-2fa). * **New endpoints**: `POST /v1/otp/challenges` (sends the code), `POST /v1/otp/challenges/{id}/verify` (returns the single-use `otp_token` for the `X-OTP-Token` header), `GET /v1/otp/challenges` (+ detail) and `GET /v1/otp/settings` (your effective policy). * **Two-step login**: with OTP active on `login`, `POST /v1/auth/login` returns `otp_required: true` + `pending_token`, and the session is issued at `POST /v1/auth/login/otp`. * **User sessions only**: `pk_` API keys are exempt — your server-to-server integrations do not change. * New error codes in the [catalog](/en/errors): `otp_required`, `otp_invalid`, `phone_required`, `phone_binding_cooldown`, `too_many_attempts` and more. ### v1.23 **Documentation — person vs company and unified guides** * **New [persons and companies](/en/concepts/persons-companies) page**: ALL the differences between the two account types (wallets, cards, members, KYC/KYB) in a single table, with the errors each limit produces. * **Cards guide reorganized by account type**: "Person account" and "Company account" tabs, each with its complete flow (first card, subsequent ones, and for companies both corporate and employee issuance) — no more assembling the flow from scattered notes. * **Country examples back in their guides**: the per-corridor requests/responses for payouts and payins live INSIDE each product's guide again (one page per product, no jumping to a separate reference). Old URLs redirect. * **Postman with live freshness**: the Postman page now shows how long ago the collection was updated (seconds/minutes/days), on top of the date and version. * The compiled MD now includes the full endpoint reference and the documentation version. ### v1.22 **Documentation — full site redesign** * **New navigation**: Getting started → Concepts → Integration flows → Products → Integration → Resources, with per-page icons and breadcrumbs. * **New pages**: [environment and testing](/en/environment-testing) (webhook tunnel for local dev + go-live checklist), [enabled services](/en/concepts/services), [statuses and lifecycle](/en/concepts/statuses) (including the failed payout `status_code` catalog), [movements and reconciliation](/en/concepts/movements-reconciliation) and [integration flows](/en/flows) with end-to-end diagrams. * **Payouts and payins split**: general guide + country reference with the real request and response of every corridor. * **Expanded guides**: quickstart closes the loop with webhooks; profile (`PATCH /v1/me`) and members with roles; complete idempotency endpoint table; webhook retry schedule; on-chain confirmation times; EUR banking accounts; banking errors in the catalog; FAQ with limits, cancellations and reconciliation. * **Cards: when to send `cardholder`, clarified.** The guide and spec now explain that an account's **first issuance** creates and verifies the holder (full data + mandatory documents) and that **subsequent** cards reuse it with no data — the minimal example previously implied data was never required. ### v1.21 **Added** * **`payin_rate` in `GET /v1/rates`**: each country now carries your two rates — `rate` for payouts (dispersals) and `payin_rate` for payins (fiat collections/deposits). Quoted = credited, always. **Changed** * **Payin pricing now works like payouts**: a payin's FX pricing lives in your `payin_rate` (the credit converts at exactly that rate) and the payin fee becomes a **fixed amount per operation** — no separate percentages. Each payin's `fx_rate` field records the rate applied. See [fees](/en/concepts/fees) and the [payins guide](/en/guides/payins). * Credit conversions round down to the micro-USDT (debits keep rounding up), with at most 1 micro-USDT of difference. **Documentation** * **KYC/KYB: full identity field reference.** The `customer` object has always accepted many more optional fields than the examples showed (date of birth, nationalities, documents with issuing country, aliases, residences, company registry data…) and sending them makes the screening more precise. The [KYC guide](/en/guides/kyc) now documents every field, with full-identity examples and the deduplication rule. ### v1.20 **Added** * **Card catalogs**: `GET /v1/cards/catalog/occupations` and `GET /v1/cards/catalog/business-activities` (searchable with `?q=`) to populate pickers. When designating a person, `occupation` must be a catalog **code**; for a company, so must `kind_of_business`. An out-of-catalog value is rejected with `400 invalid_occupation` / `400 invalid_kind_of_business` before reaching the issuer. See the [cards guide](/en/guides/cards). ### v1.19 **Added** * **`GET /v1/services`**: effective map of the services enabled for your account (`payouts`, `payins`, `transfers`, `crypto`, `banking`, `kyc`, `cards`) — use it to decide what to show in your UI. Services are enabled per account according to your commercial agreement; when one is off, its actions answer the new `403 service_disabled` error (reads and money in flight are never blocked). ### v1.18 **Added** * **Virtual and physical cards** that spend straight from the account's USDT balance, with no prefunding: every purchase is authorized in real time against the available balance and the card's limits. Persons: 1 virtual + 1 physical; companies: unlimited, for the company or for designated persons (e.g. employees). New endpoints `POST/GET /v1/cards`, `GET/PATCH /v1/cards/{id}` (limits and freeze/unfreeze), `POST /v1/cards/{id}/activate|cancel|reveal` and `GET /v1/cards/{id}/transactions`. See the [cards guide](/en/guides/cards). * **New billable services** (fixed, configurable, can be 0): `card_creation_virtual`, `card_creation_physical`, `card_monthly` (with no balance the card is frozen — no debt) and `card_cancellation`. * **New webhooks** `card_transaction` (authorized/annulled/adjusted) and `card_status_changed` (state changes, including automatic freezes). * **New ledger movement types**: `card_debit`, `card_refund`, `card_fee`, `card_fee_refund`. ### v1.17 **Added** * **Chile: hosted payment page (`method: "fintoc"`)** on `POST /v1/payins`. The response carries a `payment_url` the payer opens to transfer from **any Chilean bank or wallet** (Banco Estado, Santander, Mach, Tenpo, Mercado Pago, among others); the deposit is detected, validated and credited automatically in USDT with the usual `payin_credited` webhook. Supports an optional `idempotency_key`: a retry returns the same payin and the same URL without opening a second payment session. See the [payins guide](/en/guides/payins). ### v1.16 **Added** * **`from`/`to` date filters on every list endpoint**: `/v1/movements`, `/v1/payouts`, `/v1/payins` and `/v1/crypto/transactions` now accept `from`/`to` (YYYY-MM-DD, UTC, inclusive), on top of the usual pagination (`page`, `page_size` up to 200). Invalid dates return `400 invalid_range`. * **Query transfers**: `GET /v1/transfers` (list with pagination and date filters) and `GET /v1/transfers/{id}` — previously they could only be created. * **List webhook subscriptions**: `GET /v1/webhooks/subscriptions`. * **Idempotency on active collections**: `POST /v1/payins/collect` now requires `idempotency_key` (it executes a real charge; a retry never re-charges the payer). Same hardening for wallet creation (no double fee on retries) and admin adjustments. * Uniform pagination added to `members`, `crypto/wallets`, `deposit-accounts` and (admin) `orgs`. * **Account statement** (`GET /v1/reports/statement`): consolidates every movement of the period — payouts, payins, crypto, transfers and fees — into one auditable document with an exact accounting reconciliation (`opening + inflows − outflows = closing`, verified against the ledger). Three formats from the same endpoint: **JSON** for your web, **PDF** with CBPay branding and a multi-sheet **Excel** with numeric cells, filters and a movements sheet for auditors (`format=json|pdf|xlsx`, `lang=es|en`). The org admin can generate any of its accounts' statements. See the [guide](/en/guides/statement). ### v1.15 **Improved** * **Visual flow diagrams across the documentation**: the money map in the introduction (everything entering and leaving the USDT balance), the payout lifecycle with debit/hold/refund, the two-step QR flow, the four payin modes converging into the credit, crypto deposit and withdrawal, the full banking lifecycle, KYC states, webhook delivery and retries, and the idempotency decision rule ("which key do I retry with?"). ### v1.14 **Changed** * **New base URL: `https://api.qbank.cl/platform`** (previously `exchange.qbank.cl/platform`). The old domain keeps working as an alias, so no existing integration breaks — but use `api.qbank.cl` for everything new. All documentation, the spec and the Postman collection already point to the new URL. ### v1.13 **Added** * **Banking**: real bank accounts for your account — receive, hold and send money over international banking rails (SEPA, SWIFT, ACH depending on the currency). 14 new endpoints under `/v1/banking/*`: * Banking profile: create, fetch, upload documents and submit for verification. * Accounts: open per currency, list and check live balances. * Beneficiaries: register, list and attach destination accounts. * Payments: quote (`prepare`, free) and execute `TRANSFER`/`WITHDRAW` with idempotency. * New webhooks: `banking_customer_status_changed` and `banking_operation_status_changed`. * New fees (fixed, configurable, refunded if the operation fails): `banking_customer`, `banking_account`, `banking_operation` — the `banking_fee` field on each response shows what was charged. * Full [Banking guide](/en/guides/banking) with the end-to-end flow and examples for every operation. ### v1.12 **Improved** * **Fully localized API Reference**: titles, descriptions, fields and sidebar groups are now translated when browsing the documentation in Spanish (previously only the UI chrome switched languages). * Payouts guide reorganized: Brazil PIX now lives only under "Examples by country" (the duplicated section was removed); QR stays as the single separate flow section since it is a distinct flow (scan + confirm). * Webhooks: sample payload for **each of the 5 events**. * Quickstart: registration examples for both person **and** company. * **Postman collection expanded to 53 requests**: endpoints with several use cases now ship one request per case (a payout per country and method, payins per mode, person/company KYC, etc.), each with a ready-to-send body. * **Payins guide restructured by country**, matching payouts: a corridor matrix with each country's mode plus Chile / Peru / Mexico / Venezuela / Bolivia / Brazil tabs with their complete examples. * **New [FAQ page](/en/faq)**: sandbox, initial funding, pre-payout cost estimation, rate guarantees, arrival times, safe retries, deposits without a reference and more — day-one questions answered inside the docs. * Quickstart opens with the **key facts** table (base URL, auth header, slug, amount format, environment) and the `GET /v1/rates` response example with the cost-estimation formula. * Payouts: response examples for the methods and banks catalogs, plus a status table with the effect on your balance. Payins: catalog response example with the meaning of `delivery`. ### v1.11 **Improved** * **Complete per-use-case examples across the documentation**: * Payouts: an example for every country and method with its real `beneficiary` and response (Chile, Peru CCI + Yape, Mexico CLABE + debit card, Venezuela Pago Móvil + bank transfer, Bolivia ACH, Brazil PIX, Paraguay). * Payins: Bolivia and Brazil QR side by side, active collection `c2p` and `debito_inmediato` with the OTP response, dedicated deposit account. * KYC/KYB: person, company and minimal autofilled requests, with the screening, rescreening and monitoring (enable/disable) responses. * Transfers: by email, by `account_id`, company→person (payroll) and idempotent replay. * Crypto: person vs company wallet creation, and the `wallet_limit_reached` error. * API Reference: selectable named examples on every endpoint (10 payout corridors, 3 payin modes, person/company KYC…). ### v1.10 **Added** * **Brazil (BRL) with PIX** documented for payouts and payins: * `pix` payout by key (CPF/CNPJ, phone, email or `evp` random key) via `POST /v1/payouts`. * Payout to a PIX QR (static or "copia e cola") via the `qr/scan` + `qr/confirm` flow with `country: "BR"`. * Payin with a dynamic PIX QR via `POST /v1/payins` (`method: "qr"`, `country: "BR"`), carrying the QR image and the "copia e cola" code. * Payin by announced bank transfer (`method: "bank_transfer"`). Corridor enablement is gradual; the catalog (`GET /v1/payouts/methods`, `GET /v1/payins/methods`) reflects availability at any given time. ### v1.9 **Added** * **Every collection (payin) method now available through the API**: * `POST /v1/payins` now accepts `method`: `qr` (QR charge, as before) or `bank_transfer` (announce an incoming deposit and get the reference the transfer must include to be credited automatically). * `POST /v1/payins/collect` — active pull collection in corridors that support it (e.g. Venezuela `c2p` / `debito_inmediato`), with synchronous crediting; `POST /v1/payins/collect/otp` for the prior OTP when the method requires it. * `POST /v1/payins/deposit-accounts` — fixed dedicated deposit account (e.g. a Mexican CLABE) bound to your account: everything arriving to it is credited automatically. `GET /v1/payins/deposit-accounts` to list them. * Full corridor and method matrix for payouts in the guide (Chile, Peru with `yape`, Mexico SPEI, Venezuela with `pago_movil`, Bolivia with `qr`, Paraguay). * Venezuela (VES) joined the `GET /v1/rates` quotes. ### v1.8 **Added** * **Bolivia QR payout**: pay any Bolivian collection QR in two steps — `POST /v1/payouts/qr/scan` (free, returns the recipient's data) and `POST /v1/payouts/qr/confirm` (charged like a regular payout: your rate + fixed fee, with a synchronous final result and automatic refund on failure). * Bolivia (BOB) joined the `GET /v1/rates` quotes. ### v1.7 **Added** * New **[Postman](/en/postman)** page: official downloadable collection with all 25 endpoints, example bodies and pre-configured authentication. Regenerated with every API version. **Changed** * The Fees page and payout examples now reflect the current pricing model: payouts are charged **at your rate + a fixed fee per operation** (no separate percentage). Dispersing the equivalent of 100 USDT debits 100 USDT plus your configured fixed fee. ### v1.6 **Improved** * `GET /v1/rates` now returns **your account's own exchange rate** per country: the same rate your operations execute at (`local_amount / rate = USDT`), with no difference between what is quoted and what is charged. ### v1.5 **Removed (Breaking)** * `GET /v1/crypto/deposit-address` (the alias deprecated in v1.4) was removed for good. Use `POST /v1/crypto/wallets` to create wallets and `GET /v1/crypto/wallets` to list them. ### v1.4 **Added** * **Multiple wallets for companies**: company accounts can now create **unlimited wallets per network** (persons keep 1 per network). * New endpoints: `POST /v1/crypto/wallets` (create a wallet, with an optional `label` to tell them apart) and `GET /v1/crypto/wallets` (list my wallets). Every creation bills the fixed `wallet_creation` fee when configured. * New `422 wallet_limit_reached` error when a person tries to create a second wallet on the same network. * Wallet responses now include `wallet_id` and `label`. **Changed** * The Crypto guide was reorganized into: **create wallet, view my wallets, deposit, transfer and movements**. * `GET /v1/crypto/deposit-address` remains as a deprecated legacy alias: use the wallet endpoints instead. **Fixed** * Copy and translation polish in both languages; the movements table now includes the `wallet_creation_fee` and `wallet_creation_refund` entry types. ### v1.3 **Improved** * Professional-grade API Reference: all 25 endpoints now include request and response examples for **every case** (success, idempotency replay, and each possible error with its real body), ready to try from the docs playground. * The 5 webhooks are now documented inside the API Reference itself (standard OpenAPI Webhooks section), with schema and example payload for each event. * Methods and banks catalogs documented with the system's real response shapes. * `GET /healthz` endpoint documented (service status). * The Crypto guide adds **"Wallet balance and activity"**: how to check your balance, on-chain activity with `tx_id`, and the accounting history. * Brand-voice copy: the whole documentation now speaks as **CBPay** (it previously used generic wording like "your operator" or "the organization"). * Identity verification is now named **KYC/KYB** across the documentation (KYC for persons, KYB for companies). * Internal transfers: explicitly documented that they work between **any combination** of accounts (person↔person, person↔company, company↔company) and are **always free**. ### v1.2 **Added** * New `wallet_creation` fee service: the first creation of a deposit address on each chain may carry a fixed charge configured by CBPay (0 = free, the default). The `GET /v1/crypto/deposit-address` response now includes `creation_fee`, and the movements history adds the `wallet_creation_fee` and `wallet_creation_refund` entry types. Fetching an existing address remains always free; if creation fails, the charge is refunded automatically. * New **Changelog** page (this page) with the version history of the API and documentation. **Changed** * The Crypto guide now has an explicit **"Create your wallet"** section explaining per-network creation (201 on first call with `creation_fee`, free 200 afterwards), and the API Reference renames the endpoint to "Create or get my wallet (deposit address)". ### v1.1 **Added** * Per-operation compliance fees: `compliance_person`, `compliance_company`, `compliance_rescreen` and `compliance_monitoring` (fixed per-call charge; 0 = free). KYC responses now include `compliance_service` and `compliance_fee`. * `POST /v1/kyc/rescreen` and `PATCH /v1/kyc/monitoring` endpoints (disabling monitoring is free). Both require a prior KYC (`409 no_kyc`). **Changed** * Official CBPay brand identity applied across the documentation. * Administration documentation moved to CBPay's internal portal; this site now covers the account API only. ### v1.0 **Initial release** * Public CBPay API documentation, bilingual (Spanish and English): authentication (JWT sessions and `pk_` API keys), USDT money model, fees, idempotency, multi-country fiat payouts, payins, internal transfers, crypto (on-chain funding and withdrawals), KYC, signed webhooks and the full error catalog. * Interactive API Reference generated from OpenAPI 3.1. # Errors Source: https://docs.cbpayapp.com/en/errors Error format and complete code catalog All errors share the same shape: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "error": "insufficient_funds", "message": "account balance is not enough for this operation" } ``` * `error`: stable `snake_case` code — use it in your logic. * `message`: human-readable explanation — may change, don't parse it. **Sanitized error messages.** An error `message` will never expose provider names, infrastructure details, URLs, raw upstream bodies (JSON/HTML), or internal configuration — not in API responses, webhooks, or stored status fields. Business rejections from the payment processor keep their actionable reason (for example, why a document or account was rejected); infrastructure failures are replaced by the fixed generic message `"the payment provider could not process the request"` — retry those operations with the same `idempotency_key`. ## Codes by category ### Authentication and permissions | HTTP | `error` | Meaning | | ---- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | | 401 | `unauthorized` | Missing or invalid credential | | 401 | `invalid_credentials` | Wrong email or password (login) | | 401 | `invalid_refresh_token` | Refresh token invalid, expired, already used or revoked — send the user back to login | | 403 | `account_required` | Endpoint requires an account credential | | 403 | `org_admin_required` | Endpoint requires an admin credential | | 403 | `forbidden` | Credential level not allowed | | 403 | `account_blocked` | The account is not active | | 403 | `service_disabled` | The service is not enabled for your account (check `GET /v1/services`) | | 403 | `org_suspended` | Service suspended; contact the CBPay team | | 403 | `company_only` | Feature only for company accounts | | 403 | `company_required` | Feature only for company accounts (e.g. [third-party banking](/en/guides/banking)) | | 403 | `human_session_required` | The operation handles a private key (segregated wallet import/export) and requires a signed-in user session with 2FA — API keys are not allowed | | 403 | `member_disabled` | The mirror user is disabled in the test environment; contact support | | 403 | `passkey_rejected` | The passkey could not be verified; retry or sign in with another method | | 403 | `owner_required` | Only the account owner can change the 2FA preferences | ### OTP / 2FA Full flow and details in [security and 2FA](/en/security-2fa). | HTTP | `error` | Meaning | | ---- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | | 403 | `otp_required` | The action requires OTP: verify a challenge and retry with `X-OTP-Token` | | 403 | `otp_invalid` | OTP token invalid, expired or already used | | 403 | `session_required` | OTP challenges require a user session, not an API key | | 403 | `phone_binding_cooldown` | Phone linked less than 24 h ago without verification and no alternative factor (authenticator app or verified email) | | 409 | `phone_verification_required` | Verify your phone (OTP challenge) before enabling login 2FA over SMS/WhatsApp | | 401 | `invalid_code` | The code does not match | | 401 | `invalid_pending_token` | The intermediate login token expired; log in again | | 400 | `invalid_action` / `invalid_channel` | Action or channel outside the catalog | | 409 | `phone_required` | The account has no phone (`PATCH /v1/me`) | | 409 | `otp_phone_missing` | Login requires OTP and the account has no phone; contact your operator | | 409 | `challenge_not_pending` | The challenge expired or was already used; create a new one | | 429 | `too_many_attempts` | Send/verification limits reached; wait a few minutes | | 409 | `otp_disabled_for_org` | Two-factor authentication is not enabled for your organization; contact support | | 409 | `totp_not_started` | Confirm requires starting enrollment first (`POST /v1/me/totp/enroll`) | | 503 | `otp_unavailable` | Verification service unavailable (the action stays blocked; OTP is never skipped) | ### Social login (OAuth) Full flow and details in [social login](/en/guides/social-login). | HTTP | `error` | Meaning | | ---- | ------------------------- | ------------------------------------------------------------------------ | | 400 | `invalid_provider` | Provider outside `google/apple/microsoft/facebook` | | 400 | `provider_not_configured` | Your organization has not enabled that provider | | 401 | `invalid_credential` | The provider credential is invalid, expired or from another app | | 409 | `email_conflict` | An account with that email already exists; sign in and link the provider | | 409 | `identity_taken` | That provider is already linked to another account | | 409 | `last_login_method` | You cannot unlink your only sign-in method | ### Organization admin panel These codes come from **organization administration surfaces** (the [CBPay Admin](https://cbpayapp.com) panel), not from the account-level API documented above — they never appear on `/v1/*` account endpoints. | HTTP | `error` | Meaning | | ---- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 403 | `global_treasury_access_disabled` | The organization has not enabled global treasury visibility for its admin panel; ask a platform administrator to enable `global_treasury_read` in the organization settings | | 400 | `invalid_value` | An organization setting was sent with the wrong type (e.g. `global_treasury_read` must be a boolean, not a string) | ### Validation (400) | `error` | Meaning | | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `invalid_json` | Body is not valid JSON or has unknown fields | | `invalid_settings` | Organization settings are not a valid JSON object or could not be normalized; correct the settings payload and retry | | `invalid_type` | `type` must be `person` or `company` | | `invalid_email` / `invalid_display_name` | Invalid required field | | `weak_password` | Password shorter than 8 characters | | `invalid_role` | Invalid member role | | `unknown_org` | Wrong organization slug (use `cbpay`) | | `invalid_request` | Missing `country`/`currency` | | `idempotency_key_required` | Missing idempotency key | | `reserved_idempotency_key` | The key uses a system-reserved prefix (`payin-convert:` or `checkout-swap:`, owned by auto-conversions) — pick another key | | `beneficiary_required` | Missing payout beneficiary | | `invalid_amount` | Amount is not a valid positive decimal | | `recipient_required` / `self_transfer` | Invalid transfer destination | | `invalid_chain` / `invalid_asset` | Unsupported network or asset | | `to_address_required` | Missing withdrawal destination address | | `invalid_payload` | Missing a required field (e.g. `enabled` on AML monitoring, `external_customer_id` on verifications) | | `invalid_qr_payload` | The payout QR is unreadable or unsupported (corrupt BR Code, bad checksum, or a dynamic PIX QR); the `message` explains the exact reason | | `liveness_already_completed` | That verification's liveness check already passed | | `invalid_event_type` / `weak_secret` / `invalid_callback_url` | Invalid webhook subscription | | `invalid_phone` | Phone not normalizable to E.164 (contacts and `to_phone`) | | `invalid_language` | The PDF report `lang` is not `en`, `es` or `zh` (AML report and verification report) | | `invalid_format` | The verification report `format` is not `pdf` or `json` | | `batch_too_large` | Contact import with more than 1,000 entries (paginate the upload) | | `invalid_alias` | Alias must be 4–20 chars (a-z, 0-9, dot, underscore, hyphen), start/end alphanumeric, and not a reserved word | | `invalid_body` | The request body could not be read (binary uploads, webhooks) | | `invalid_image` | The avatar body is empty or not a supported image (PNG/JPEG/WebP, max 512 KB) | | `invalid_interval` | Subscription `interval` must be `daily`, `weekly`, `monthly` or `yearly` | | `query_required` | `GET /v1/resolve` needs `alias=` or `qr=` | | `same_email` | The new login email is the current one; use `POST /v1/me/email/verify` to verify it instead | | `invalid_status` / `invalid_kyc_status` / `invalid_direction` / `reason_required` / `account_id_required` / `invalid_service` / `invalid_fee` | Administration validations | | `invalid_settlement_hours` | `settlement_hours` is only accepted on the `payin_card` fee service and must be a non-negative integer (`0` = immediate credit) — see [fees](/en/concepts/fees) | | `invalid_country` | Malformed or missing country code — ISO 3166-1 alpha-2 (e.g. `GET /v1/aml/catalogs/cities?country=`); also a valid but unsupported country filter (e.g. a non-`US` `country` on the bank directory lookup) | ### Money and state (402 / 404 / 409 / 422) | HTTP | `error` | Meaning | | ---- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 402 | `insufficient_funds` | Not enough available balance — on outbound banking transfers the check includes the rail fee (`balance >= amount + fee`); the operation is not created when it does not hold | | 404 | `not_found` | Resource does not exist (or belongs to another account) | | 404 | `country_not_found` | Unknown ISO 3166-1 alpha-2 country code (cities catalog) | | 404 | `bank_not_found` | The routing number or SWIFT/BIC is not in the embedded bank directory — keep the payout/counterparty form manual | | 404 | `postal_code_not_found` | Unknown postal code, or a country without a dataset (postal code lookup) — keep the address fields manual | | 404 | `recipient_not_found` | Transfer destination does not exist | | 404 | `verification_not_found` | The account has no submitted verification yet (`GET /v1/me/verification/report` before onboarding) | | 409 | `duplicate` | The resource already exists | | 403 | `verification_required` | Your account has not approved its identity verification yet (person=KYC, company=KYB); until then you can only fund — request your link at `POST /v1/me/verification/link` | | 422 | `verification_required` | The operation requires the `verification_id` of an approved third-party verification (third-party banking registration, designated card) | | 422 | `verification_not_approved` | The referenced verification is not approved yet | | 422 | `verification_kind_mismatch` | The verification kind does not match the product (KYC ⇒ person/INDIVIDUAL, KYB ⇒ company/COMPANY) | | 422 | `verification_invalid` | You referenced an own-onboarding verification where a third-party one is required | | 403 | `company_account_required` | Third-party verification (KYC/KYB links/submissions) is for company accounts only | | 409 | `already_verified` | Onboarding link requested with an already-verified account | | 409 | `identity_locked` | With the verification approved, `display_name`, `tax_id` and `country` come from the verified identity and cannot be changed via `PATCH /v1/me`; contact support | | 409 | `no_screening` | AML rescreen/monitoring without a prior screening | | 409 | `no_banking_customer` | Banking operation without a banking profile (`POST /v1/banking/customer` first) | | 409 | `banking_customer_exists` | The account already has a banking profile (one per account) | | 422 | `currency_not_supported` | No FX rate for that currency | | 422 | `core_rejected` | The processor rejected the operation | | 422 | `recipient_unavailable` | The destination account cannot receive | | 422 | `recipient_ambiguous` | More than one account shares the `to_phone` number (use `to_account_id` or `to_email`) | | 422 | `contact_not_linked` | The contact has no linked CBPay account to transfer to | | 422 | `no_saved_destination` | The contact has no saved destination for that corridor/chain | | 422 | `wallet_limit_reached` | The account already holds its wallet for that network+asset pair (deposit: every account; [segregated](/en/guides/segregated-wallets): persons) | | 422 | `insufficient_gas` | The [segregated wallet](/en/guides/segregated-wallets) has no native gas (TRX/ETH) for the network fee; fund the address and retry | | 409 | `idempotency_conflict` | Another wallet creation/send with the same key is still in flight; retry with the same key | | 409 | `card_limit_reached` | A person account tried to create a second card of the same type | | 409 | `card_cancelled` | The card is already cancelled and cannot be updated | | 409 | `card_not_pending` | Only cards in `pending_activation` can be activated | | 409 | `cardholder_kyc_pending` | The designated cardholder requires identity documents | | 400 | `invalid_occupation` | `occupation` is not a catalog code (`GET /v1/cards/catalog/occupations`) | | 400 | `invalid_kind_of_business` | `kind_of_business` is not a catalog code (`GET /v1/cards/catalog/business-activities`) | | 400 | `invalid_settlement_asset` | `settlement_asset` is not USDT, USDC, BTC or GOLD | | 400 | `settlement_asset_disabled` | Your organization disabled that asset as settlement source | | 422 | `settlement_limit_exceeded` | The operation exceeds the per-operation limit for volatile assets (BTC/GOLD); use USDT/USDC or split the operation | | 422 | `settlement_daily_limit_exceeded` | The account exceeded its 24h volume for volatile assets (BTC/GOLD); use USDT/USDC or retry later | | 400 | `invalid_pair` | Swap with the same source and destination currency | | 400 | `amount_too_small` | The swap amount does not reach the destination currency's minimum unit | | 400 | `swap_asset_disabled` | One of the swap currencies is disabled for your organization | | 409 | `already_paid` | The [universal checkout link](/en/guides/checkout) was already paid through another method | | 410 | `checkout_expired` | The universal checkout link expired unpaid | | 422 | `method_unavailable` | The method chosen on the checkout link is not available for that link or country | | 400 | `country_required` | Fiat materialization on the checkout link is missing `?country=XX` | | 400 | `currency_required` | The country offers the checkout link method in several currencies; `?currency=YYY` is missing | | 422 | `country_unavailable` | That country has no payment methods available on the checkout link | | 422 | `collect_otp_failed` | The rail rejected the OTP delivery for the link's pull collection | | 422 | `collect_rejected` | The rail rejected the link's pull charge (invalid OTP or wrong data); the link stays pending | | 422 | `settlement_asset_disabled` | The checkout link's `settlement_asset` is disabled for your organization | | 422 | `checkout_amount_mismatch` | The CBPay transfer does not cover the checkout link's current due; the message carries the updated amount | | 422 | `stored_card_revoked` | The [saved card](/en/guides/stored-cards-subscriptions) is revoked; it no longer accepts charges | | 422 | `verification_required` | [QR Crypto POS](/en/guides/qr-pos): register the merchant with the `verification_id` of their approved third-party KYC/KYB | | 422 | `merchant_disabled` | The [QR Crypto POS](/en/guides/qr-pos) merchant is disabled; re-enable it before charging | | 422 | `nothing_received` | The [QR Crypto POS](/en/guides/qr-pos) charge has not received any on-chain payment: there is nothing to refund | | 422 | `refund_exceeds_received` | The refund exceeds what the [QR Crypto POS](/en/guides/qr-pos) charge received minus prior refunds | | 400 | `to_address_required` | The [QR Crypto POS](/en/guides/qr-pos) refund (and crypto withdrawals) require an explicit destination address | | 422 | `deposit_account_limit_reached` | Accounts hold one deposit account per corridor (created automatically with the account); it cannot be changed or deleted | | 422 | `export_rejected` | The processor rejected the segregated wallet key export | | 422 | `stored_card_corridor_mismatch` | The [saved card](/en/guides/stored-cards-subscriptions) belongs to a different country/currency corridor than the charge | | 409 | `subscription_state` | The [subscription](/en/guides/stored-cards-subscriptions) is not in a state that allows that action (e.g. pausing a canceled plan) | | 409 | `email_required` | The login has no real email address; set one with `POST /v1/me/email/change` | | 422 | `payin_not_refundable` | The payin is not credited or has no processor credential; it cannot be [refunded](/en/guides/refunds) | | 422 | `refund_not_supported` | That rail does not support [refunds](/en/guides/refunds) (QR, announced transfer, dedicated account, collect). POS charges are refunded through the crypto rail | | 422 | `refund_exceeds_payin` | The [refund](/en/guides/refunds) exceeds what is left to refund on the payin; your balance was not touched | | 422 | `settlement_pending` | The payin's balance is still scheduled for [settlement](/en/concepts/fees#card-payin-settlement-delay) and cannot be [refunded](/en/guides/refunds) until it is released (at `settle_at` or by an org-admin release) | | 400 | `invalid_amount` | The [refund](/en/guides/refunds) `amount` must be a positive decimal in the payin currency | ### Compliance (403 / 503) | HTTP | `error` | Meaning | | ---- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 403 | `compliance_hold` | The operation was held by the platform's compliance controls. It is not a request error: contact support with the timestamp — by policy the exact reason is not disclosed | | 403 | `geo_restricted` | The service or the operation is not available for the origin or counterparty jurisdiction | | 503 | `compliance_check_unavailable` | The compliance check could not be evaluated; the operation did NOT go out — retry with the **same** idempotency key | | 422 | `travel_rule_required` | On-chain withdrawal above the Travel Rule threshold without beneficiary data — add `travel_address` or `wallet_type: "self_hosted"` + `beneficiary_name` ([crypto guide](/en/guides/crypto)) | | 422 | `travel_rule_beneficiary_required` | `beneficiary_name` is missing on a withdrawal subject to the Travel Rule | | 422 | `travel_rule_address_mismatch` | Your `to_address` does not match the payment address approved by the receiving institution — omit it or use the one from the exchange | | 422 | `travel_rule_rejected` | The receiving institution rejected the transfer; verify the beneficiary data | | 422 | `travel_rule_pending` | The receiving institution has not resolved the exchange yet; retry with the **same** idempotency key | | 422 | `travel_rule_incomplete_approval` | The receiving institution approved without providing a payment address; contact support | | 503 | `travel_rule_unavailable` | Travel Rule exchange temporarily unavailable; retry with the **same** idempotency key | ### Qscore Errors from the credit bureau endpoints ([guide](/en/guides/qscore)). | HTTP | `error` | Meaning | | ---- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `purpose_required` | Missing `purpose` when creating a report — data protection law requires declaring it (`credit_evaluation`, `tenant_screening`, `hiring`, `supplier_onboarding` or `other`) | | 400 | `invalid_purpose` | The `purpose` is not one of the allowed values (`credit_evaluation`, `tenant_screening`, `hiring`, `supplier_onboarding`, `other`) — fix the value. `self_access` is rejected here: your own report only goes through `POST /v1/qscore/my-report` | | 400 | `invalid_doc_id` | The `doc_id` is not valid for the given `country` (e.g. bad RUT check digit in Chile) — fix the document format | | 400 | `invalid_subject_type` | `subject_type` is not `person`/`company` and could not be inferred from the document — send it explicitly | | 400 | `invalid_tax_id` | The verified `tax_id` of the account is not valid for its country (self report) — contact support to fix your verified data | | 403 | `kyc_required` | Pulling your own report requires an approved KYC/KYB — complete the identity verification first | | 403 | `report_required` | Enabling monitoring requires a purchased report for that subject — buy one first ([guide](/en/guides/qscore)); for privacy, a subject that does not exist gets this same response | | 404 | `no_score` | The subject has no computed score yet — buy a report first | | 404 | `pdf_not_ready` | The report PDF is not available yet — poll the detail until `status=ready` (the `risk_report_ready` webhook tells you) | | 409 | `no_tax_id` | The verified account has no `tax_id` on file, so the self report cannot resolve its subject — complete your verified data first | | 409 | `identity_mismatch` | The account `tax_id` does not match the verified identity document (self report) — contact support; your verified data must be consistent | | 400 | `no_valid_items` | Every row of the batch was rejected (`invalid_doc_id` / `duplicate_in_batch`) and no batch was created — validate the file locally (each `doc_id` must pass the country check digit and be unique) and resubmit with a **new** idempotency key | | 400 | `too_many_items` | A batch accepts at most 5,000 subjects — split the portfolio into multiple batches, each with its own idempotency key | | 409 | `already_decided` | The consent link was already decided (`granted`, `revoked` or `expired`) and cannot transition again — create a new link if you need a fresh authorization | | 409 | `link_inactive` | The banking connection behind the consent is not `active` (the widget session expired or the link was revoked) — the holder must reconnect from the same consent link | | 409 | `holder_mismatch` | The document verified by the bank does not match the subject `doc_id` — an account owned by a different document can never grant the consent; check that you created the link for the right document | | 409 | `seal_companies_only` | The public Qscore seal is only available for **company** accounts — person accounts have no seal ([guide](/en/guides/qscore-seal)) | | 409 | `seal_not_eligible` | The account's Qscore does not currently qualify for a public seal (band A or B with an evaluation no older than 90 days) — buy a fresh report ([guide](/en/guides/qscore)) and, if the band stays below B, the seal stays unavailable until the score improves | | 404 | `no_active_seal` | The account has no active seal to revoke — activate one first with `POST /v1/qscore/my-seal`; a revoked seal stays revoked permanently (create a new one instead) | ### Transactional firewall Errors from the review of operations held by the transactional firewall ([guide](/en/guides/transaction-reviews)). The hold itself is **not an error**: the create call answers `202 Accepted` with `status: in_review` and a `review_id`. | HTTP | `error` | Meaning | | ---- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | 400 | `invalid_status` | Invalid `status` filter — use `in_review`, `info_requested`, `released`, `rejected` or `all` | | 400 | `invalid_range` | Invalid `from`/`to` dates — `YYYY-MM-DD` format (organization timezone) | | 400 | `invalid_name` | Missing filename in the `name` query param (max 200 chars, no path separators) | | 400 | `empty_file` | The file body arrived empty | | 404 | `not_found` | The review or file does not belong to your account — by design the API never answers 403 | | 409 | `not_awaiting_info` | The review is not waiting for information (it already moved back to `in_review` or has a final decision) — check its current state | | 413 | `file_too_large` | The file exceeds the 50 MB limit — compress it or split it | | 415 | `unsupported_file_type` | Unsupported type — use PDF, PNG, JPEG, WEBP, TXT, CSV, DOC(X) or XLS(X) with the correct `Content-Type` header | | 422 | `file_limit_reached` | The review already has 20 files | | 503 | `firewall_unavailable` | The policy could not be evaluated or the review could not be persisted — retry with the **same** idempotency key | | 503 | `storage_unavailable` | File storage is unavailable — retry in a few seconds | ### Real-time events (SSE) Codes from [`GET /v1/events`](/en/realtime-events) and its history. | HTTP | `error` | Meaning | | ---- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `invalid_event_type` | A value in `?types=` is not in the catalog — `message` lists the valid ones | | 429 | `too_many_streams` | Concurrent stream limit reached (per account or per organization) — close one before opening another | | 429 | `rate_limited` | Too many stream openings from this IP (600 per hour) — the quota counts *attempts*, not live connections: stop the reconnect loop and back off | | 500 | `streaming_unsupported` | The connection does not support streaming (an intermediate proxy is buffering) — remove the buffering or fall back to webhooks | | 503 | `stream_unavailable` | The stream could not be opened; retry with backoff and keep your `Last-Event-ID` | ### Service (5xx) | HTTP | `error` | Meaning | | ---- | --------------------------- | -------------------------------------------------------------------------------------------- | | 500 | `internal_error` | Unexpected error; retry with the same idempotency key | | 500 | `fee_config_invalid` | The account's fee configuration is invalid; contact support (the operation did not run) | | 502 | `rates_unavailable` | FX rates temporarily unavailable | | 502 | `core_unavailable` | Processor temporarily unavailable | | 502 | `core_invalid_response` | The processor returned an unexpected response; retry with the **same** idempotency key | | 502 | `compliance_unavailable` | AML screening temporarily unavailable | | 503 | `verifications_unavailable` | Identity verification temporarily unavailable (the fee was refunded) | | 503 | `org_credential_missing` | Service being configured; contact CBPay support | | 503 | `withdrawals_unavailable` | On-chain withdrawals not enabled for the corridor | | 503 | `pricing_unavailable` | BTC/GOLD execution price unavailable or stale; retry later or settle in USDT/USDC | | 503 | `channel_unavailable` | The payout channel is temporarily unavailable; retry later with the **same** idempotency key | | 503 | `export_unavailable` | Segregated wallet private key export is not enabled on this environment | ## How to handle them * **Validation 4xx**: fix the request. Don't retry as-is. * **402**: fund the account and retry (new idempotency key only if the operation was never created). * **5xx / timeouts**: retry with **the same** idempotency key; the operation will never duplicate. # FAQ Source: https://docs.cbpayapp.com/en/faq The typical integration questions, answered upfront ## Getting started There is one per environment: **live** uses `https://api.qbank.cl/platform` and **test** uses `https://cryptobank.qbank.cl/platform`. Every endpoint in this documentation hangs from them (for example `https://api.qbank.cl/platform/v1/balances`). Yes. CBPay runs two fully isolated environments: **test** (`https://cryptobank.qbank.cl/platform`, `pk_test_...` keys, simulated money) and **live** (`https://api.qbank.cl/platform`, `pk_...` keys, real money). Build against test first: every corridor is served by a deterministic simulator with magic values to force every failure path, and going live is just swapping the base URL and the key. Full guide in [environments and testing](/en/environment-testing). Two ways: (1) **deposit USDT on-chain** — create a wallet with `POST /v1/crypto/wallets` and send USDT to that address (TRON or Ethereum); (2) **collect fiat** with a [payin](/en/guides/payins) (QR, announced transfer, etc.). Either way the balance is credited automatically and a webhook notifies you. Yes: every account must approve its identity verification (person = KYC, company = KYB) before moving money out. Meanwhile you can **fund** (payins, crypto deposits, incoming transfers) and read; other actions answer `403 verification_required`. Request your link with `POST /v1/me/verification/link` and complete the wizard — [full guide](/en/guides/kyc). If anything returns `403 account_blocked`, contact the CBPay team. That service is not enabled for your account (services are enabled per account according to your commercial agreement). Check `GET /v1/services` for the full map of what you can use — also handy to decide what to show in your UI — and contact the CBPay team if you need something enabled. Reads and money already in flight are never blocked. For server-to-server processes always use an **API key** (`pk_…`, never expires). JWT sessions (24 h) are for front-ends with users who log in. Both travel in `Authorization: Bearer ` (or `X-API-Key`). ## Money and rates Your account holds **four independent balances**: USDT (the operating currency, 6 decimals), USDC, BTC and GOLD. Fiat operations (payouts in CLP, collections in BOB…) convert to/from USDT at your account's rates at execution time (`rate` for payouts, `payin_rate` for payins); you can also settle payouts from another balance (`settlement_asset`) and keep your payins in the asset you choose (`default_payin_asset`). See the [money model](/en/concepts/money-model). Query `GET /v1/rates` (returns **your** rate per country) and compute: ``` usdt_amount ≈ local_amount / rate (rounded up, 6 decimals) total_debit = usdt_amount + fixed fee (your fees come in the same response) ``` The payout object returns the exact server-computed values (`fx_rate`, `usdt_amount`, `fee`, `total_debit`). It is not a frozen quote: the payout uses the rate in force **at creation time** and the payin the one in force **at credit time**, which can drift slightly from the one you fetched. The applied rate is recorded in each operation's `fx_rate` field for audit. The API imposes no technical minimums; with very small amounts the fixed fee can exceed the amount (you'll get `invalid_amount` or an uneconomic debit). Maximums depend on your configuration with CBPay. CBPay defines them for your account: per service (payout, payin, funding, withdrawal, KYC, wallet creation), per country, with % and/or fixed components. `GET /v1/rates` returns your effective list in the `fees` field (FX percentages are already baked into the quoted rate). Details in [fees](/en/concepts/fees). ## Payouts It depends on the corridor: several are **synchronous or near-instant** (Yape, Pago Móvil, Bolivia QR) and others process within minutes through the local banking rail (SPEI, transfers). Design your integration around the `payout_status_changed` webhook: don't assume fixed timings or poll. The **full debit** (amount + fee) is refunded to your `available` balance automatically, and the webhook arrives with `status: failed` and a `status_code` explaining the cause. Fix the data and retry with a **new** `idempotency_key`. Yes — that is what the `idempotency_key` is for: repeating the same key returns the original payout (`idempotency_hit: true`) without creating or debiting anything new. Use a different key only when you truly want another payment. See [idempotency](/en/concepts/idempotency). [Examples by country](/en/guides/payouts#examples-by-country) has a field table and a complete example per country and method, and `GET /v1/payouts/banks?country=XX` returns the current bank codes where they apply. No: each scan `provider_reference` admits a single confirm. Retries with the same `idempotency_key` return the original payout. No. Once a payout is `processing` the banking rail already has it; there is no API cancellation. Wait for the final state: if the rail rejects it, the full refund is automatic. Verify the beneficiary data **before** creating (the free `qr/scan` exists precisely to confirm the recipient before paying a QR). The API imposes no technical minimums (with tiny amounts the fixed fee may exceed the amount). Maximums and operational limits depend on your commercial agreement and the corridor — to raise limits, contact your CBPay administrator with the country, expected volume and average ticket. ## Payins and deposits When the provider confirms the payment: QRs and active collections usually credit within seconds; bank transfers when the deposit arrives and is matched. You always receive the `payin_credited` webhook with the net credited amount. No. The deposit lands as `unassigned` and the CBPay team routes it to your account manually (once assigned it is credited with your normal rate and fees). Meanwhile it does not show in your balance — if you are expecting a deposit that never arrives, tell your administrator the amount, currency and approximate time to speed up the assignment. To avoid it, use the **dedicated CLABE account** in Mexico, the **payment page** in Chile, or make sure the reference travels in the transfer description. Detection is near-instant and the credit lands when the network confirms: **TRON \~1 minute** (19 confirmations), **Ethereum a few minutes** depending on congestion. The `crypto_deposit_credited` webhook closes the cycle with the `tx_id` so you can verify it on the explorer. With the [statement](/en/guides/statement): one endpoint that consolidates every movement of the period (payouts, payins, crypto, transfers and fees) with an exact accounting reconciliation. Request `format=pdf` or `format=xlsx` to download the CBPay-branded document, or `json` to render it in your web. No. [Banking](/en/guides/banking) money lives in **your real bank accounts** (USD or other enabled currencies) and is queried with `GET /v1/banking/accounts/{id}/balance`. Your CBPay balance is USDT and is only touched to charge the fixed banking fees (refunded if the operation fails). No: a **payin** is a fiat collection (local currency → USDT); a **crypto deposit** is on-chain USDT arriving at your wallet (`funding`). Both end up in the same USDT balance, with different webhooks (`payin_credited` vs `crypto_deposit_credited`). ## Webhooks and errors No, but strongly recommended: final states arrive by webhook with no polling. You can still fetch any object by API (`GET /v1/payouts/{id}`, `GET /v1/payins/{id}`…) at any time. Local URLs are rejected for security. Use a free HTTPS tunnel (Cloudflare Tunnel or ngrok) and subscribe that public URL — step-by-step recipe in [environment and testing](/en/environment-testing). They read the same ledger: `movements` is the paginated programmatic view (for automatic reconciliation and your UI); the statement is the period snapshot with totals, breakdowns and a guaranteed balance (for accounting closes). They never disagree. Details in [movements and reconciliation](/en/concepts/movements-reconciliation). Deliveries are **at-least-once**: timeouts trigger retries (up to 5). Deduplicate with the `X-Webhook-Event-ID` header, unique per event. It is transient: retry with backoff using the **same** `idempotency_key` (so you never duplicate). If it persists, contact the CBPay team with the `payout_id`/`payin_id` and the time. # Contacts Source: https://docs.cbpayapp.com/en/guides/contacts Contact book: fills itself with every send, imports the phone address book, discovers who has CBPay and enables sending money by phone number The **contact book** kills repeated typing: every send (internal transfer, fiat payout or crypto withdrawal) saves the destination as a contact automatically, you can **import the phone's address book** to discover which of your contacts have CBPay, and transfers accept a **phone number** or a `contact_id` directly as destination. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR send["Any send
(transfer / payout / crypto)"] -->|"auto-save"| contact["Contact + reusable
destinations"] book["POST /v1/contacts/import
(phone address book)"] --> contact contact -->|"has_cbpay: true"| cbpay["Has CBPay"] contact -->|"contact_id"| send2["Quick send"] phone["to_phone (verified)"] --> send2 ``` ## Contacts create themselves Every send saves its destination in your book (deduplicated: repeating the same destination never creates duplicates, it only marks it as used): | Send | What gets saved | | ----------------- | ----------------------------------------------------------------------- | | Internal transfer | The destination CBPay account (name, email and its phone when verified) | | Fiat payout | The full beneficiary (bank, account, document…) per country and method | | Crypto withdrawal | The address per network (name it with `contact_name` on the withdrawal) | Don't want to save a one-off destination? Add `"save_contact": false` to the send body. Auto-save never affects the send: if anything fails, the send goes through anyway. ## Import the phone address book Upload the phone's contacts (up to **1,000 per request**; paginate beyond that) and CBPay tells you **who already has an account** — matching is by phone number, and only against accounts of the same operator: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/contacts/import \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "contacts": [ { "name": "Carlos Soto", "phones": ["+56 9 8765 4321"] }, { "name": "Ana Pérez", "phones": ["912345678"] }, { "name": "Aunt Rosa", "phones": ["not-a-number"] } ] }' ``` `200` response: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "imported": 2, "matched": 1, "total": 3, "contacts": [ { "name": "Carlos Soto", "phone": "+56987654321", "contact_id": "3f8a…", "has_cbpay": true }, { "name": "Ana Pérez", "phone": "+56912345678", "contact_id": "9c1d…", "has_cbpay": false }, { "name": "Aunt Rosa", "skipped": true, "reason": "no_valid_phone" } ] } ``` * Numbers are normalized to **E.164** automatically: accepts `+…`, `00…` and local numbers (your account's country code is prepended). Invalid ones are skipped. * Re-importing is safe: existing contacts are never duplicated. * `has_cbpay: true` means that phone belongs to an active account of the same operator — you can transfer to it instantly. ## Send money by phone number Internal transfers accept `to_phone` (besides `to_account_id`, `to_email` and `to_contact_id`): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/transfers \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "to_phone": "+56987654321", "amount": "25.000000", "description": "Lunch", "idempotency_key": "lunch-2026-07-10" }' ``` For safety, `to_phone` only resolves accounts with an **OTP-verified** phone (we never guess a money destination from an unverified number). If the number is not verified: `404 recipient_not_found`; if more than one account shares it: `422 recipient_ambiguous` (use `to_account_id` or `to_email`). ## Send to a contact Every send accepts the contact directly: ```bash Transfer (contact with CBPay) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/transfers \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "to_contact_id": "3f8a…", "amount": "10.000000", "idempotency_key": "t-991" }' ``` ```bash Payout (saved beneficiary) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "country": "CL", "currency": "CLP", "amount": "45000", "beneficiary_contact_id": "7b2c…", "idempotency_key": "rent-07" }' ``` ```bash Crypto withdrawal (saved address) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/crypto/withdrawals \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "chain": "tron", "to_contact_id": "5d4e…", "amount": "100.000000", "idempotency_key": "w-2211" }' ``` * **Payouts**: uses the contact's most recent saved beneficiary for that country (and method when sent; otherwise the saved destination's method applies). An explicit `beneficiary` in the body always wins. No saved destination for that corridor: `422 no_saved_destination`. * **Crypto**: uses the saved address for that `chain`; an explicit `to_address` wins. * **Transfers**: uses the contact's linked CBPay account; if the contact only has a phone, its (verified) number is tried. Neither: `422 contact_not_linked`. ## Manage the book ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Listing with search and filters curl "https://api.qbank.cl/platform/v1/contacts?q=carlos&has_cbpay=true&page=1&page_size=50" \ -H "Authorization: Bearer " # Create manually curl -X POST https://api.qbank.cl/platform/v1/contacts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "display_name": "Carlos Soto", "phone": "+56987654321", "email": "carlos@mail.com", "favorite": true }' # Detail (includes saved destinations) curl https://api.qbank.cl/platform/v1/contacts/{contact_id} \ -H "Authorization: Bearer " # Edit / delete curl -X PATCH https://api.qbank.cl/platform/v1/contacts/{contact_id} \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{ "alias": "Carlitos", "favorite": true }' curl -X DELETE https://api.qbank.cl/platform/v1/contacts/{contact_id} \ -H "Authorization: Bearer " ``` Contact detail (`200`): ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "contact_id": "3f8a1b2c-…", "display_name": "Carlos Soto", "alias": "Carlitos", "phone": "+56987654321", "email": "carlos@mail.com", "has_cbpay": true, "cbpay_account_id": "389d34a3-…", "source": "import", "favorite": true, "destinations": [ { "destination_id": "aa11…", "type": "cbpay", "last_used_at": "2026-07-10T15:00:00Z", "created_at": "2026-07-08T10:00:00Z" }, { "destination_id": "bb22…", "type": "payout", "country": "CL", "currency": "CLP", "method": "bank_transfer", "details": { "name": "Carlos Soto", "tax_id": "12.345.678-5", "bank_code": "012", "account_type": "checking", "account_number": "123456789" }, "last_used_at": "2026-07-09T18:30:00Z", "created_at": "2026-07-09T18:30:00Z" }, { "destination_id": "cc33…", "type": "crypto", "chain": "tron", "address": "TVJ6njG5Fyrq6XwYok3xPQx8kR7HQx6vXk", "last_used_at": "2026-07-07T12:00:00Z", "created_at": "2026-07-07T12:00:00Z" } ], "created_at": "2026-07-07T12:00:00Z", "updated_at": "2026-07-10T15:00:00Z" } ``` You can also add destinations manually (`POST /v1/contacts/{id}/destinations` with `type: payout|crypto|cbpay` and its fields) and delete them (`DELETE .../destinations/{destination_id}`). ## Errors | HTTP | `error` | Cause | Solution | | ---- | ---------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------- | | 400 | `invalid_phone` | The phone could not be normalized to E.164 | Send it as `+` | | 400 | `batch_too_large` | Import with more than 1,000 contacts | Paginate the upload | | 404 | `not_found` | The contact/destination does not exist or is not yours | Check the id | | 404 | `recipient_not_found` | No verified phone matches | Ask the recipient to verify their phone, or use email/account\_id | | 409 | `duplicate` | You already have a contact with that phone/email | Edit the existing one | | 422 | `recipient_ambiguous` | More than one account shares the phone | Use `to_account_id` or `to_email` | | 422 | `contact_not_linked` | The contact has no linked CBPay account | Transfer through another identifier, or pay them out | | 422 | `no_saved_destination` | The contact has no saved destination for that corridor/chain | Send the explicit `beneficiary`/`to_address` (it will be saved) | ## FAQ No. The book is private to your account: importing your address book or saving contacts never notifies anyone or shares your data. Only you see your book. Matching is by exact phone number (E.164) against accounts of the same operator. If that person registered a different number (or none) on their account, there is no match. Once they register and verify that phone, a re-import picks it up. Not via internal transfer (there is no account to credit). But you can send a fiat payout to their bank account or a crypto send to their wallet — and those destinations get saved on the contact too. Sending by phone fails explicitly with 422 recipient\_ambiguous — we never guess a money destination. Use to\_account\_id or to\_email in that case. Matching is only against accounts of your same operator, with a cap of 1,000 contacts per request under the API's global rate limit. It exposes nothing about the matched account beyond its existence (needed to be able to transfer to it). # Profile & security Source: https://docs.cbpayapp.com/en/guides/profile Password, verified email, alias and QR to get paid, profile photo, 2FA (SMS/WhatsApp/email/app), passkeys, and session and security-activity management Everything an end user manages about **their own account**: credentials (password and email), their public identity to receive money (alias, QR and photo), two-factor authentication (2FA) factors, and control over their sessions and security activity. It all lives under `/v1/me/*` and `/v1/auth/*` and requires a **user session** (JWT); API keys do not apply. This guide is the home of the user's **2FA factors and preferences** (SMS/WhatsApp/email, authenticator app, recovery codes, passkeys). The **per-action OTP flow** your integration handles when the API answers `otp_required` (challenge → verify → `X-OTP-Token`) is documented in [Security and 2FA (OTP)](/en/security-2fa). ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR cred["Credentials
password · email"] --> acct["My account"] pub["Public identity
alias · QR · photo"] --> acct factors["2FA factors
SMS · WhatsApp · email · app · passkey"] --> acct sessions["Sessions & activity"] --> acct ``` ## Profile data and verified identity `PATCH /v1/me` updates your profile data (`display_name`, `tax_id`, `phone`, `country`). But once your identity verification (KYC/KYB) is **approved**, your name, tax ID and country are **filled automatically from the verified identity** — what the verification confirmed, not what was self-declared — and they become **immutable** through this endpoint: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "error": "identity_locked", "message": "display_name, tax_id and country come from your approved identity verification and cannot be changed here; contact support to update your verified identity" } ``` To correct verified data (a legal name change, for example) contact your platform's support: it takes a new verification or an operational override by an administrator. `phone` stays editable at any time with its own verification flow (OTP to the previous number when the policy requires it). ## Password `POST /v1/me/password` with `current_password` and `new_password`. If your account was created via social login and has no password yet, leave `current_password` empty to set the first one. Changing it **revokes all other sessions** and the response carries a fresh one. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/me/password \ -H "Authorization: Bearer $TOKEN" \ -d '{"current_password":"old-pass","new_password":"my-new-strong-pass"}' ``` `POST /v1/auth/password/forgot` with `org` and `email`. It **always** returns 200 with the same body whether or not the account exists (it never reveals if the email is registered). The code goes to the email; with `channel:"sms"` it goes to the verified phone. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/auth/password/forgot \ -d '{"org":"cbpay","email":"taylor@example.com"}' ``` Then `POST /v1/auth/password/reset` with `code` and `new_password`. Revokes all sessions. ## Login email The email can be changed, but the **new email is always verified**: the code goes to the new address and the change only applies once you confirm it. This prevents anyone from pointing the login at a mailbox they do not control. `POST /v1/me/email/change` with `new_email`. If the 2FA policy requires it, also send the `X-OTP-Token` header for the `email_change` action. `POST /v1/me/email/confirm` with the `code` received at the new email. The old email is notified of the change. Changing your email does **not** break your already-linked social logins (Google, Apple, etc.): they are identified by the provider, not the email. ## Alias and QR to get paid Each account has two **permanent** public identifiers so others can send you money between CBPay accounts: * **Alias** — you choose it once with `PUT /v1/me/alias` (4-20 chars `a-z 0-9 . _ -`, no reserved words). It cannot be changed. * **Profile QR** — `GET /v1/me/qr` returns the `qr_token`, the payload `cbpay:pay?to=` and a ready-to-render PNG. It only lets others **receive** money to you, so it never changes. Whoever is about to send can confirm your identity first with `GET /v1/resolve?alias=taylor.code` (or `?qr=`), which returns your name, type and avatar. And transfers accept the destination directly: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/transfers \ -H "Authorization: Bearer $TOKEN" \ -d '{"to_alias":"taylor.code","amount":"10.00","idempotency_key":"t-001"}' ``` `to_qr_token` works the same (accepts the token or the `cbpay:pay?to=…` payload). ## Profile photo `PUT /v1/me/avatar` with the image bytes (JPEG, PNG or WebP, max 512 KB; the type is detected from the content). `DELETE /v1/me/avatar` removes it and `GET /v1/avatars/{accountID}` serves it for previews. The response includes `avatar_url`: when the image is published to the public CDN it is an **absolute URL that loads without authentication** (ideal for the front end — use it directly in an ``); in that case `GET /v1/avatars/{accountID}` answers with a `302` redirect to the same URL. ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "status": "avatar_updated", "content_type": "image/png", "size_bytes": 20481, "avatar_url": "https://cdn.cbpayapp.com/public/avatars/1fa63bd1-…/9b1deb4d-…" } ``` ## Two-factor authentication (2FA) CBPay protects sensitive actions with a one-time code. You choose, per action, whether it is required and over **which channel**: | Channel | How the code arrives | Notes | | ------------------ | ----------------------------------------------- | --------------------------------- | | `sms` / `whatsapp` | Message to the phone | Subject to channel availability | | `email` | Email to the verified address | Requires the email verified | | `totp` | Authenticator app (Google Authenticator, Authy) | Immune to SIM swap; sends nothing | `GET /v1/otp/preferences` shows your effective policy (and what your organization requires, which is the **floor**: you can harden, not go below). `PUT /v1/otp/preferences` adjusts it. **Weakening** your 2FA (disabling an action or lowering the channel) first requires verifying your current factor. Enabling **login** 2FA over `sms` or `whatsapp` requires your phone number already **verified** (complete any SMS/WhatsApp OTP challenge first: `POST /v1/otp/challenges` + verify). If the number is not verified the API responds `409 phone_verification_required` — so a mistyped number cannot lock you out of your account. ### Authenticator app (TOTP) `POST /v1/me/totp/enroll` returns the `otpauth://` and a QR. Scan it in your app. `POST /v1/me/totp/confirm` with the first code. It hands you **10 one-time backup codes** — store them, they are shown only once. Regenerate the codes with `POST /v1/me/totp/recovery-codes` or remove the app with `DELETE /v1/me/totp` (both require a valid code). ### Passkeys **Passkeys** let you sign in without a password using the device's biometrics (Face ID, Touch ID, Windows Hello, or a security key). `POST /v1/me/passkeys/register/begin` → pass `options.publicKey` to `navigator.credentials.create()` → `POST /v1/me/passkeys/register/finish` with the result and a name ("Taylor's MacBook"). `POST /v1/auth/passkey/login/begin` with `org` → `navigator.credentials.get()` → `POST /v1/auth/passkey/login/finish`. Since a passkey is already two factors (device + biometrics), this login does not ask for a second code. List and remove your passkeys with `GET`/`DELETE /v1/me/passkeys`. You cannot remove your **only** sign-in method. Passkeys and passkey registration depend on your organization having its domain configured; otherwise they return `passkeys_unavailable`. ## Sessions and activity * `GET /v1/me/sessions` lists your active sessions (device, IP, login method, which one is current). `DELETE /v1/me/sessions/{id}` closes one; `POST /v1/me/sessions/revoke-all` closes all but the current. * `GET /v1/me/security/events?from=&to=` is your account's security history: logins, password or email changes, factors added or removed. On top of that, CBPay **emails you** when your password or email changes or a factor is added/removed — your safety net against unauthorized access. ## Common errors | Code | HTTP | What to do | | --------------------------------------- | --------- | ----------------------------------------------------------------------------- | | `invalid_password` | 403 | The current password does not match | | `alias_already_set` | 409 | The alias is already set; it is permanent | | `alias_taken` | 409 | That alias is taken; choose another | | `email_in_use` | 409 | Another login already uses that email | | `no_pending_email` | 409 | No pending email change; start it again | | `policy_locked_by_org` | 403 | Your organization requires that action/channel; it cannot be weakened | | `totp_enrollment_required` | 409 | Enroll the app before requiring the `totp` channel | | `phone_verification_required` | 409 | Verify your phone (OTP challenge) before enabling login 2FA over SMS/WhatsApp | | `last_login_method` | 409 | You cannot remove your only sign-in method | | `passkeys_unavailable` | 503 | Your organization has no passkeys configured | | `image_too_large` / `unsupported_image` | 413 / 415 | Avatar max 512 KB, JPEG/PNG/WebP | No. Both are permanent by design: they are your stable identity to receive money. The QR only allows receiving, so sharing it is not a risk. Use one of your **backup codes** (the ones you got when confirming TOTP) in any verification or login. If you do not have them, recover access with another factor (passkey or password + another channel) and regenerate everything. No. Social logins are identified by the provider, not the email, so they keep working. # Qscore — API-first credit bureau Source: https://docs.cbpayapp.com/en/guides/qscore Buy full credit reports with score for people and companies (Chile first), download the PDF, verify it publicly, manage ARCO disputes, and monitor subjects with alerts. Qscore is the API-first credit bureau of the platform. One call returns a **complete credit report** of a person or company — identity, tradelines, delinquencies, bankruptcies, commercial activity, alternative data — plus a **credit score (1–999) with its band and explainable reason codes**, rendered as a branded PDF and exposed as JSON. * **Chile first, country-agnostic design**: today subjects are Chilean (`country: "CL"`, RUT as `doc_id`); new countries plug in without contract changes. * **Live freshness**: every report queries the data sources at purchase time and declares, per source, whether the data is `live`, `cached` or `unavailable`. No silent stale data. * **Compliance built in**: the declared `purpose` is mandatory (Chilean data protection law), every score carries its reason codes, and every report includes a public verification code. Qscore is a paid product gated by the `risk` service flag of your account and billed per report (`risk_report_person` / `risk_report_company` standalone fees). If the generation fails after the charge, the fee is **refunded automatically** and the report ends `failed` with its `error_code`. The exception is **your own report**: a verified account holder generates their self report for free — see "Your own report (self)" below. ## How it works ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} sequenceDiagram autonumber participant C as Your system participant P as CBPay platform participant Q as Qbank core participant S as Data sources C->>P: POST /v1/qscore/reports (doc_id, purpose, idempotency_key) P->>Q: POST /v1/bureau/fetch (live fetch) Q->>S: Query official sources S-->>Q: Bureau records (deduplicated) Q-->>P: Records P->>P: Compute score v1 + build + render PDF P-->>C: 201 report ready (score, band, full report JSON) P-->>C: Webhook risk_report_ready C->>P: GET /v1/qscore/reports/{report_id}/pdf ``` Generation is **synchronous**: the `POST` fetches the bureau records, computes the score, renders the PDF and returns the ready report in a single response. A source that is down does **not** fail a paid report — it is generated with the persisted data and the source is declared `cached` (or `unavailable` if it contributed nothing) in the `sources` section. ## Your own report (self) If you hold a **verified account** (approved KYC/KYB), you can generate and download **your own Qscore report** directly. This is your right of access to your personal data (ARCO / Chilean Law 21.719), not a purchase: * **Free**: no fee is charged, ever. * **No score penalty**: self reports are excluded from the inquiry count of your score — checking your own report never hurts it. * **Anti-oracle by design**: the subject identity comes from the `tax_id` verified in your KYC/KYB. The request does **not** accept a `doc_id` — asking for someone else's report through these endpoints is impossible. * **Frequency limit**: one **new** report every 30 days. If you already have a `ready` report within the window, the `POST` returns it with `idempotency_hit: true` (HTTP 200) instead of generating another. ### Generate (or reuse) your report `POST /v1/qscore/my-report` — the body is optional: `{"lang": "es"|"en"|"zh"}` (default `es`). Generation is **synchronous**: the response carries the finished report. No `idempotency_key` is needed — idempotency is deterministic per account, subject and day (a double submit on the same day returns the already-created report). ```bash Generate your own report theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST "https://api.qbank.cl/platform/v1/qscore/my-report" \ -H "Authorization: Bearer pk_live_..." \ -H "Content-Type: application/json" \ -d '{"lang": "es"}' ``` ```json 201 Created (new report generated) theme={"theme":{"light":"github-light","dark":"github-dark"}} { "report_id": "9f1c2d3e-4a5b-4c6d-8e7f-0a1b2c3d4e5f", "subject_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "status": "ready", "purpose": "self_access", "lang": "es", "band": "B", "model_version": "qscore-v2", "verify_code": "Q9f1c2d3e4a5b4c6d8e7f0a1b2c3d4e5f1a2b3c4d5e6f", "created_at": "2026-08-08T15:04:12Z", "score": 742, "reason_codes": ["RC01", "RC07"], "completed_at": "2026-08-08T15:04:19Z", "report": { "...": "full report JSON" } } ``` A second call within 30 days answers `200 OK` with the same report and `"idempotency_hit": true`. If the generation fails, the response is `201` with `status: "failed"` and its `error_code` / `error_message` (nothing was charged — the self report is free). ### Read your latest report `GET /v1/qscore/my-report` returns your most recent self report (any status) without generating a new one — `404 not_found` if you never generated one. ### Download the PDF `GET /v1/qscore/my-report/pdf` downloads the PDF of your latest self report (`Content-Disposition: attachment; filename="qscore_self_.pdf"`). If the report is not `ready` yet, it answers `404 pdf_not_ready`. The PDF carries the same public verification code as any Qscore report — anyone holding it can check its authenticity at `GET /verify/qscore/{code}` (see "Public verification" below). ### Self-report errors | HTTP | Code | When | Solution | | ---- | ------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------- | | 403 | `kyc_required` | Your account's KYC/KYB is not approved | Complete identity verification first | | 409 | `no_tax_id` | Your account has no verified tax id on file | Complete your verified profile or contact support | | 400 | `invalid_tax_id` | The tax id on file is not valid for the account country | Contact support to fix your verified data | | 409 | `identity_mismatch` | The account `tax_id` does not match the verified identity document (it was overridden) | Contact support — your verified data must be consistent | | 404 | `not_found` | You never generated a self report (`GET`) | Generate one with `POST /v1/qscore/my-report` | | 404 | `pdf_not_ready` | The report is not `ready` yet or has no PDF | Retry the download once the report is `ready` | The commercial endpoint `POST /v1/qscore/reports` **rejects** `purpose: "self_access"` with `400 invalid_purpose` — self access only goes through `/v1/qscore/my-report`. The `risk_report_ready` webhook of a self report carries an extra `"purpose": "self_access"` field in its payload. ## 1. Buy a report `POST /v1/qscore/reports` creates and generates the full report. `idempotency_key` is **mandatory** (the report charges a fee: a retry with the same key returns the original report with `idempotency_hit: true` and never double-charges). | Field | Type | Required | Description | | ----------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------- | | `doc_id` | string | yes | Document ID of the subject. In Chile, the RUT (`11.111.111-1`); it is normalized to canonical form. | | `country` | string | yes | ISO 3166-1 alpha-2 country of the document. Today `CL`. | | `subject_type` | string | no | `person` or `company`. If omitted it is inferred from the document. | | `purpose` | string | yes | Declared purpose (data protection law): `credit_evaluation`, `tenant_screening`, `hiring`, `supplier_onboarding`, `other`. | | `lang` | string | no | Report language: `es` (default), `en`, `zh`. | | `idempotency_key` | string | yes | Your unique key for this purchase. | ```bash Create person report theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST "https://api.qbank.cl/platform/v1/qscore/reports" \ -H "Authorization: Bearer pk_live_..." \ -H "Content-Type: application/json" \ -d '{ "doc_id": "11.111.111-1", "country": "CL", "purpose": "credit_evaluation", "lang": "en", "idempotency_key": "qscore-2026-08-08-0001" }' ``` ```bash Create company report theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST "https://api.qbank.cl/platform/v1/qscore/reports" \ -H "Authorization: Bearer pk_live_..." \ -H "Content-Type: application/json" \ -d '{ "doc_id": "76.123.456-0", "country": "CL", "subject_type": "company", "purpose": "supplier_onboarding", "lang": "en", "idempotency_key": "qscore-2026-08-08-0002" }' ``` ```json 201 Created (report ready) theme={"theme":{"light":"github-light","dark":"github-dark"}} { "report_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "subject_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "status": "ready", "purpose": "credit_evaluation", "lang": "en", "band": "B", "model_version": "qscore-v1", "verify_code": "Qf47ac10b58cc4372a5670e02b2c3d4791a2b3c4d5e6f", "created_at": "2026-08-08T15:04:22Z", "score": 742, "completed_at": "2026-08-08T15:04:25Z", "report": { "meta": { "report_id": "QSR-f47ac10b58cc", "lang": "en", "purpose": "credit_evaluation", "generated_at": "2026-08-08T15:04:25Z", "verification_code": "Qf47ac10b58cc4372a5670e02b2c3d4791a2b3c4d5e6f", "verification_url": "https://business.cbpayapp.com/verify/qscore/Qf47ac10b58cc4372a5670e02b2c3d4791a2b3c4d5e6f" }, "identity": { "subject_type": "person", "doc_id": "11111111-1", "name": "Juan Pérez González", "country": "CL" }, "score": { "score": 742, "band": "B", "model_version": "qscore-v1", "reason_codes": [ {"code": "ACTIVE_TRADELINES", "direction": "positive", "weight": "medium"}, {"code": "CREDIT_HISTORY_DEPTH", "direction": "positive", "weight": "low"} ], "computed_at": "2026-08-08T15:04:25Z" }, "summary": ["No open delinquencies on record", "Active tax status"], "internal_score": {"available": false}, "sources": [ {"source": "res_chile", "label": "Registro de Empresas y Sociedades (RES)", "records": 2, "fetched_at": "2026-08-08T15:04:23Z", "freshness": "live"} ] } } ``` If something fails after the fee was charged, the fee is refunded and the response is the error with `error_code: "generation_failed"` persisted on the report. Re-running with the **same** `idempotency_key` returns the original report (or its failure) — it never charges twice. ## 2. The score (model v1) The score runs `qscore-v1`: base **600**, range **1–999**, adjusted by adverse facts (open delinquencies, protests, bankruptcies, recent queries) and positive signals (active tradelines, credit history depth, company activity, alternative data). | Band | Range | Reading | | ---- | ------- | ----------------------------------------------- | | `A` | 800–999 | Excellent | | `B` | 650–799 | Good | | `C` | 500–649 | Fair | | `D` | 350–499 | Weak | | `E` | 1–349 | High risk | | `SC` | — | No data found for the subject (score is `null`) | Every report carries its `reason_codes` — the explainability layer of the score: | Code | Direction | Meaning | | ---------------------- | --------- | --------------------------------------------------------- | | `NO_DATA` | negative | No records found for the subject (band `SC`) | | `BANKRUPTCY_OPEN` | negative | Open insolvency/bankruptcy proceeding | | `OPEN_DELINQUENCY` | negative | Open delinquency in collections | | `PROTESTO_OPEN` | negative | Unpaid protested document (bounced check/promissory note) | | `RECENT_DELINQUENCY` | negative | Delinquency reported recently | | `MANY_RECENT_QUERIES` | negative | Many reports purchased on the subject in the last 90 days | | `ACTIVE_TRADELINES` | positive | Active, up-to-date credit lines | | `CREDIT_HISTORY_DEPTH` | positive | Long credit history | | `COMPANY_ACTIVE` | positive | Active company with tax activity | | `COMPANY_NEW` | negative | Recently incorporated company | | `ALTERNATIVE_POSITIVE` | positive | Positive alternative data (utilities, open finance) | | `INTERNAL_ACTIVITY` | positive | Positive internal platform signals | ### Industry peer benchmark (company reports only) **Company** reports may include the `peer_benchmark` block: the score's position **within its segment** — same country and same industry (ISIC classification, from the tax registry). ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} "peer_benchmark": { "available": true, "segment_code": "6499", "segment_label": "Other financial service activities", "peers": 12, "percentile": 75, "median_score": 640 } ``` | Field | Type | Description | | -------------------------------- | ------- | --------------------------------------------------------------------------------- | | `available` | boolean | `true` when there is a large enough comparable population. | | `segment_code` / `segment_label` | string | The subject's industry code and label (ISIC). | | `peers` | number | Comparable companies considered. | | `percentile` | number | Percentage of peers with a **lower** score (75 = better than 75% of the segment). | | `median_score` | number | Segment median score. | Benchmark rules: * **Company reports only** — person reports never include it (the block is omitted). * **The industry comes from the tax registry** and is stamped on the subject (the latest known value wins). * **The comparable population is the latest score of each company** in the same country and industry, excluding the evaluated subject. * **Published only with at least 5 comparable companies** — below that, the block is omitted from the report (statistical context is never invented). * `percentile` reads as "better than N% of the segment"; `median_score` is the segment median. The report PDF includes the peer comparison section only when the block is available. ## 3. Query and history ### List reports `GET /v1/qscore/reports` lists the reports purchased by your account. `from` and `to` (dates `YYYY-MM-DD`, organization timezone, both inclusive) are **mandatory**; filters `subject_id` and `status` (`pending`, `ready`, `failed`) are optional; pagination with `page` / `page_size` (default 50, max 200). ```bash List reports theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/qscore/reports?from=2026-08-01&to=2026-08-31&status=ready&page=1&page_size=50" \ -H "Authorization: Bearer pk_live_..." ``` ```json 200 OK theme={"theme":{"light":"github-light","dark":"github-dark"}} { "items": [ { "report_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "account_id": "ae8c91f2-…", "subject_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "purpose": "credit_evaluation", "status": "ready", "lang": "en", "score": 742, "band": "B", "score_model_version": "qscore-v1", "reason_codes": [{"code": "ACTIVE_TRADELINES", "direction": "positive", "weight": "medium"}], "verify_code": "Qf47ac10b58cc4372a5670e02b2c3d4791a2b3c4d5e6f", "created_at": "2026-08-08T15:04:22Z", "completed_at": "2026-08-08T15:04:25Z" } ], "meta": {"page": 1, "page_size": 50, "total": 1} } ``` ### Report detail `GET /v1/qscore/reports/{report_id}` returns the report; when `ready` it includes the full `report` object (same shape as the creation response). ### Download the PDF `GET /v1/qscore/reports/{report_id}/pdf` downloads the branded PDF (`application/pdf`, filename `qscore_.pdf`). Until the report is `ready` it answers `404 pdf_not_ready`. The PDF is a private document: download it **authenticated** — it is never attached to emails nor exposed on public URLs. ### Subject file and current score (without buying a new report) `GET /v1/qscore/subjects/{doc_id}?country=CL` returns the subject file (identity + latest score) for a document you already reported on: ```json 200 OK (subject file) theme={"theme":{"light":"github-light","dark":"github-dark"}} { "subject_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "country": "CL", "doc_id": "11111111-1", "subject_type": "person", "display_name": "Juan Pérez González", "last_score": 742, "last_band": "B", "last_score_at": "2026-08-08T15:04:25Z" } ``` `GET /v1/qscore/subjects/{doc_id}/score?country=CL` returns just the current score (`404 no_score` if the subject has none yet): ```json 200 OK (current score) theme={"theme":{"light":"github-light","dark":"github-dark"}} { "subject_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "doc_id": "11111111-1", "country": "CL", "band": "B", "model_version": "qscore-v1", "computed_at": "2026-08-08T15:04:25Z", "score": 742 } ``` ## 4. Report statuses | Status | Meaning | What to do | | --------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | | `pending` | Report created, generation in progress (transient inside the synchronous call) | Nothing — the `POST` response carries the final state | | `ready` | Report generated: score, full JSON and PDF available (final) | Read the JSON, download the PDF, share the verification link | | `failed` | Generation failed; the fee was **refunded** (final) | Read `error_code` / `error_message`, fix the cause, buy a new report with a **new** `idempotency_key` | ## 5. Errors | HTTP | Code | When | Solution | | ---- | -------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `invalid_payload` | `doc_id`/`country` missing or malformed JSON | Send both fields with a valid JSON body | | 400 | `purpose_required` | `purpose` missing | Declare the purpose (data protection law) | | 400 | `invalid_purpose` | `purpose` outside the closed list, or `self_access` sent to the commercial endpoint | Use `credit_evaluation`, `tenant_screening`, `hiring`, `supplier_onboarding` or `other` — self access goes through `POST /v1/qscore/my-report` | | 400 | `invalid_doc_id` | The document is not valid for the country (e.g. bad RUT check digit) | Fix the `doc_id` format for the country | | 400 | `invalid_subject_type` | `subject_type` is not `person`/`company` and could not be inferred | Send `subject_type` explicitly | | 400 | `idempotency_key_required` | Missing `idempotency_key` | Send a unique key per purchase | | 404 | `not_found` | Report/subject does not exist (or belongs to another account) | Check the ID | | 404 | `no_score` | The subject has no computed score yet | Buy a report first | | 404 | `pdf_not_ready` | The report is not `ready` yet | Poll the detail until `status=ready` | | 502 | `generation_failed` | The report could not be generated after charging | The fee was refunded; retry later or contact support | See the full catalogue in [Errors](/en/errors). ## 6. Webhooks Subscribe to the Qscore events in your [webhook settings](/en/webhooks). All three are account-audience events, signed like every other webhook. ```json risk_report_ready theme={"theme":{"light":"github-light","dark":"github-dark"}} { "report_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "subject_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "doc_id": "11111111-1", "country": "CL", "subject_type": "person", "score": 742, "band": "B", "verify_code": "Qf47ac10b58cc4372a5670e02b2c3d4791a2b3c4d5e6f" } ``` A self report (see "Your own report (self)") emits the same event with an extra `"purpose": "self_access"` field; commercial reports omit it. Fired when a new report computes a score different from the subject's previous one. ```json risk_score_changed theme={"theme":{"light":"github-light","dark":"github-dark"}} { "subject_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "report_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "old_score": 715, "new_score": 742, "old_band": "B", "new_band": "B" } ``` Fired for every active monitoring subscription when the subject's score drops below your `monitor_since_score` floor, new bureau records appear, or records are removed. The first evaluation after subscribing only seeds the baseline and never alerts. ```json risk_monitoring_alert theme={"theme":{"light":"github-light","dark":"github-dark"}} { "monitoring_id": "2f7b1c94-8d3a-4c5e-9f01-6a7b8c9d0e11", "subject_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "doc_id": "11111111-1", "country": "CL", "subject_type": "person", "triggers": ["score_drop_below", "new_records"], "previous_score": 688, "score": 612, "band": "C", "record_count": 4, "new_records": [ { "source": "res_chile", "record_type": "debt_collection", "reported_at": "2026-08-08", "amount": "350000", "currency": "CLP", "status": "open" } ], "detected_at": "2026-08-08T16:30:00Z" } ``` ## 7. Public verification Every report PDF prints a **verification code** and URL. Anyone holding the code can check the report's authenticity — without PII — at `GET /verify/qscore/{code}` (no auth): ```json 200 OK (valid report) theme={"theme":{"light":"github-light","dark":"github-dark"}} { "valid": true, "type": "verification_report", "kind": "qscore", "status": "ready", "decision": "B", "date": "2026-08-08", "issued_by": "CBPay" } ``` An invalid or tampered code answers `404` with `{"valid": false, ...}`. The endpoint is rate-limited per IP and reveals nothing beyond validity, band and date. ## 8. ARCO disputes Data subjects can exercise their ARCO rights (access, rectification, cancellation, opposition). Your account opens a dispute against a specific record of a subject: ```bash Open a dispute theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST "https://api.qbank.cl/platform/v1/qscore/subjects/11111111-1/disputes?country=CL" \ -H "Authorization: Bearer pk_live_..." \ -H "Content-Type: application/json" \ -d '{ "record_source": "res_chile", "record_ref": "RES-2026-04512", "reason": "The reported delinquency was paid on 2026-07-30", "report_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479" }' ``` ```json 201 Created theme={"theme":{"light":"github-light","dark":"github-dark"}} { "dispute_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "subject_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "report_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "record_source": "res_chile", "record_ref": "RES-2026-04512", "reason": "The reported delinquency was paid on 2026-07-30", "status": "open", "created_by": "ae8c91f2-…", "created_at": "2026-08-08T16:11:00Z" } ``` Dispute lifecycle: `open` → `under_review` → `resolved_corrected` | `resolved_rejected` (final). List them with `GET /v1/qscore/subjects/{doc_id}/disputes?country=CL&status=open` (paginated) and read one with `GET /v1/qscore/disputes/{dispute_id}`. Resolution is handled by your org admin from the admin panel. ## 9. Monitoring Once you own a `ready` report on a subject, subscribe to **continuous monitoring** and receive a `risk_monitoring_alert` webhook every time something relevant changes: the score drops below your threshold, new bureau records appear, or records are removed. Monitoring is **free** — the only requirement is the purchased report (the same policy as the score endpoint: nobody watches a third party without paying to know them first). ```bash Subscribe (or update thresholds) theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X PUT "https://api.qbank.cl/platform/v1/qscore/subjects/11111111-1/monitoring" \ -H "Authorization: Bearer pk_live_..." \ -H "Content-Type: application/json" \ -d '{ "country": "CL", "monitor_since_score": 640, "only_material": true }' ``` ```json 200 OK theme={"theme":{"light":"github-light","dark":"github-dark"}} { "monitoring_id": "2f7b1c94-8d3a-4c5e-9f01-6a7b8c9d0e11", "subject_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "doc_id": "11111111-1", "country": "CL", "subject_type": "person", "active": true, "only_material": true, "monitor_since_score": 640, "last_score": 688, "last_record_count": 3, "created_at": "2026-08-08T16:20:00Z", "last_checked_at": "2026-08-08T16:25:00Z" } ``` * `monitor_since_score` (optional, 1–999): alert when the score falls below this threshold (`score_drop_below` trigger). * `only_material` (default `false`): when `true`, only material changes fire the alert. * The worker re-evaluates every monitored subject every **\~5 minutes**. The first pass only seeds the baseline — it never alerts on data you already saw in the report you paid for. Read one subscription with `GET /v1/qscore/subjects/{doc_id}/monitoring`, list every monitored subject of the account with `GET /v1/qscore/monitoring?active=true&page=1&page_size=50` (paginated: `items`, `page`, `page_size`, `total`), and deactivate with `DELETE`: ```bash Deactivate monitoring theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X DELETE "https://api.qbank.cl/platform/v1/qscore/subjects/11111111-1/monitoring?country=CL" \ -H "Authorization: Bearer pk_live_..." ``` ```json 200 OK theme={"theme":{"light":"github-light","dark":"github-dark"}} { "subject_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "doc_id": "11111111-1", "active": false } ``` `DELETE` deactivates (`active: false`) — the subscription history is never deleted, and a new `PUT` reactivates it with fresh thresholds. Without a purchased `ready` report for the subject, `PUT` answers `403 report_required` — the **same** response a non-existent subject gets, by design, so the endpoint never reveals whether a document exists in the bureau. See [errors](/en/errors). The alert payload (`risk_monitoring_alert`) carries the triggers (`score_drop_below`, `new_records`, `records_removed`), the current and previous score, the band and the new records — full example in [webhooks](/en/webhooks). ## 10. Batch scoring (portfolios) To score a whole portfolio instead of one subject at a time, submit a **batch** with `POST /v1/qscore/batches`: up to 5,000 subjects (JSON or CSV), one shared `country` and `purpose`, and an estimated fee computed up front. The API answers `202 Accepted` immediately and a background worker generates the individual reports one by one — each item is a standard Qscore report with its own PDF, fee and automatic refund if its generation fails. * **Terminal fan-out**: when the batch finishes you receive exactly **one** `risk_batch_completed` webhook and **one** summary email (never one per subject). * **Follow-up**: list and inspect batches, page through their items and download the consolidated CSV at `GET /v1/qscore/batches/{id}/results.csv`. The full flow (mermaid diagram, per-item rejection, statuses, errors and FAQ) lives in the [batch scoring guide](/en/guides/qscore-batch). ## FAQ Yes. Every purchase fetches the sources live and recomputes the score with the current `qscore-v1` model. If a source is down, the report is generated with persisted data and the source is declared `cached`/`unavailable` in the `sources` section — never silently. The fee is refunded automatically in the same flow and the report ends `failed` with its `error_code`. Your `idempotency_key` replays to that failed report; to try again, use a new key. Chilean data protection law requires a declared, legitimate purpose to query a person's or company's credit data. It is stored with the report and printed in it (auditability for the data subject). Yes — if you already purchased a report on that subject, `GET /v1/qscore/subjects/{doc_id}/score` returns the latest computed score at no extra cost. The first report on a subject is always a paid full report. No. The "report ready" email carries no attachment on purpose (third-party data minimization). The PDF only downloads authenticated from the API. Chile today (`country: "CL"`, RUT as `doc_id`). The contract is country-agnostic: new countries will work with the same endpoints once their sources are plugged in. Every \~5 minutes. The `risk_monitoring_alert` webhook only fires when something changed against the baseline (or only on material changes with `only_material: true`) — you never get paged for a no-op. # Qscore batch (portfolio scoring) Source: https://docs.cbpayapp.com/en/guides/qscore-batch Score a whole portfolio in one asynchronous batch: upload up to 5,000 subjects by JSON or CSV, get one webhook and one email when the batch finishes, and download every score as JSON or CSV. Batch scoring is the high-volume flavor of [Qscore](/en/guides/qscore): instead of requesting one credit report at a time, you submit a **batch** of subjects (Chilean RUTs today) and CBPay generates a full Qscore report for each one **asynchronously**. When the batch finishes you receive **one** webhook and **one** email with the counters — never one notification per subject. Use it to re-score an existing portfolio (monthly refresh of your debtors), to run a one-time due-diligence sweep over a list of suppliers, or to backfill scores after onboarding a new book of business. For one-off checks on a single subject, keep using the [individual report](/en/guides/qscore). ## How it works ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} sequenceDiagram participant C as Your system participant P as CBPay API participant W as Batch worker participant H as Your webhook C->>P: POST /v1/qscore/batches (JSON or CSV + idempotency_key) P->>P: Validate rows (RUT, duplicates) P-->>C: 202 Accepted (batch_id, estimated_fee_usdt, rejected_items) Note over W: Runs in the background, item by item W->>P: Full Qscore report per subject (bureau fetch included) W->>H: Webhook risk_batch_completed (one, with counters) W-->>C: One completion email (counters + link, no scores) C->>P: GET /v1/qscore/batches/{id}/items or results.csv P-->>C: Score, band and verify_code per subject ``` The batch is accepted immediately (`202`) and processed by a background worker. Each item goes through the **same pipeline as an individual report** — including the on-demand bureau fetch — so a batch score is identical to the score you would get one by one, with the same deterministic model (1–999, bands A–E, `SC` when the subject has no data). ## Step by step Send `POST /v1/qscore/batches` with the subjects as a JSON array **or** as CSV text (`subjects_csv`). Every request needs an `idempotency_key`: a replay with the same key returns the original batch with `idempotency_hit: true` and never duplicates the batch or its charges. Invalid rows are **rejected at creation time** and reported in `rejected_items` — the batch only processes the valid ones. A `doc_id` that fails the country check digit yields `invalid_doc_id`; the same `doc_id` twice inside one batch yields `duplicate_in_batch` (reported with its normalized form). An unrecognized `subject_type` is **not** an error: the row is accepted and the type is inferred as described below. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/qscore/batches \ -H "Authorization: Bearer pk_live_..." \ -H "Content-Type: application/json" \ -d '{ "country": "CL", "purpose": "credit_evaluation", "lang": "es", "subjects": [ {"doc_id": "12.345.678-5"}, {"doc_id": "15.678.234-3"}, {"doc_id": "11.222.333-9"}, {"doc_id": "76.543.210-3", "subject_type": "company"}, {"doc_id": "12.345.678-9"}, {"doc_id": "12.345.678-5"} ], "idempotency_key": "portfolio-2026-08-refresh-01" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "batch_id": "b7f2c1a4-3e5d-4f8a-9c2b-1d0e6a8f4c5d", "status": "pending", "purpose": "credit_evaluation", "country": "CL", "lang": "es", "total_items": 4, "processed_items": 0, "succeeded_items": 0, "failed_items": 0, "estimated_fee_usdt": "14.50", "created_at": "2026-08-09T14:32:10Z", "rejected_count": 2, "rejected_items": [ { "line": 5, "doc_id": "12.345.678-9", "error_code": "invalid_doc_id", "error": "doc_id is not valid for CL" }, { "line": 6, "doc_id": "12345678-5", "error_code": "duplicate_in_batch", "error": "doc_id appears more than once in the batch" } ] } ``` The estimate above assumes a configured fee of `4.00` USDT per person report and `2.50` USDT per company report: 3 × 4.00 + 1 × 2.50 = **14.50**. Your estimate reflects the fees configured for your account. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/qscore/batches \ -H "Authorization: Bearer pk_live_..." \ -H "Content-Type: application/json" \ -d '{ "country": "CL", "purpose": "supplier_onboarding", "lang": "es", "subjects_csv": "doc_id,subject_type\n12.345.678-5,person\n76.543.210-3,company\n96.123.450-6,company", "idempotency_key": "suppliers-2026-08-01" }' ``` The CSV payload is a **string field inside the JSON body** (not a file upload): a header row `doc_id[,subject_type]` followed by one subject per line, up to 5 MB. ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "batch_id": "c8a3d2b5-4f6e-5a9b-8d3c-2e1f7b9a5d6e", "status": "pending", "purpose": "supplier_onboarding", "country": "CL", "lang": "es", "total_items": 3, "processed_items": 0, "succeeded_items": 0, "failed_items": 0, "estimated_fee_usdt": "9.00", "created_at": "2026-08-09T15:04:44Z", "rejected_count": 0, "rejected_items": [] } ``` | Field | Type | Rules | | ----------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `country` | string | Required. `CL` today. | | `purpose` | string | Required. Closed list (Ley 20.575): `credit_evaluation`, `tenant_screening`, `hiring`, `supplier_onboarding`, `other`. `self_access` is **not allowed** in batches — the holder's own report is free via [my-report](/en/guides/qscore). | | `lang` | string | `es` (default), `en` or `zh` — language of the generated PDF reports. | | `subjects` | array | 1–5,000 items: `{doc_id, subject_type?}`. XOR with `subjects_csv`. | | `subjects_csv` | string | CSV text with header `doc_id[,subject_type]`, up to 5 MB. XOR with `subjects`. | | `subject_type` | string | Optional per row: `person` or `company`. If omitted **or unrecognized** for `CL`, it is inferred from the RUT series (first digit 5–9 → `company`; anything else → `person`). | | `idempotency_key` | string | **Required.** Unique per batch; the replay never duplicates. | An idempotent replay returns `200` (not `202`) with the original batch and `idempotency_hit: true`; the `rejected_items` / `rejected_count` detail is only included in the original creation response. The worker processes items one by one. You do **not** need to poll: when the batch reaches a final state you get exactly **one** `risk_batch_completed` webhook and **one** completion email with the counters and a link to your account. Reports inside a batch never emit the individual `risk_report_ready` webhook or per-report emails — **the batch is the signal**. If you still want to poll, `GET /v1/qscore/batches/{id}` returns the live counters (`processed_items`, `succeeded_items`, `failed_items`). ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/qscore/batches/b7f2c1a4-3e5d-4f8a-9c2b-1d0e6a8f4c5d" \ -H "Authorization: Bearer pk_live_..." ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "batch_id": "b7f2c1a4-3e5d-4f8a-9c2b-1d0e6a8f4c5d", "status": "completed_with_errors", "purpose": "credit_evaluation", "country": "CL", "lang": "es", "total_items": 4, "processed_items": 4, "succeeded_items": 3, "failed_items": 1, "estimated_fee_usdt": "14.50", "created_at": "2026-08-09T14:32:10Z", "started_at": "2026-08-09T14:34:02Z", "completed_at": "2026-08-09T14:41:37Z" } ``` Per-item results are available as JSON (paginated) or as a CSV export ready for Excel. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/qscore/batches/b7f2c1a4-3e5d-4f8a-9c2b-1d0e6a8f4c5d/items?status=ready&page=1&page_size=50" \ -H "Authorization: Bearer pk_live_..." ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "items": [ { "item_id": "e1f0a9b8-7c6d-4e5f-8a9b-0c1d2e3f4a5b", "report_id": "3f5c9f2d-7d21-4b8c-9a2d-2d5f6a1b8c01", "doc_id": "12.345.678-5", "subject_type": "person", "status": "ready", "score": 715, "band": "B", "created_at": "2026-08-09T14:32:10Z", "completed_at": "2026-08-09T14:34:52Z" }, { "item_id": "f2a1b0c9-8d7e-4f6a-9b0c-1d2e3f4a5b6c", "report_id": "7c9a1f3d-2e44-4b8a-9d51-0a1b2c3d4e5f", "doc_id": "15.678.234-3", "subject_type": "person", "status": "ready", "score": 430, "band": "D", "created_at": "2026-08-09T14:32:10Z", "completed_at": "2026-08-09T14:35:41Z" }, { "item_id": "a3b2c1d0-9e8f-4a7b-8c1d-2e3f4a5b6c7d", "report_id": "9d0e1f2a-3b4c-4d5e-8f6a-7b8c9d0e1f2a", "doc_id": "76.543.210-3", "subject_type": "company", "status": "ready", "score": 604, "band": "C", "created_at": "2026-08-09T14:32:10Z", "completed_at": "2026-08-09T14:36:22Z" } ], "meta": {"page": 1, "page_size": 50, "total": 3} } ``` For a failed item, `score` is `null` and the row carries `error_code` / `error_message` instead: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "item_id": "b4c3d2e1-0f9a-4b8c-9d2e-3f4a5b6c7d8e", "doc_id": "11.222.333-9", "subject_type": "person", "status": "failed", "score": null, "error_code": "generation_failed", "error_message": "the report could not be generated; the fee was refunded", "created_at": "2026-08-09T14:32:10Z", "completed_at": "2026-08-09T14:37:05Z" } ``` The CSV export (`GET /v1/qscore/batches/{id}/results.csv`) streams every row with a UTF-8 BOM so Excel opens it correctly, and includes the public `verify_code` of each report. Every cell is sanitized against CSV formula injection. You can download it at **any time**, including while the batch is still `processing` — rows for items still `pending` have empty score/band/verify\_code fields: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -OJ "https://api.qbank.cl/platform/v1/qscore/batches/b7f2c1a4-3e5d-4f8a-9c2b-1d0e6a8f4c5d/results.csv" \ -H "Authorization: Bearer pk_live_..." ``` ```csv theme={"theme":{"light":"github-light","dark":"github-dark"}} doc_id,subject_type,status,score,band,verify_code,report_id,error_code 12.345.678-5,person,ready,715,B,Q3f5c9f2d7d214b8c9a2d2d5f6a1b8c01a1b2c3d4e5f60718293a,3f5c9f2d-7d21-4b8c-9a2d-2d5f6a1b8c01, 15.678.234-3,person,ready,430,D,Q7c9a1f3d2e444b8a9d510a1b2c3d4e5fb2c3d4e5f60718293a4b5,7c9a1f3d-2e44-4b8a-9d51-0a1b2c3d4e5f, 76.543.210-3,company,ready,604,C,Q9d0e1f2a3b4c4d5e8f6a7b8c9d0e1f2ac3d4e5f60718293a4b5c6,9d0e1f2a-3b4c-4d5e-8f6a-7b8c9d0e1f2a, 11.222.333-9,person,failed,,,,,generation_failed ``` Each `report_id` is a full individual report: you can download its PDF with the standard [report download](/en/guides/qscore) endpoint, and anyone can verify its authenticity at `https://business.cbpayapp.com/verify/qscore/{verify_code}`. ## List and search your batches `GET /v1/qscore/batches` returns your batches paginated, with optional `from`/`to` date filters (`YYYY-MM-DD`, organization timezone, both inclusive — an invalid date yields `400 invalid_range`): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/qscore/batches?page=1&page_size=50&from=2026-08-01&to=2026-08-09" \ -H "Authorization: Bearer pk_live_..." ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "items": [ { "batch_id": "b7f2c1a4-3e5d-4f8a-9c2b-1d0e6a8f4c5d", "account_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "status": "completed_with_errors", "purpose": "credit_evaluation", "country": "CL", "lang": "es", "total_items": 4, "processed_items": 4, "succeeded_items": 3, "failed_items": 1, "estimated_fee_usdt": "14.50", "created_at": "2026-08-09T14:32:10Z", "started_at": "2026-08-09T14:34:02Z", "completed_at": "2026-08-09T14:41:37Z" } ], "meta": {"page": 1, "page_size": 50, "total": 1} } ``` Pagination is `page` + `page_size` (default 50, maximum 200). ## Batch and item statuses **Batch** (`GET /v1/qscore/batches/{id}`): | Status | Meaning | Terminal? | | ----------------------- | ------------------------------------------------------------------------------- | --------- | | `pending` | Accepted, waiting for the worker | No | | `processing` | The worker is generating reports item by item | No | | `completed` | Every item finished successfully | Yes | | `completed_with_errors` | Finished, but at least one item failed (failed items were refunded) | Yes | | `failed` | The batch itself failed (infrastructure) — check `error_code` / `error_message` | Yes | **Item** (`GET /v1/qscore/batches/{id}/items`): | Status | Meaning | | --------- | ------------------------------------------------------------------------------- | | `pending` | Queued, not processed yet | | `ready` | Report generated — `score`, `band` and `report_id` are set | | `failed` | Terminal failure for this subject — the item fee was **refunded automatically** | ## Billing Each item charges the standalone fee configured for your account (`risk_report_person` or `risk_report_company`) when the worker processes it. `estimated_fee_usdt` in the creation response is the upfront estimate for the valid items. * **Idempotent charges**: every item is charged with a deterministic billing reference derived from the batch and the item, so a worker restart never double-charges an item. * **Automatic refunds**: an item that ends `failed` gets its fee refunded in the same run. You only pay for reports that were actually generated. * Charges and refunds appear in your [statement](/en/guides/statement) like any other Qscore fee. ## Errors | HTTP | Code | What to do | | ---- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `invalid_payload` | The body is not valid JSON, `country` is missing, you sent both `subjects` and `subjects_csv` (send exactly one), or the CSV text is malformed / over 5 MB / has no rows | | 400 | `purpose_required` / `invalid_purpose` | Send a `purpose` from the closed list; `self_access` is not allowed in batches | | 400 | `idempotency_key_required` | Every batch creation needs an `idempotency_key` | | 400 | `no_valid_items` | Every row was rejected (`invalid_doc_id` / `duplicate_in_batch`) and **no batch was created** — the response is the standard error shape; validate the file locally (each `doc_id` must pass the country check digit and be unique) and resubmit with a **new** idempotency key | | 400 | `too_many_items` | A batch accepts at most 5,000 subjects — split the portfolio into several batches, each with its own idempotency key | | 400 | `invalid_range` | A `from`/`to` date on a listing endpoint is not `YYYY-MM-DD` — fix the format and retry | | 401 | `unauthorized` | Missing or invalid API key | | 403 | `verification_required` | Your account identity verification (KYC/KYB) is not approved yet — complete it before creating batches | | 403 | `service_disabled` | The `risk` product is not enabled for your account — contact your organization admin | | 404 | `not_found` | The batch does not exist or belongs to another account | ## Webhook: `risk_batch_completed` Exactly **one** webhook per batch, delivered to the subscriptions of the owning account when the batch reaches a final state. Subscribe with event type `risk_batch_completed` (see [webhooks](/en/webhooks)). The event type travels in the `X-Webhook-Event` header and the body is the flat payload: ```http theme={"theme":{"light":"github-light","dark":"github-dark"}} POST https://your-server.example/webhooks/cbpay X-Webhook-Event: risk_batch_completed X-Webhook-Signature: t=1754764330,v1=… Content-Type: application/json ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "batch_id": "b7f2c1a4-3e5d-4f8a-9c2b-1d0e6a8f4c5d", "status": "completed_with_errors", "total_items": 4, "succeeded_items": 3, "failed_items": 1, "country": "CL", "purpose": "credit_evaluation" } ``` The webhook carries **counters only** — never scores or documents. Fetch the results with `GET /v1/qscore/batches/{id}/items` or the CSV export. The completion email follows the same data-minimization rule: counters and a link, nothing else. ## FAQ It depends on the size and on how fresh the bureau data is for each subject. Each item is a full report (including the on-demand bureau fetch), so plan for a few seconds per item; a 1,000-subject batch typically finishes in well under an hour. You do not need to wait online — the webhook tells you when it is done. No. The worker runs the exact same pipeline as an individual report, with the same deterministic model version. Scoring the same subject individually or inside a batch yields the same result at the same point in time. Split it into several batches of up to 5,000 each, with a distinct `idempotency_key` per batch. Batches are processed independently and each sends its own completion webhook. Yes. Declare `subject_type` per row or let the API infer it from the Chilean RUT series. Each item is billed with the fee that matches its type. Processing is crash-safe: the worker resumes the batch where it left off, and the deterministic billing reference guarantees an item is never charged twice. Not in this version. A batch that is already `processing` runs to completion; items that fail are refunded automatically. Only your account — any other account gets `404 not_found`. Batches are never visible across organizations. The individual reports generated by a batch are regular Qscore reports, so they show up in the same places as any other report (including your organization's admin view of reports). # Consent links (holder authorization) Source: https://docs.cbpayapp.com/en/guides/qscore-consents Ask a person or company to authorize access to their banking data with a shareable link: the holder opens it, connects their bank, and CBPay derives positive facts that feed the Qscore. Create, track and revoke consent links by API. ## What it is and when to use it A **consent link** is a URL you create for a subject (a person or a company identified by their document) so the **holder** can authorize read access to their banking data through a secure connection flow. Once the holder grants it, CBPay derives **positive facts** (accounts, balances, income and expense activity over the last 90 days) and feeds them to the subject's credit file — the Qscore reflects them on the next report. Use it when the subject has little or no credit history and their banking activity is the strongest evidence of their real payment capacity — for example a tenant with no bureau record, or a supplier asking for better commercial terms. * **You** create the link (optionally emailed to the holder, with your organization's branding). * **The holder** opens it, sees your brand and the declared purpose, connects their bank through the secure widget and confirms — or declines. * **CBPay** validates that the bank-verified document matches the subject's `doc_id` **exactly** (an account owned by a different document can never grant the consent), derives the facts and notifies you by webhook. ## How the flow works ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} sequenceDiagram autonumber participant You as Your system participant CB as CBPay participant H as Holder participant B as Banking aggregator You->>CB: POST /v1/qscore/consents (country, doc_id, purpose) CB-->>You: 201 consent (consent_url) opt email provided CB->>H: Branded email with the link end You->>H: (or you share the link yourself) H->>CB: GET /platform/consent/{token} CB-->>H: Brand, purpose, masked document H->>CB: POST /begin CB->>B: Create link session CB-->>H: widget_token + public_key H->>B: Connects bank (widget) → exchange_token H->>CB: POST /complete (exchange_token) CB->>B: Exchange + verify holder identity CB-->>H: 200 granted CB->>You: Webhook risk_consent_granted Note over CB: Facts derived and persisted
(next Qscore report includes them) ``` The link is a **capability URL**: the 128-bit token in it is the authorization to view and decide. It works without any login, shows only your brand, the purpose and the holder's masked document (last 4 characters), and expires after a TTL you choose (7 days by default, 30 maximum). ## Step by step `POST /v1/qscore/consents` — requires the `risk` service flag and a verified account. The `idempotency_key` is **mandatory**: creating sends an email when `email` is provided, and a retry with the same key returns the original consent with `idempotency_hit: true` instead of creating a duplicate. ```json Request theme={"theme":{"light":"github-light","dark":"github-dark"}} { "country": "CL", "doc_id": "11111111-1", "subject_type": "person", "purpose": "tenant_screening", "email": "maria.torres@example.cl", "expires_in_days": 7, "idempotency_key": "consent-maria-torres-2026-08-09" } ``` ```json Response 201 theme={"theme":{"light":"github-light","dark":"github-dark"}} { "consent_id": "9f2c1ab4-7d3e-4c1a-8f55-2b9e0c4d6a71", "subject_id": "5d2a8f19-3b7c-4e92-a1d4-6c8b0f2e5a93", "channel": "link", "status": "pending", "purpose": "tenant_screening", "consent_url": "https://api.qbank.cl/platform/consent/cns_3f8a1c94e2b745109d6f8a0c2e5b7d19", "email": "maria.torres@example.cl", "created_at": "2026-08-09T14:32:10Z", "updated_at": "2026-08-09T14:32:10Z", "expires_at": "2026-08-16T14:32:10Z" } ``` * `country` — ISO alpha-2, required. Coverage today: `CL`. * `doc_id` — required, validated with the country's check digit (Chilean RUT, e.g. `11111111-1`). * `subject_type` — `person` or `company`; inferred from the document if omitted. * `purpose` — required: `credit_evaluation`, `tenant_screening`, `hiring`, `supplier_onboarding` or `other`. Data protection law requires declaring it. `self_access` is **rejected** here — your own report goes through `POST /v1/qscore/my-report`. * `email` — optional; if present, the holder receives the link in a branded email from your organization. * `expires_in_days` — optional; default 7, maximum 30. Share `consent_url` with the holder (or let the email deliver it). The public page first loads `GET /platform/consent/{token}` (no authentication) to show your brand, the declared purpose and the masked document: ```json Response 200 theme={"theme":{"light":"github-light","dark":"github-dark"}} { "status": "pending", "purpose": "tenant_screening", "country": "CL", "subject_type": "person", "doc_id": "******11-1", "org": { "name": "Arriendos del Sur", "website": "https://arriendosdelsur.cl", "logo_url": "https://cdn.cbpayapp.com/org/arriendos-del-sur/logo.png" }, "expires_at": "2026-08-16T14:32:10Z" } ``` The public view never exposes the holder's email, full document, internal IDs or the token itself. Choosing **Authorize** calls `POST /platform/consent/{token}/begin`, which opens a secure bank-connection session: ```json Response 200 theme={"theme":{"light":"github-light","dark":"github-dark"}} { "widget_token": "wgt_6f1c9a2d8e4b4c0a9f3d5e7b1a2c4d6e", "public_key": "wpk_9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a", "expires_at": "2026-08-09T14:47:10Z" } ``` The page mounts the banking widget with those credentials; the holder authenticates with their bank and authorizes the connection. The widget returns an `exchange_token` to the page. The page sends `POST /platform/consent/{token}/complete` with the `exchange_token`: ```json Request theme={"theme":{"light":"github-light","dark":"github-dark"}} { "exchange_token": "ext_2b4d6f8a0c1e3a5c7e9b1d3f5a7c9e1b" } ``` ```json Response 200 theme={"theme":{"light":"github-light","dark":"github-dark"}} { "status": "granted", "granted_at": "2026-08-09T14:46:02Z", "holder_name": "María Torres" } ``` Before sealing the grant, CBPay verifies two things and rejects otherwise: * the banking connection is `active` — otherwise `409 link_inactive`; * the document verified by the bank matches the subject's `doc_id` **exactly** (both normalized) — otherwise `409 holder_mismatch`. Once granted, CBPay derives the positive facts in the background and emits the `risk_consent_granted` webhook. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/qscore/consents?status=pending&from=2026-08-01&to=2026-08-31&page=1&page_size=50" \ -H "Authorization: Bearer pk_live_..." ``` ```json Response 200 theme={"theme":{"light":"github-light","dark":"github-dark"}} { "consents": [ { "consent_id": "9f2c1ab4-7d3e-4c1a-8f55-2b9e0c4d6a71", "subject_id": "5d2a8f19-3b7c-4e92-a1d4-6c8b0f2e5a93", "channel": "link", "status": "pending", "purpose": "tenant_screening", "consent_url": "https://api.qbank.cl/platform/consent/cns_3f8a1c94e2b745109d6f8a0c2e5b7d19", "email": "maria.torres@example.cl", "created_at": "2026-08-09T14:32:10Z", "updated_at": "2026-08-09T14:32:10Z", "expires_at": "2026-08-16T14:32:10Z" } ], "total": 1, "page": 1, "page_size": 50 } ``` `GET /v1/qscore/consents/{id}` returns a single consent (another account's consent answers `404 not_found`). A `pending` link past its `expires_at` flips to `expired` the next time it is read. `POST /v1/qscore/consents/{id}/revoke` cancels a consent (for example when the operation fell through). A consent that was already granted, revoked or expired answers `409 already_decided`. Revoking emits the `risk_consent_revoked` webhook. ```json Response 200 theme={"theme":{"light":"github-light","dark":"github-dark"}} { "consent_id": "9f2c1ab4-7d3e-4c1a-8f55-2b9e0c4d6a71", "subject_id": "5d2a8f19-3b7c-4e92-a1d4-6c8b0f2e5a93", "channel": "link", "status": "revoked", "purpose": "tenant_screening", "consent_url": "https://api.qbank.cl/platform/consent/cns_3f8a1c94e2b745109d6f8a0c2e5b7d19", "email": "maria.torres@example.cl", "created_at": "2026-08-09T14:32:10Z", "updated_at": "2026-08-09T15:05:41Z", "expires_at": "2026-08-16T14:32:10Z", "revoked_at": "2026-08-09T15:05:41Z" } ``` ## States | State | Meaning | What to do | | --------- | -------------------------------------------------------- | ----------------------------------------------------------------------- | | `pending` | Created, waiting for the holder | Wait for the webhook, or share the link again | | `granted` | The holder connected their bank and the identity matched | Facts are derived automatically; generate the Qscore report | | `revoked` | The holder declined, or you revoked it | The link is dead — create a new one if you still need the authorization | | `expired` | The TTL passed without a decision | Create a new link (longer `expires_in_days` if needed) | A consent is decided **exactly once**: every terminal state rejects further transitions with `409 already_decided`. ## Errors Public (holder) endpoints share an IP throttle with the verification surfaces: **30 requests per minute per IP** — a `429 rate_limited` response means slow down. A nonexistent or malformed token always answers a generic `404 not_found` (anti-enumeration). | HTTP | `error` | Solution | | ---- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `invalid_payload` | Missing/invalid body — check `country`, `doc_id` and `exchange_token` formats | | 400 | `purpose_required` | `purpose` is mandatory when creating a link — declare it | | 400 | `invalid_purpose` | `purpose` must be one of `credit_evaluation`, `tenant_screening`, `hiring`, `supplier_onboarding`, `other`; `self_access` is rejected here (use `POST /v1/qscore/my-report` for your own data) | | 400 | `invalid_doc_id` | The document fails the country's validation (bad check digit) — fix the format | | 400 | `invalid_subject_type` | Send `person` or `company` explicitly | | 400 | `invalid_email` | The `email` is malformed — fix it or omit it (the link works without an email) | | 400 | `idempotency_key_required` | The create call requires an `idempotency_key` — send one (a retry with the same key never duplicates the link nor re-sends the email) | | 403 | `verification_required` | Your account needs an approved KYC/KYB before creating consent links | | 404 | `not_found` | No consent with that id/token (also the answer for another account's consent) | | 409 | `already_decided` | The link was already decided (`granted`, `revoked`, `expired`) — create a new one | | 409 | `link_inactive` | The banking connection is not `active` — the holder must reconnect from the same link | | 409 | `holder_mismatch` | The bank-verified document does not match the subject's `doc_id` — check you created the link for the right document | | 429 | `rate_limited` | Public endpoints throttle — slow down | | 502 | `provider_error` | The data provider could not create or complete the bank session — retry; if it persists, contact support | | 503 | `org_credential_missing` | Your organization is not fully configured for this feature — contact CBPay support | See the [error catalog](/en/errors) for the full list. ## Webhooks Subscribe to these events to get notified when the holder decides: | Event | Fires when | | ---------------------- | ----------------------------------------------------------- | | `risk_consent_granted` | The holder connected their bank and the consent was granted | | `risk_consent_revoked` | The holder declined, or the consent was revoked by API | ```json risk_consent_granted theme={"theme":{"light":"github-light","dark":"github-dark"}} { "event_type": "risk_consent_granted", "consent_id": "9f2c1ab4-7d3e-4c1a-8f55-2b9e0c4d6a71", "subject_id": "5d2a8f19-3b7c-4e92-a1d4-6c8b0f2e5a93", "country": "CL", "doc_id": "11111111-1", "subject_type": "person", "purpose": "tenant_screening", "status": "granted", "previous_status": "pending", "holder_name": "María Torres", "openfinance_link_id": "lnk_8f7e6d5c4b3a29180f7e6d5c4b3a2918", "granted_at": "2026-08-09T14:46:02Z" } ``` ```json risk_consent_revoked theme={"theme":{"light":"github-light","dark":"github-dark"}} { "event_type": "risk_consent_revoked", "consent_id": "9f2c1ab4-7d3e-4c1a-8f55-2b9e0c4d6a71", "subject_id": "5d2a8f19-3b7c-4e92-a1d4-6c8b0f2e5a93", "country": "CL", "doc_id": "11111111-1", "subject_type": "person", "purpose": "tenant_screening", "status": "revoked", "previous_status": "pending", "revoked_at": "2026-08-09T15:05:41Z" } ``` The `doc_id` travels **full** in the webhook (it is your own account's data), so you can reconcile against the subject you created the link for. ## How it feeds the Qscore Granting a consent triggers a background derivation: CBPay reads the link's accounts and activity (last 90 days), aggregates positive facts — accounts count, available and current balances, income and expense totals — and persists them to the subject's credit file. Raw movements are never stored nor exposed (data minimization). Every Qscore report generated afterwards re-derives the subject's `granted` consents, so the positive data is fresh in each report. No extra call is needed on your side. ## FAQ No. The link is fully public and works without login — the 128-bit token in the URL is the authorization. The holder only sees your brand, the purpose and their masked document. The grant is rejected with `409 holder_mismatch`: the document verified by the bank must match the subject's `doc_id` exactly. An account owned by a different document can never grant the consent — this is the identity proof of the flow. A granted consent is a terminal state and rejects transitions (`409 already_decided`). To stop using the data, stop generating reports for the subject; the banking connection itself is managed by the holder at their bank. 7 days by default, configurable with `expires_in_days` up to 30. An expired link flips to `expired` and can no longer be used — create a new one. No. Without `email` you get the `consent_url` in the response and share it yourself (WhatsApp, SMS, your own email). With `email`, CBPay sends a branded email on your behalf. Either way the create call needs an `idempotency_key`. Coverage today: Chile (`CL`). More corridors are added as banking aggregation becomes available in each country — creating a link for an uncovered country fails at connect time with `502 provider_error`. Aggregated facts only: number of accounts, available/current balance totals, currency, institutions, first observation date, and income/expense/movement totals over 90 days. Individual transactions are never stored nor exposed. # Qscore seal (verified badge) Source: https://docs.cbpayapp.com/en/guides/qscore-seal Let your company display a public, verifiable Qscore seal: a verification page and an embeddable SVG badge that always shows your current credit band — or nothing at all. The **Qscore seal** is a public, verifiable badge that a company with a strong credit standing can display on its website, quotes and emails: a public verification page plus an embeddable SVG badge that shows the company's **current** Qscore band (A or B). It is free, self-service, and radically honest by design: the badge is evaluated **live** on every view. If the score drops below band B or goes stale (no evaluation in the last 90 days), the public surface stops showing the band on its own — you never have to remember to take it down, and you can never display a band you no longer hold. ## When to use it * You are a **company account** with an approved KYB and a Qscore band of **A or B** (score ≥ 650), evaluated within the last 90 days — from any report, self or purchased by a third party. * You want to prove creditworthiness to customers, suppliers or partners with a link they can verify themselves, instead of sending PDFs. * You want the proof to **expire on its own** when it stops being true. The seal is available for **companies only**. Personal accounts already have per-report public verification codes on every Qscore report they pull (see the [Qscore guide](/en/guides/qscore)). ## How it works ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} stateDiagram-v2 [*] --> none : no seal yet none --> active : POST /v1/qscore/my-seal
(band A/B, score ≤ 90 days) active --> active : public views re-evaluate live active --> not_current : band drops below B
or score goes stale active --> revoked : DELETE /v1/qscore/my-seal revoked --> active : POST again (new seal, new code) not_current --> active : a fresh evaluation returns to A/B ``` * **Activation** creates a seal with a cryptographically signed public code. Activating twice is safe: the second call returns the existing seal (`idempotency_hit: true`). * **Every public view re-evaluates eligibility live**: the page and the badge show the band only if the seal is active **and** the subject still qualifies right now. * **Revocation is yours**: `DELETE` retires the seal permanently (that code will forever answer "revoked"). You can activate a new seal later — with a new code. ## Step 1 — Activate your seal Requires an account session (company account with approved KYB). No request body and no idempotency key: activation is naturally idempotent per subject. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/qscore/my-seal \ -H "Authorization: Bearer $SESSION_TOKEN" ``` `201 Created` — the seal is active: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "seal": { "seal_id": "c8f3e2a1-9b4d-4c7e-8a1f-2e5d6b7c8a91", "subject_id": "3e9b7c41-2f68-4a1d-8c5e-9a0d4b6f8e21", "status": "active", "created_at": "2026-08-09T14:22:10Z", "verify_code": "Sc8f3e2a19b4d4c7e8a1f2e5d6b7c8a91a1b2c3d4e5f60718", "verify_url": "https://api.qbank.cl/platform/verify/qscore/seal/Sc8f3e2a19b4d4c7e8a1f2e5d6b7c8a91a1b2c3d4e5f60718", "badge_url": "https://api.qbank.cl/platform/verify/qscore/seal/Sc8f3e2a19b4d4c7e8a1f2e5d6b7c8a91a1b2c3d4e5f60718/badge.svg" }, "subject_id": "3e9b7c41-2f68-4a1d-8c5e-9a0d4b6f8e21", "eligibility": { "eligible": true, "band": "A", "score": 831, "evaluated_at": "2026-08-07T16:45:31Z" } } ``` Calling `POST` again while the seal is active returns `200 OK` with the **same** seal and `"idempotency_hit": true` — retries never create duplicates. The account receives a branded email when the seal is activated (and another when it is revoked). ## Step 2 — Check status and eligibility ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/qscore/my-seal \ -H "Authorization: Bearer $SESSION_TOKEN" ``` `200 OK`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "subject_id": "3e9b7c41-2f68-4a1d-8c5e-9a0d4b6f8e21", "seal": { "seal_id": "c8f3e2a1-9b4d-4c7e-8a1f-2e5d6b7c8a91", "subject_id": "3e9b7c41-2f68-4a1d-8c5e-9a0d4b6f8e21", "status": "active", "created_at": "2026-08-09T14:22:10Z", "verify_code": "Sc8f3e2a19b4d4c7e8a1f2e5d6b7c8a91a1b2c3d4e5f60718", "verify_url": "https://api.qbank.cl/platform/verify/qscore/seal/Sc8f3e2a19b4d4c7e8a1f2e5d6b7c8a91a1b2c3d4e5f60718", "badge_url": "https://api.qbank.cl/platform/verify/qscore/seal/Sc8f3e2a19b4d4c7e8a1f2e5d6b7c8a91a1b2c3d4e5f60718/badge.svg" }, "eligibility": { "eligible": true, "band": "A", "score": 831, "evaluated_at": "2026-08-07T16:45:31Z" } } ``` The `eligibility` block is always **live** — use it to know whether you could activate (or keep displaying) the seal before doing anything: | `reason` (when `eligible: false`) | Meaning | | --------------------------------- | ------------------------------------------------------------------------------------------- | | `no_score` | No Qscore evaluation exists yet — pull your self report first (`POST /v1/qscore/my-report`) | | `band_too_low` | The current band is C, D or E — only A and B qualify | | `score_stale` | The last evaluation is older than 90 days — generate a fresh report | | `companies_only` | Personal accounts do not get seals (200 with `seal: null`) | A revoked seal keeps showing in the response with `status: "revoked"` and `revoked_at`, and **without** `verify_code`/`verify_url`/`badge_url`. ## Step 3 — Publish it Share the `verify_url` directly, or embed the badge on your site. The badge is a plain SVG served without credentials: ```html theme={"theme":{"light":"github-light","dark":"github-dark"}} Qscore seal ``` * The badge is **180×64**, dark background, with the band letter (A or B) while the seal is current. * If the seal stops being current, the same URL renders a **grey "NO VIGENTE" badge** — it never shows a stale band and never errors visually on your page. * `Cache-Control: public, max-age=300`: viewers may cache the image for up to 5 minutes. The badge labels are in Spanish ("VERIFICADO" / "NO VIGENTE"). * The badge URL answers `404` (empty) only for invalid or tampered codes. ## Step 4 — Revoke it (optional) Retire the seal at any time. Revocation is **permanent for that code**: the public page will forever show "seal revoked", and the badge goes grey. You can activate a new seal afterwards with a fresh code. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X DELETE https://api.qbank.cl/platform/v1/qscore/my-seal \ -H "Authorization: Bearer $SESSION_TOKEN" ``` `200 OK`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "seal": { "seal_id": "c8f3e2a1-9b4d-4c7e-8a1f-2e5d6b7c8a91", "subject_id": "3e9b7c41-2f68-4a1d-8c5e-9a0d4b6f8e21", "status": "revoked", "created_at": "2026-08-09T14:22:10Z", "revoked_at": "2026-08-09T18:03:44Z" } } ``` ## What verifiers see (public, no credentials) Anyone with the link can check the seal — JSON for machines, a branded HTML page for browsers (`Accept: text/html`): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/verify/qscore/seal/Sc8f3e2a19b4d4c7e8a1f2e5d6b7c8a91a1b2c3d4e5f60718 ``` Current seal — `200 OK`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "valid": true, "type": "qscore_seal", "seal_status": "active", "band": "A", "evaluated_at": "2026-08-07", "company_name": "Comercial Andes SpA", "doc_id": "76.543.210-3", "country": "CL" } ``` Revoked seal — `200 OK`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "valid": false, "seal_status": "revoked", "revoked_at": "2026-08-09" } ``` Seal no longer eligible (band dropped or score stale) — `200 OK`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "valid": false, "seal_status": "not_current" } ``` Invalid or tampered code — `404`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "valid": false, "seal_status": "not_current" } ``` **Anti-oracle by design.** When a seal is not current, the public surface says only `not_current` — it never reveals the band the company fell to, the score, or the reason (stale vs. dropped). The numeric score never appears on any public surface: only the band, only while deserved. Public endpoints are rate limited per IP (`429 too_many_attempts`). ## Seal states | State | Where it shows | Public surface | | ------------- | ------------------------------------------------------------------------- | --------------------------------------------------- | | `active` | Authenticated API, while eligibility holds | Band A/B + company name, document and country | | `not_current` | Public surfaces only (the seal is active but eligibility no longer holds) | Grey badge, `valid: false`, no band, no reason | | `revoked` | Everywhere | "Revoked" page with the revocation date; grey badge | ## Errors | HTTP | `error` | Solution | | ---- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | 400 | `invalid_tax_id` | The verified tax id of the account is not valid for its country — contact support to fix your verified data | | 401 | `unauthorized` | Sign in with a company account session | | 403 | `kyc_required` | Complete the identity verification (KYB) first | | 404 | `no_active_seal` | `DELETE` was called without an active seal — nothing to revoke | | 409 | `no_tax_id` | The verified account has no tax id on file — complete your verified data first | | 409 | `identity_mismatch` | The account tax id does not match the verified identity document — contact support | | 409 | `seal_companies_only` | The account is a person; seals are for companies only | | 409 | `seal_not_eligible` | Band is not A/B or the evaluation is older than 90 days — check `GET` for the live `eligibility.reason`, generate a fresh report and retry | | 429 | `too_many_attempts` | Public verification is rate limited per IP — wait a moment and retry | See the [errors page](/en/errors) for the full catalog. ## Webhooks and fees The seal emits **no webhooks** and charges **no fee** — it is a self-service surface over your own verified data, like the self credit report. State changes are delivered by branded email to the account (activation and revocation). ## FAQ No. Activating, displaying and revoking the seal is free. The Qscore evaluations behind it follow the normal rules (your self report is free every 30 days). Any of them: your own self report or a report a third party purchased about your company. The seal always reads the **latest** score on file, whatever its origin. The badge and the page turn grey / `not_current` on their own at the next view — evaluated live, at most 5 minutes of cache. When a fresh evaluation returns you to band A/B, the seal shows the band again without any action from you (as long as you did not revoke it). Yes. Revocation is permanent for the revoked code (it will always answer "revoked"), but you can activate a new seal at any time, which gets a new code and new URLs. No — seals are for companies. Personal reports carry their own public verification code printed on each report (see the [Qscore guide](/en/guides/qscore)). Never. The public page and JSON show only the band (A or B), the company name, document and country, and the evaluation date — while the seal is current. # Transaction reviews Source: https://docs.cbpayapp.com/en/guides/transaction-reviews Query and respond to transactional firewall reviews on your money operations When your organization has the **transactional firewall** enabled, some money operations (payouts, crypto withdrawals, payins or banking transfers) may be **held for manual review** before they execute — and if your organization also enabled **application review**, creating a banking profile, registering a banking third party or issuing a card can be held the same way. This guide shows you how to query those reviews and respond when information is requested. Integration testing? In the test environment (`https://cryptobank.qbank.cl/platform`, `pk_test_` keys) the firewall behaves exactly like production once your org enables it. Details in [Environments and testing](/en/environment-testing). ## What you'll see When one of your operations is held: 1. **Its status changes to `in_review`** — the operation does not execute yet. The `POST` that created it responds **`202 Accepted`** with a `review_id`. 2. **You receive a webhook** `txn_review_status_changed` with the new status. 3. **If information is requested**, you receive an email with the reason and a link to upload documents. **This is normal.** The transactional firewall is a control layer your organization enabled to meet compliance policies. Most reviews resolve within minutes or hours. ## List your reviews List your operations that are or were under review: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/me/txn-reviews?from=2026-07-01&to=2026-08-06" \ -H "Authorization: Bearer pk_..." ``` Response: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "reviews": [ { "id": "7a3f2b1c-0000-4000-8000-000000000001", "kind": "payout", "resource_id": "8b4c3d2e-1111-4111-8111-111111111111", "status": "info_requested", "amount_label": "1500.00", "asset": "USD", "country": "MX", "method": "spei", "counterparty": "Juan Pérez", "info_request": { "message": "Please upload the invoice that justifies this payment", "requested_at": "2026-08-06T15:20:00Z" }, "created_at": "2026-08-06T14:32:00Z", "updated_at": "2026-08-06T15:20:00Z" } ], "page": 1, "page_size": 50, "total": 1 } ``` ### Filters * `?status=` — `in_review`, `info_requested`, `released`, `rejected` or `all` (empty = open: `in_review` + `info_requested`). Any other value ⇒ `400 invalid_status`. * `?from=` / `?to=` — date range (`YYYY-MM-DD`, organization timezone, both inclusive). Invalid date ⇒ `400 invalid_range`. * `?page=` / `?page_size=` — pagination (default 50, max 200). ## Review detail ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/me/txn-reviews/7a3f2b1c-0000-4000-8000-000000000001 \ -H "Authorization: Bearer pk_..." ``` Response: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "review": { "id": "7a3f2b1c-0000-4000-8000-000000000001", "kind": "payout", "resource_id": "8b4c3d2e-1111-4111-8111-111111111111", "status": "info_requested", "amount_label": "1500.00", "asset": "USD", "country": "MX", "method": "spei", "counterparty": "Juan Pérez", "info_request": { "message": "Please upload the invoice that justifies this payment", "requested_at": "2026-08-06T15:20:00Z" }, "files": [ { "id": "f1e2d3c4-0000-4000-8000-0000000000aa", "review_id": "7a3f2b1c-0000-4000-8000-000000000001", "file_name": "invoice-221.pdf", "content_type": "application/pdf", "size_bytes": 482110, "uploaded_by": "account", "created_at": "2026-08-06T15:40:00Z" } ], "created_at": "2026-08-06T14:32:00Z", "updated_at": "2026-08-06T15:40:00Z" } } ``` A review belonging to another account answers `404 not_found` (never `403`, so existence is not leaked). When the review was rejected, the detail includes `decision_note` (the same text as the rejection email) and `decided_at`. **The internal reason is never exposed.** For security and to avoid compromising compliance investigations, the end-user view only shows the status and the information request message — never the internal hold reason or team notes. ## Review statuses | Status | Meaning | What to do | | ---------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | | `in_review` | The operation is being reviewed by the compliance team | Wait — no action needed | | `info_requested` | You were asked for additional information | Upload the requested documents as soon as possible | | `released` | The review approved the operation | The operation already executed (or is on its way) — for an application, the banking profile or card was created | | `rejected` | The review rejected the operation | The operation was cancelled; any held funds were returned to your balance — and a rejected application refunds its fee | **Automatic rejection by deadline.** If your organization configured a review deadline, a review nobody decides within that window (counted from its last status change — uploading evidence resets the clock) is **automatically rejected** by an hourly sweep: the operation is cancelled, held funds return to your balance, and you receive the same email and `txn_review_status_changed` webhook as with a manual rejection. On the detail, the `decision_note` carries the standard deadline notice. **Application reviews are never auto-rejected**: a held banking or card application always waits for a human decision, with no deadline. ## Held applications (banking and cards) If your organization enabled **application review** (two separate toggles, one for banking and one for cards), these requests can also be held before they are processed: * `POST /v1/banking/customer` — opening your own banking profile * `POST /v1/banking/third-parties` — registering a third party for banking * `POST /v1/cards` — issuing a card (virtual or physical) A held application answers **`202 Accepted`** instead of `201`: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "status": "in_review", "kind": "card_application", "review_id": "7a3f2b1c-0000-4000-8000-000000000001", "message": "application received; it is pending review by our compliance team" } ``` A retry with the same `idempotency_key` returns the same `202` payload with `idempotency_hit: true` — it never opens a second review. * **The application fee is charged when the application is held.** If the review is rejected, the fee is **automatically refunded**; if it is approved, the profile or card is created at that moment. * **Follow the result** with the webhook `txn_review_status_changed` (`kind` will be `banking_application` or `card_application`) or by polling `GET /v1/me/txn-reviews`. * The review can also request information (`info_requested`) — upload documents exactly like with a transactional review. ## Upload documents when asked If your review is in `info_requested`, upload the supporting files. The body is the **raw file binary**, the filename travels in the `name` query param and the type in the `Content-Type` header: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST "https://api.qbank.cl/platform/v1/me/txn-reviews/7a3f2b1c-0000-4000-8000-000000000001/files?name=invoice-221.pdf" \ -H "Authorization: Bearer pk_..." \ -H "Content-Type: application/pdf" \ --data-binary "@invoice-221.pdf" ``` `201` response — uploading a file moves the review **back to `in_review`** so the team re-evaluates it: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "file": { "id": "f1e2d3c4-0000-4000-8000-0000000000aa", "review_id": "7a3f2b1c-0000-4000-8000-000000000001", "file_name": "invoice-221.pdf", "content_type": "application/pdf", "size_bytes": 482110, "uploaded_by": "account", "created_at": "2026-08-06T15:40:00Z" }, "status": "in_review" } ``` **Limits:** * Allowed types: PDF, PNG, JPEG, WEBP, TXT, CSV, DOC(X), XLS(X) — validated by the `Content-Type` header. * Max size: **50 MB** per file. * Max **20 files** per review. ## Download your own files ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/me/txn-reviews/7a3f2b1c-0000-4000-8000-000000000001/files/f1e2d3c4-0000-4000-8000-0000000000aa" \ -H "Authorization: Bearer pk_..." \ -o invoice-221.pdf ``` Returns the raw binary with its original `Content-Type` (files from other accounts answer `404 not_found`). ## Webhook `txn_review_status_changed` Whenever the status of one of your reviews changes, you receive this webhook: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "event": "txn_review_status_changed", "account_id": "ae8cf540-1234-5678-9abc-def012345678", "review_id": "7a3f2b1c-0000-4000-8000-000000000001", "kind": "payout", "resource_id": "8b4c3d2e-1111-4111-8111-111111111111", "status": "released", "previous_status": "in_review", "amount": "1500.00", "asset": "USD", "timestamp": "2026-08-06T16:30:00Z" } ``` The webhook payload is **neutral** by design: it carries the status and the operation summary, but never the internal review reason or compliance notes. For application reviews (`kind`: `banking_application` / `card_application`) the payload omits `amount` and `asset`; `method` carries the application flow (`self`/`third_party`) or the card type (`virtual`/`physical`). ## Own errors | HTTP | Code | Solution | | ---- | ----------------------- | ------------------------------------------------------------------------------------------------ | | 400 | `invalid_status` | The `status` filter must be `in_review`, `info_requested`, `released`, `rejected` or `all` | | 400 | `invalid_range` | Check the `YYYY-MM-DD` format of `from`/`to` | | 400 | `invalid_name` | Send the filename in the `name` query param (max 200 chars, no path separators) | | 400 | `empty_file` | The file body arrived empty | | 404 | `not_found` | The review (or file) does not exist or does not belong to your account | | 409 | `not_awaiting_info` | The review is not in `info_requested` — you can only upload files when information was requested | | 413 | `file_too_large` | The file exceeds 50 MB | | 415 | `unsupported_file_type` | Use PDF, PNG, JPEG, WEBP, TXT, CSV, DOC(X) or XLS(X) with its `Content-Type` | | 422 | `file_limit_reached` | The review already has 20 files | | 503 | `storage_unavailable` | Storage is unavailable; retry in a few seconds | Full catalog in [Errors](/en/errors). ## Frequently asked questions Your organization enabled the transactional firewall, a control layer that holds certain operations for manual review before executing them. The exact criteria depend on your organization's compliance policy. Most reviews resolve within minutes or hours. If your review stays unanswered for over 24 hours, your organization receives an automatic alert. If your organization configured a decision deadline, the review is automatically rejected when it expires — uploading the requested evidence resets that clock. The operation is cancelled. If funds were held (for example, in a payout), they are automatically returned to your available balance. You receive an email with the rejection reason. Not directly. If you need to cancel it, contact your organization's compliance team — they can reject it from their panel. For security and to avoid compromising compliance investigations, the internal reason is never exposed to the end user. You will only see the information request message when documents are asked of you. Your organization enabled application review: the request was held before processing. Nothing is created yet — when compliance approves the review, the profile or card is created automatically and you receive the webhook `txn_review_status_changed` with `status: released`. The application fee was charged at hold time; if the review is rejected, the fee is refunded to your balance automatically. # MCP Server Source: https://docs.cbpayapp.com/en/mcp Connect your AI editor or assistant to the CBPay documentation with one click CBPay ships an official [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server at `https://mcp.cbpayapp.com`. Add it to Cursor, VS Code, Claude or any MCP-compatible client and your AI assistant can search this documentation, read every endpoint with its real request/response examples and look up error codes — without leaving your editor. It is **documentation only and read-only**: it never calls the live API, never needs an API key, and requires **no authentication**. The transport is streamable HTTP. ## One-click install Add cbpay-docs MCP server to Cursor Add cbpay-docs MCP server to Cursor Install cbpay-docs MCP server in VS Code Install cbpay-docs MCP server in VS Code Insiders ## Setup by client Click the **Add to Cursor** button above and confirm the install, or add the server manually to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` in your project: ```json mcp.json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "mcpServers": { "cbpay-docs": { "url": "https://mcp.cbpayapp.com" } } } ``` The server appears in **Settings → MCP** with its tools ready to use. One command from your terminal: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} claude mcp add --transport http cbpay-docs https://mcp.cbpayapp.com ``` Verify with `claude mcp list` — `cbpay-docs` should show as connected. On [claude.ai](https://claude.ai) or in Claude Desktop: 1. Open **Settings → Connectors**. 2. Click **Add custom connector**. 3. Name it `cbpay-docs` and paste the URL `https://mcp.cbpayapp.com`. 4. Save — no authentication step is required. Click the **VS Code** button above, or add the server manually to your user `mcp.json` (**Command Palette → MCP: Open User Configuration**): ```json mcp.json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "servers": { "cbpay-docs": { "type": "http", "url": "https://mcp.cbpayapp.com" } } } ``` GitHub Copilot Chat (agent mode) picks up the tools automatically. In ChatGPT (paid plans, with developer mode enabled): 1. Open **Settings → Connectors → Advanced → Developer mode**. 2. Click **Create** and paste `https://mcp.cbpayapp.com` as the MCP server URL. 3. Set authentication to **None** and save. Any MCP client that supports remote servers over streamable HTTP works. The generic configuration is: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "mcpServers": { "cbpay-docs": { "url": "https://mcp.cbpayapp.com" } } } ``` No API key, no headers, no OAuth — just the URL. ## Available tools | Tool | What it does | | -------------------- | ------------------------------------------------------------------------------------------------- | | `search_docs` | Searches the documentation (sections and endpoints) and returns results ranked by relevance | | `list_sections` | Lists the table of contents with each section id and group | | `get_section` | Returns the full markdown of a section, including its subsections | | `list_endpoints` | Lists the documented endpoints, filterable by group and/or HTTP method | | `get_endpoint` | Returns the documentation of one endpoint with its curl, request and response examples | | `list_groups` | Lists the products/groups (payouts, payins, banking, crypto, …) with their endpoint counts | | `get_errors_catalog` | Returns the API error catalog (HTTP code, `error` code, meaning and category), filterable by text | ## Prompts to try Once connected, ask your assistant things like: * "Using the CBPay docs, show me how to create a payout to Chile with curl." * "What does the `idempotency_key_required` error mean and how do I fix it?" * "List every payins endpoint and explain the QR flow for Bolivia." * "How do I verify the signature of a CBPay webhook?" ## FAQ No. The MCP server is public and serves documentation only. Your CBPay API key is never involved — keep it for your integration code. No. The server is strictly read-only over documentation content: it cannot create payouts, move balances or touch the live API in any way. Streamable HTTP at `https://mcp.cbpayapp.com`. Clients that only support stdio-based local servers can bridge it with a proxy such as `mcp-remote`. Same content, different consumer: the [Postman collection](/en/postman) is for humans testing requests, the site search is for humans reading — the MCP server is for your AI assistant, so it can answer integration questions with the real documented behavior instead of guessing. Yes — it is generated from the same documentation you are reading, so every release listed in the [changelog](/en/changelog) is reflected in the MCP content as well. Yes — a dedicated MCP server for the **organization administration** documentation (`mcp-admin.cbpayapp.com`) is being deployed. It answers questions about the org-admin API (accounts, fees, treasury, compliance) and is aimed at organization administrators — this server here keeps covering the account-level API your clients integrate. # Postman Source: https://docs.cbpayapp.com/en/postman Ready-to-import collection to try the whole API Download the official CBPay Postman collection, generated from the same OpenAPI specification behind this documentation: every endpoint, with one request per use case (each named example in the spec) and one saved response per operation. Download `cbpay-api.postman_collection.json` (v2.1) > **Collection updated:** 2026-08-11 19:53 UTC · 338 requests · version `3146c00278d0` ## How to use it In Postman: **Import** → drag the downloaded file. The collection ships with two variables: | Variable | Value | | --------- | ------------------------------------------------ | | `baseUrl` | `https://api.qbank.cl/platform` (pre-configured) | | `token` | Your session JWT or `pk_...` API key | Every request inherits Bearer authentication with `{{token}}`. Start with `GET /v1/me` to validate your credential and `GET /v1/balances` to see your balance. The collection is regenerated with every API version — download it again after each [changelog](/en/changelog) entry to get the latest endpoints. # Real-time events (SSE) Source: https://docs.cbpayapp.com/en/realtime-events Stream everything that happens in the account — or across the organization — over a single HTTP connection Webhooks push events to **your server**. The real-time event stream pushes the same events to **your frontend**: one long-lived `GET` that receives every event as it happens, with guaranteed replay if the connection drops. Use the stream to keep a dashboard live (balances, payins landing, payouts settling, card authorizations, KYT alerts for admins). Use [webhooks](/en/webhooks) for anything that must survive your browser being closed — the two channels carry the **same events with the same payloads** and the same `event_id`, so you can consume both without writing two mappings. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} sequenceDiagram autonumber participant App as Your frontend participant CB as CBPay App->>CB: GET /platform/v1/events (Bearer token) CB-->>App: 200 text/event-stream CB-->>App: event: payin_credited (id: 4821) CB-->>App: : ping (every 20s) Note over App,CB: Network drops App->>CB: GET /platform/v1/events (Last-Event-ID: 4821) CB-->>App: replay of 4822…4830, then live again ``` ## Open the stream The endpoint requires the same `Authorization: Bearer` credential as the rest of the API, so the browser's native `EventSource` (which cannot send headers) is **not** an option. Use `fetch` with a streaming reader: ```js Browser (fetch) theme={"theme":{"light":"github-light","dark":"github-dark"}} const res = await fetch("https://api.qbank.cl/platform/v1/events", { headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream", }, }); const reader = res.body.pipeThrough(new TextDecoderStream()).getReader(); let buffer = ""; let lastEventId = null; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += value; // SSE frames are separated by a blank line let sep; while ((sep = buffer.indexOf("\n\n")) !== -1) { const frame = buffer.slice(0, sep); buffer = buffer.slice(sep + 2); if (frame.startsWith(":")) continue; // heartbeat const id = frame.match(/^id: (.+)$/m)?.[1]; const type = frame.match(/^event: (.+)$/m)?.[1]; const data = frame.match(/^data: (.+)$/m)?.[1]; if (id) lastEventId = id; // remember it: this is your replay cursor handle(type, data ? JSON.parse(data) : null); } } ``` ```bash curl theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -N https://api.qbank.cl/platform/v1/events \ -H "Authorization: Bearer " \ -H "Accept: text/event-stream" ``` ```text Response theme={"theme":{"light":"github-light","dark":"github-dark"}} : cbpay event stream retry: 3000 id: 4821 event: payin_credited data: {"event_id":"9f1c…","type":"payin_credited","account_id":"ae8c…","created_at":"2026-07-25T18:42:07Z","cursor":"4821","data":{"payin_id":"7d2f…","usdt_credited":"99.700000","status":"credited"}} : ping ``` Every event frame carries three lines: | Line | Meaning | | -------- | ------------------------------------------------------------------------------------------------------------------------------- | | `id:` | Monotonic cursor of the log. Store the last one you processed — it is your `Last-Event-ID`. | | `event:` | The event type (`payin_credited`, `payout_status_changed`, …), identical to the webhook catalog. | | `data:` | JSON envelope: `event_id`, `type`, `account_id`, `created_at`, `cursor` and `data` (the **same payload** the webhook delivers). | ## Reconnect without gaps If the connection drops, reconnect sending the last cursor you processed. The server replays everything you missed **from the log** before switching back to live, so a flaky network never loses an event. ```js Header theme={"theme":{"light":"github-light","dark":"github-dark"}} await fetch("https://api.qbank.cl/platform/v1/events", { headers: { Authorization: `Bearer ${token}`, "Last-Event-ID": lastEventId, // e.g. "4821" }, }); ``` ```bash Query param theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -N "https://api.qbank.cl/platform/v1/events?last_event_id=4821" \ -H "Authorization: Bearer " ``` The replay is capped at **1,000 events**. If you were offline long enough to miss more, the stream emits a `replay_truncated` control event and you should reconcile with `?snapshot=true` or with [`GET /v1/events/history`](#queryable-history) instead of assuming continuity. ## Initial snapshot Opening with `?snapshot=true` sends the **current state** first, then the deltas. It removes the classic race of "read the REST endpoints, then subscribe, and lose whatever happened in between": the cursor is taken after the subscription is open, so nothing falls through the crack. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -N "https://api.qbank.cl/platform/v1/events?snapshot=true" \ -H "Authorization: Bearer " ``` ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} id: 4820 event: snapshot data: {"generated_at":"2026-07-25T18:42:00Z","scope":{"org_admin":false,"account_id":"ae8c…","types":[]},"balances":[{"asset":"USDT","available":"1025.000000","held":"0.000000"}]} id: 4821 event: payin_credited data: {…} ``` The snapshot is **absolute state**, never deltas — applying it twice is harmless. It contains `balances` for an account credential (same fields as [`GET /v1/balances`](/en/guides/analytics)) and, for an organization admin, the operational `health` counters. ## Filter by event type `?types=` narrows what you receive. It can only **restrict** what your credential already sees, never widen it. An unknown type is rejected with `400 invalid_event_type` instead of silently returning nothing. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -N "https://api.qbank.cl/platform/v1/events?types=payin_credited,payout_status_changed" \ -H "Authorization: Bearer " ``` ## Scope: account vs organization admin | Credential | What the stream delivers | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | Account (`pk_…` or member session) | Only that account's events. | | Organization admin with `ops:read` | Every account in the organization, plus org-wide events (KYT alerts, corridor health, approvals). Optional `?account_id=` filters within the organization. | An account never sees another account's events, and never sees the org-wide compliance surface. The stream never delivers a field that the same credential could not already read over REST. ## Control events Besides your business events, the stream emits protocol events. They carry no `id:` (except `snapshot`), so they never move your replay cursor. | `event:` | When | What to do | | ------------------ | ------------------------------------------------------------- | -------------------------------------------------------- | | `snapshot` | You opened with `?snapshot=true` | Replace local state with the payload. | | `reconnect` | Max lifetime reached (30 min) or the server buffer overflowed | Reconnect with `Last-Event-ID`. | | `unauthorized` | Session revoked, key disabled or account blocked | Authenticate again. | | `replay_truncated` | More than 1,000 missed events | Reconcile with `?snapshot=true` or the history endpoint. | | `error` | Replay or snapshot could not be built | Retry; fall back to REST. | A `: ping` comment arrives every 20 seconds to keep proxies from closing an idle connection — ignore lines starting with `:`. ## Limits | Limit | Value | Why | | ----------------------------------- | ------------ | ----------------------------------------------------------- | | Concurrent streams per account | 5 | One tab per device is plenty; leaked connections are a bug. | | Concurrent streams per organization | 50 | Protects the shared hub. | | Connection lifetime | 30 min | Ends with `reconnect`; the cursor makes it seamless. | | Heartbeat | every 20 s | Stays under the proxy read timeout. | | Credential revalidation | every 60 s | A revoked session stops receiving events immediately. | | Stream openings per IP | 600 per hour | Absorbs legitimate reconnects; stops a broken retry loop. | Exceeding the concurrency limit returns `429 too_many_streams`. Exceeding the openings quota returns `429 rate_limited` — that one counts *attempts*, so a client reconnecting in a tight loop burns it even with no stream open. Always honour the `retry:` hint (3 s) plus exponential backoff. ## Queryable history The same log that feeds the stream is readable over REST — useful for auditing, for a "what did I miss" view, or when the replay was truncated. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/events/history?from=2026-07-01&to=2026-07-25&event_type=payin_credited&page=1&page_size=50" \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "page": 1, "page_size": 50, "total": 3, "events": [ { "event_id": "9f1c0d3a-6b52-4c81-9f0e-2a7d5b1c8e44", "type": "payin_credited", "account_id": "ae8c…", "created_at": "2026-07-25T18:42:07Z", "cursor": "4821", "data": { "payin_id": "7d2f…", "usdt_credited": "99.700000" } } ] } ``` A single event by its public id: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/events/9f1c0d3a-6b52-4c81-9f0e-2a7d5b1c8e44 \ -H "Authorization: Bearer " ``` The event log keeps **90 days**. It is a notification buffer, not the financial record: balances, payins, payouts, transfers and ledger entries are immutable and stay available for as long as regulation requires through their own endpoints and the [statement](/en/guides/statement). ## Errors | HTTP | Code | Solution | | ---- | -------------------- | ------------------------------------------------------------------------------------ | | 400 | `invalid_event_type` | Use a type from the [webhook catalog](/en/webhooks#events). | | 400 | `invalid_range` | `from`/`to` are required in the history endpoint (`YYYY-MM-DD`, `from` before `to`). | | 404 | `not_found` | The event does not exist or belongs to another account. | | 429 | `too_many_streams` | Close an open stream before opening another. | | 429 | `rate_limited` | Too many openings from this IP (600/h). Back off; never reconnect in a loop. | | 503 | `stream_unavailable` | Retry with backoff; the stream is temporarily unavailable. | Full list in [Errors](/en/errors). ## FAQ No. The stream lives as long as the browser tab; webhooks reach your backend even when nobody is looking. Use the stream for the UI and webhooks for anything that triggers business logic (reconciliation, accounting, notifications). Yes — after a reconnect the replay may re-deliver the boundary event, and both channels (webhook and stream) share the same `event_id`. Deduplicate by `event_id` and treat every payload as absolute state. By design. A stream with no lifetime cap hides leaked connections. The server sends a `reconnect` control event first, and reconnecting with `Last-Event-ID` continues exactly where you were. No. The stream needs no configuration: it delivers everything your credential is allowed to see. Webhook subscriptions only control HTTP deliveries to your server. Check that your HTTP client is not buffering the response (in `fetch`, read `res.body` as a stream instead of awaiting `res.text()`), and that no proxy of yours is buffering `text/event-stream`. CBPay already disables buffering on its side. Yes, with `?account_id=`. The filter only works inside your own organization; anything else returns 404. # Security and 2FA (OTP) Source: https://docs.cbpayapp.com/en/security-2fa One-time codes over SMS or WhatsApp protecting login, payouts, withdrawals and more CBPay can require a **one-time verification code (OTP)** before sensitive actions: logging in, creating a payout, withdrawing crypto, revealing a card, issuing an API key… The code arrives over **SMS or WhatsApp** to the account's phone, and your operator decides **which actions require it and over which channel** — per account or for the whole organization. OTP applies **only to user sessions** (JWT login). **`pk_` API keys are exempt**: they are server-to-server integrations with no human holding a phone on the other side. If your whole operation runs on API keys, this page changes nothing in your integration. This page covers the **per-action OTP flow** your integration must handle (`otp_required` → challenge → verify). Everything the end user manages about their own factors — enabling 2FA, channels (SMS/WhatsApp/email), the authenticator app (TOTP), recovery codes and passkeys — lives in [Profile & security](/en/guides/profile). ## How it works ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} sequenceDiagram participant U as User (JWT session) participant API as CBPay API U->>API: POST /v1/payouts (no OTP) API-->>U: 403 otp_required {action, channel} U->>API: POST /v1/otp/challenges {action: "payout"} Note over U: Code arrives over SMS/WhatsApp API-->>U: 201 {challenge_id, expires_at} U->>API: POST /v1/otp/challenges/{id}/verify {code} API-->>U: 200 {otp_token} U->>API: POST /v1/payouts + X-OTP-Token header API-->>U: 201 payout created ``` The `otp_token` is **single use**, bound to your user and to the action it was issued for, and expires with the challenge (10 minutes after the code was sent). ## 1. Check your policy `GET /v1/otp/settings` tells you whether OTP is active and which actions require it: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/otp/settings \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "enabled": true, "phone": "********5678", "phone_verified": true, "actions": [ { "action": "login", "required": true, "channel": "sms" }, { "action": "payout", "required": true, "channel": "whatsapp" }, { "action": "crypto_withdrawal", "required": true, "channel": "sms" }, { "action": "transfer", "required": false, "channel": "sms" }, { "action": "banking_operation", "required": false, "channel": "sms" }, { "action": "card_reveal", "required": true, "channel": "sms" }, { "action": "api_key_create", "required": true, "channel": "sms" }, { "action": "member_add", "required": false, "channel": "sms" }, { "action": "phone_change", "required": true, "channel": "sms" } ] } ``` ## 2. Request the code ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/otp/challenges \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "action": "payout" }' ``` Response `201` — the code is already on its way to the account's phone: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "challenge_id": "6f1c02aa-93a1-4f0e-a7d1-1f2e3c4b5a69", "account_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "action": "payout", "channel": "whatsapp", "phone": "********5678", "status": "pending", "created_at": "2026-07-08T21:00:00Z", "expires_at": "2026-07-08T21:10:00Z" } ``` If the account has no phone: `409 phone_required` (set it with `PATCH /v1/me`). Hourly send limits apply — exceeding them returns `429 too_many_attempts`. ## 3. Verify the code ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/otp/challenges/6f1c02aa-93a1-4f0e-a7d1-1f2e3c4b5a69/verify \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "code": "482913" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "challenge_id": "6f1c02aa-93a1-4f0e-a7d1-1f2e3c4b5a69", "action": "payout", "otp_token": "otp_Zk9uY2XIr1EYE0lq8xqlM3VayVZYX4aa11bb22cc33", "expires_at": "2026-07-08T21:10:00Z", "note": "single use: send it in the X-OTP-Token header of the protected action" } ``` Wrong code → `401 invalid_code` (you get 5 attempts per challenge). ## 4. Execute the action with the token ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/payouts \ -H "Authorization: Bearer " \ -H "X-OTP-Token: otp_Zk9uY2XIr1EYE0lq8xqlM3VayVZYX4aa11bb22cc33" \ -H "Content-Type: application/json" \ -d '{ "country": "CL", "currency": "CLP", "method": "bank_transfer", "amount": "50000", "beneficiary": { "...": "..." }, "idempotency_key": "pay-991" }' ``` The token is consumed on use, even if the action fails afterwards (for example, insufficient funds): retrying requires verifying a new challenge. Your `idempotency_key` remains the duplicate protection — OTP does not replace it. ## Two-step login If your policy requires OTP on `login`, `POST /v1/auth/login` no longer returns the session directly: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "otp_required": true, "pending_token": "eyJhbGciOi…", "challenge_id": "8a2b…", "channel": "sms", "phone": "********5678", "expires_at": "2026-07-08T21:10:00Z", "note": "verify the code with POST /v1/auth/login/otp to receive the session token" } ``` The `pending_token` **cannot call the API**: it is only exchanged, along with the code, for the real session: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/auth/login/otp \ -H "Content-Type: application/json" \ -d '{ "pending_token": "eyJhbGciOi…", "code": "482913" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "access_token": "eyJhbGciOi…", "expires_at": "2026-07-09T21:00:00Z", "account_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "role": "owner" } ``` ## The account's phone * **E.164** format (`+56912345678`), set at registration, through `PATCH /v1/me` or by your operator. * `phone_verified` flips to `true` the first time you verify a challenge: it proves the holder has the phone in hand. * **Changing the phone** (action `phone_change`) validates the code against the **previous** number — nobody can redirect your codes without holding your current phone. * If the phone is linked for the first time (or changed without verification), SMS/WhatsApp challenges stay locked for **24 hours**: the anti session-hijacking window. During the cooldown, if you have the authenticator app enrolled or a verified email, the challenge is issued automatically over that stronger factor (the channel comes back in the challenge response); you only get `403 phone_binding_cooldown` when no alternative factor exists. A phone set by your operator has no cooldown. * The **two-step login** honors the same rule: with login 2FA over SMS/WhatsApp and the phone in cooldown, the login code is issued over the authenticator app or your login email instead (the effective `channel` comes back in the login response) — it is never sent to a recently linked, unverified number. Without an alternative factor the login responds `403 phone_binding_cooldown` until the cooldown expires. ## Errors | HTTP | `error` | What it means | What to do | | ---- | ------------------------ | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | 403 | `otp_required` | The action requires OTP and no `X-OTP-Token` was sent | Create and verify a challenge, retry with the header | | 403 | `otp_invalid` | Token invalid, expired or already used | Verify a new challenge | | 403 | `session_required` | You requested a challenge with an API key | Challenges are for user sessions only | | 403 | `phone_binding_cooldown` | Phone linked less than 24 h ago without verification and no alternative factor (authenticator app or verified email) | Enroll the authenticator app or verify your email; otherwise wait out the cooldown or ask your operator to set the number | | 401 | `invalid_code` | The code does not match | Check the SMS/WhatsApp and retry (5 attempts) | | 401 | `invalid_pending_token` | The intermediate login token expired | Log in again | | 409 | `phone_required` | The account has no phone | `PATCH /v1/me` with an E.164 `phone` | | 409 | `otp_phone_missing` | Login requires OTP and there is no phone | Contact your operator | | 409 | `challenge_not_pending` | The challenge expired or was already used | Create a new one | | 429 | `too_many_attempts` | Send/verification limits reached | Wait a few minutes | | 503 | `otp_unavailable` | The verification service is unavailable | Retry; the action stays blocked (OTP is never skipped) | ## FAQ No. `pk_` API keys are exempt by design: automation never goes through OTP. Guard your keys accordingly — issuing a new key CAN require OTP (action `api_key_create`). The channel is configured by your operator per action (per account or for the whole organization). You see it in `GET /v1/otp/settings`. The code and challenge last 10 minutes. You get 5 verifications per challenge and an hourly send limit. The resulting `otp_token` is single use. No: the token is bound to the exact action the challenge was created for (a `payout` token does not work for `transfer`) and is consumed on first use. No. A consumed token only means you must verify a new challenge; the operation's `idempotency_key` still guarantees there are no duplicates. # Service status & corridor availability Source: https://docs.cbpayapp.com/en/service-status Know in real time whether each payment corridor is operational: availability in the method catalogs, the corridor_status_changed webhook and the public status page Payment rails can degrade or go down — a banking network outage, a channel maintenance window, an upstream incident. The platform monitors the health of every corridor (country / currency / method) **in real time**, combining the outcome of live traffic with active health checks, and exposes that state to you through three surfaces so your product can react before your users do: 1. **`availability` in the method catalogs** — decide at render time whether to show, warn about or hide a corridor. 2. **`corridor_status_changed` webhook** — get pushed the moment a corridor changes state, without polling. 3. **Public status page** — a hosted, branded page (HTML + JSON) you can link from your app or your own status tooling. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR monitor["Health monitor
(live traffic + active checks)"] --> state["Corridor state
operational / degraded / down"] state --> catalog["availability in
GET /v1/payouts/methods
GET /v1/payins/methods"] state --> hook["Webhook
corridor_status_changed"] state --> page["Public status page
HTML + JSON"] ``` The monitor is **observability, not a gate**: a `down` corridor does not block your requests. You stay in control — you can keep sending (requests will fail with the usual error codes and refunds apply as always) or pause that corridor in your UI until it recovers. ## Corridor states | State | Meaning | What to do | | ------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `operational` | The corridor is processing normally. | Nothing — business as usual. | | `degraded` | An elevated infrastructure error rate was detected in the recent window. Some operations may fail or take longer. | Consider showing a warning in your UI; retries with the same `idempotency_key` are safe. | | `down` | Consecutive infrastructure failures or failing health checks. New dispatches are very likely to fail. | Prefer hiding or disabling the corridor in your UI until it recovers; anything you do send will resolve to the usual final states (failed operations are refunded as always). | Transitions are governed by hysteresis: a single timeout never declares an outage, and recovery requires a sustained stable window — the state you read is meaningful, not noisy. ## 1. Availability in the method catalogs `GET /v1/payouts/methods` and `GET /v1/payins/methods` now include an additive `availability` field per corridor. The rest of the shape is unchanged. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/payouts/methods" \ -H "Authorization: Bearer pk_..." ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "items": [ { "country": "VE", "currency": "VES", "method": "bank_transfer", "availability": "down" }, { "country": "MX", "currency": "MXN", "method": "bank_transfer", "availability": "operational" } ] } ``` A corridor with no recorded incident is always `operational` — the monitor only persists what it has observed. ## 2. The `corridor_status_changed` webhook Subscribe your endpoint to the `corridor_status_changed` event ([webhooks guide](/en/webhooks)) and you will receive every transition the moment it happens — outage **and** recovery: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "event_type": "corridor_status_changed", "data": { "flow": "payout", "country": "VE", "currency": "VES", "method": "bank_transfer", "status": "down", "previous_status": "operational", "since": "2026-07-24T22:10:00Z", "reason": "consecutive infrastructure failures" } } ``` | Field | Description | | --------------------------------- | ------------------------------------------------------------------------ | | `flow` | `payout` or `payin` — the direction of the affected corridor. | | `country` / `currency` / `method` | The corridor key, exactly as in the method catalogs. | | `status` | New state: `operational`, `degraded` or `down`. | | `previous_status` | State before the transition. | | `since` | When the new state started (RFC 3339, UTC). | | `reason` | Short human-readable cause (never includes internal channel identities). | The event is a **broadcast**: it is not tied to one of your operations, so it carries no `account_id`. Idempotent consumption applies as with every webhook (dedupe by delivery id). ## 3. Public status page Your organization has a hosted status page with the live state of every corridor, the uptime of the last 90 days and the incident history. It is public (no auth), branded with your organization's identity and safe to share with your own customers. * **HTML**: `GET /status/{orgToken}` — a self-contained page you can link or embed. * **JSON**: `GET /v1/status/{orgToken}` — the same data for your own status tooling or monitors. The page picks up your organization's logo, colors and website, and shows for every corridor the country flag, a payment-method icon, a day-by-day availability bar for the last 90 days and the current state. A summary card at the top reports the overall state, how many corridors are operational, degraded or down, and the average uptime; the incident timeline at the bottom spells out each reason in plain language. The HTML loads no JavaScript and no external resources, so you can safely embed it in an iframe. The `orgToken` is an opaque token your operator shares with you (org admins can read it as `status_page_url` in `GET /v1/org/branding`). ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl "https://api.qbank.cl/platform/v1/status/{orgToken}" ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "status": "degraded", "generated_at": "2026-07-24T22:15:00Z", "corridors": [ { "flow": "payout", "country": "VE", "currency": "VES", "method": "bank_transfer", "status": "down", "since": "2026-07-24T22:10:00Z", "uptime_90d_pct": 99.62 } ], "incidents": [ { "flow": "payout", "country": "VE", "currency": "VES", "method": "bank_transfer", "from_status": "operational", "to_status": "down", "reason": "consecutive infrastructure failures", "at": "2026-07-24T22:10:00Z" } ] } ``` | Field | Description | | ------------- | ------------------------------------------------------------------------------------------------------ | | `status` | Overall page state: the worst state among all corridors. | | `corridors[]` | Current state per corridor plus `uptime_90d_pct` (percentage of the last 90 days spent `operational`). | | `incidents[]` | Most recent transitions (both outages and recoveries), newest first. | An unknown or malformed token returns `404` — the token does not reveal whether an organization exists. The endpoint is rate-limited per IP. ## FAQ No. The monitor never blocks dispatches. A `down` corridor means new operations are very likely to fail with the usual error codes (`channel_unavailable`, provider rejection, timeout states) — failed operations are refunded exactly as always. Use `availability` to decide what to show in your UI. The monitor evaluates continuously, combining every real dispatch with periodic active health checks, so outages are typically detected within a few minutes even on corridors with little traffic. Hysteresis prevents a single timeout from flapping the state. No — subscribe to `corridor_status_changed` and you will be pushed every transition. The catalogs are a convenient snapshot for render time; the webhook is the change feed. They resolve on their own: each operation reaches a final state (`completed` or `failed` with refund) through the usual webhook and reconciliation machinery. You never need to re-send — retrying with the same `idempotency_key` is always safe. Yes. The page uses your organization's branding (logo, colors, name) automatically — the same configuration used by receipts and hosted pages. Ask your operator for your organization's status page URL, or read it from GET /v1/org/branding if you are an org admin. By design the platform is provider-agnostic: corridors are identified by country, currency and method only. Incident reasons are normalized and never include internal channel identities. # Webhooks Source: https://docs.cbpayapp.com/en/webhooks Receive signed events in real time Webhooks notify your own HTTPS callback about your account's events, cryptographically signed. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} sequenceDiagram autonumber participant CB as CBPay participant App as Your HTTPS endpoint CB->>App: Signed POST (X-Webhook-Signature, X-Webhook-Event-ID) alt You answer 2xx in time App-->>CB: 200 OK Note over CB: Delivery complete else Timeout or error App-->>CB: 5xx / timeout CB->>App: Retry with backoff (up to 5 attempts) Note over App: The same event can arrive twice —
deduplicate by X-Webhook-Event-ID end ``` ## Create a subscription ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.qbank.cl/platform/v1/webhooks/subscriptions \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "event_type": "payout_status_changed", "callback_url": "https://api.myapp.com/webhooks/cbpay", "secret": "a-secret-of-at-least-16-chars" }' ``` * `event_type`: one of the events below, or `*` for all. * `callback_url`: **HTTPS required**; localhost and private IPs are rejected — for local development use an [HTTPS tunnel](/en/environment-testing#testing-webhooks-in-local-development). * `secret`: at least 16 characters; used to sign every delivery. Stored encrypted and cannot be retrieved. The subscription receives **your account's** events. You can list active subscriptions at any time: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.qbank.cl/platform/v1/webhooks/subscriptions \ -H "Authorization: Bearer " ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "page": 1, "page_size": 50, "subscriptions": [ { "id": "5f3a…", "event_type": "payout_status_changed", "callback_url": "https://api.myapp.com/webhooks/cbpay", "status": "active", "created_at": "2026-07-01T12:00:00Z" } ] } ``` ## Disabling and reactivating a subscription When a callback is no longer used, disable it instead of deleting it (subscriptions are **never deleted**: they stay `disabled` and you can reactivate them at any time): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X PATCH https://api.qbank.cl/platform/v1/webhooks/subscriptions/5f3a… \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "status": "disabled" }' ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "id": "5f3a…", "event_type": "payout_status_changed", "callback_url": "https://api.myapp.com/webhooks/cbpay", "status": "disabled", "created_at": "2026-07-01T12:00:00Z" } ``` To reactivate it, same call with `{ "status": "active" }`. * The toggle governs **future** events: a `disabled` subscription stops receiving new events, but deliveries **already queued** are still sent. * It is **idempotent**: repeating the current status answers `200` with no change. * You can only touch **your account's** subscriptions: a subscription of another account answers `404` (it is indistinguishable from an inexistent one). | HTTP | `error` | Fix | | ---- | ---------------- | ------------------------------------------------------------- | | 400 | `invalid_status` | Status must be `active` or `disabled` | | 404 | `not_found` | The subscription does not exist or belongs to another account | ## Events | Event | When it fires | | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `payin_credited` | A fiat collection was received and credited | | `payin_expired` | An active collection (QR / checkout) expired or failed without receiving the payment | | `payin_refunded` | A [refund](/en/guides/refunds) of a card payin reached a final state (includes issuer-imposed chargebacks) | | `payin_settlement_scheduled` | A card payin was confirmed (`credited`, `payin_credited` fired) and its balance was scheduled for a future `settle_at` (org-configured settlement delay). Emitted exactly once at payment confirmation; the balance becomes available when due | | `payout_status_changed` | A payout changed state | | `transfer_received` | The account received an internal transfer | | `crypto_deposit_credited` | An on-chain deposit was confirmed and credited | | `crypto_deposit_held` | An incoming deposit was held due to sender risk ([screening](/en/guides/screenings)) | | `crypto_deposit_alert` | A deposit was credited but the sender shows high risk (informational) | | `crypto_withdrawal_status_changed` | An on-chain withdrawal changed state | | `banking_customer_status_changed` | A banking profile verification changed (your own or a registered third party's — `customer_kind` tells them apart) | | `banking_operation_status_changed` | A bank payment changed state | | `card_transaction` | A card purchase was authorized, annulled or adjusted | | `card_status_changed` | A card changed state (including automatic freezes) | | `card_stored` | A payer's card was tokenized and saved with consent ([stored cards](/en/guides/payins#stored-cards)) | | `stored_card_revoked` | A stored card credential was revoked (merchant-initiated charges stop working) | | `subscription_status_changed` | A subscription on a stored card changed state (`active` / `paused` / `past_due` / `canceled`) | | `kyc_verification_status_changed` / `kyb_verification_status_changed` | An identity verification changed state (including your own onboarding, with `self_onboarding: true`) | | `kyc_link_completed` / `kyb_link_completed` | A hosted verification link was completed | | `kyc_document_validated` / `kyb_document_validated` | OCR finished for a document uploaded through the API | | `kyc_liveness_completed` | A liveness check was completed from a liveness link | | `aml_screening_updated` | AML screening updates (result, cases, risk, reviewed transaction) | | `risk_report_ready` | A [Qscore](/en/guides/qscore) credit report finished generating (it carries the score and band) | | `risk_score_changed` | The score of a monitored subject moved (re-evaluation after new bureau data) | | `risk_monitoring_alert` | A monitored [Qscore](/en/guides/qscore) subject triggered an alert: the score dropped below your threshold, new bureau records appeared, or records were removed | | `risk_batch_completed` | A [batch](/en/guides/qscore-batch) of Qscore reports finished processing (exactly one webhook per batch, with item counts — never per subject) | | `risk_consent_granted` | A [consent link](/en/guides/qscore-consents) was authorized by the holder — positive banking facts flow into the subject's credit file | | `risk_consent_revoked` | A [consent link](/en/guides/qscore-consents) was declined by the holder or revoked by your account | | `wallet_deposit_received` | An on-chain deposit arrived at a [segregated wallet](/en/guides/segregated-wallets) (does not touch the ledger) | | `wallet_send_status_changed` | A send from a segregated wallet changed status | | `wallet_key_exported` | A segregated wallet's private key was exported (security alert) | | `wallet_external_movement` | On-chain movement of a segregated wallet that did not go through the platform (expected under `client` custody) | | `wallet_key_compromise_suspected` | **Critical alarm**: external outflow from a `cbpay`-custody wallet — possible key compromise | | `txn_review_status_changed` | An operation held by the [transactional firewall](/en/guides/transaction-reviews) changed review state (`in_review` / `info_requested` / `released` / `rejected`) — neutral payload, internal reasons never travel; a rejection can also come from the automatic deadline sweep (auto-rejection) | | `corridor_status_changed` | A payment corridor changed availability (`operational` / `degraded` / `down`) — broadcast, see the [service status guide](/en/service-status) | | `balance_adjusted` | An administrator applied a manual credit or debit to a balance | | `account_status_changed` | The account's administrative status changed (`active` / `blocked` / `closed`) | | `member_security_event` | A security event of one of the account's users (sign-in, credential change, new factor, revoked session) | ### Payload of each event ```json payin_credited theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "9c2a…", "account_id": "ae8c…", "country": "BO", "currency": "BOB", "local_amount": "700.00", "fx_rate": "6.91", "usdt_credited": "100.302460", "fee": "1.000000" } ``` ```json payin_expired theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "567d…", "account_id": "ae8c…", "status": "expired", "country": "BO", "currency": "BOB", "local_amount": "60.99", "reference": "CBK7Q2M4XZ9P" } ``` ```json payin_refunded theme={"theme":{"light":"github-light","dark":"github-dark"}} { "refund_id": "3a7d…", "payin_id": "9f1c…", "account_id": "c57f…", "kind": "refund", "status": "completed", "currency": "USD", "local_amount": "40.00", "usdt_debited": "40.000000", "receipt_url": "https://api.qbank.cl/platform/v1/payin-refunds/3a7d…/receipt" } ``` ```json payin_settlement_scheduled theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payin_id": "8b3e…", "account_id": "ae8c…", "country": "US", "currency": "USD", "local_amount": "498.75", "fx_rate": "1.010202", "usdt_gross": "493.811881", "fee": "27.159654", "usdt_net": "466.652227", "status": "credited", "settle_at": "2026-08-12T14:08:33Z", "receipt_url": "https://api.qbank.cl/platform/v1/payins/8b3e…/receipt" } ``` With a settlement delay configured, both webhooks fire **at payment confirmation**: `payin_settlement_scheduled` first (the balance is scheduled for `settle_at`) and then `payin_credited` (the payin is confirmed, `status: credited`). What waits until `settle_at` is only the balance availability — the payin response carries `settlement_pending: true` while it is pending and `settled_at` once it lands. `usdt_net` is the amount that will be credited when due (gross − fee). Amounts may come back empty (`""`) on historical rows without gross/fee. When `settle_at` arrives, the settlement worker credits the balance and emits `payin_credited` (the existing flow, unchanged). ```json payout_status_changed theme={"theme":{"light":"github-light","dark":"github-dark"}} { "payout_id": "0d4f…", "account_id": "ae8c…", "country": "MX", "currency": "MXN", "local_amount": "1500.00", "usdt_amount": "85.714286", "total_debit": "86.014286", "status": "completed", "status_code": "", "bank_reference": "00761123456" } ``` ```json txn_review_status_changed theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "ae8c…", "review_id": "7a3f…", "kind": "payout", "resource_id": "0d4f…", "status": "info_requested", "previous_status": "in_review", "amount": "1500.00", "asset": "USD" } ``` ```json transfer_received theme={"theme":{"light":"github-light","dark":"github-dark"}} { "transfer_id": "77b1…", "from_account_id": "389d…", "to_account_id": "ae8c…", "asset": "USDT", "amount": "25.000000", "description": "Expense split", "created_at": "2026-07-06T20:10:00Z" } ``` ```json crypto_deposit_credited theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "ae8c…", "chain": "tron", "asset": "USDT", "tx_id": "b1946ac9…", "amount": "499.000000", "fee": "1.000000" } ``` ```json crypto_deposit_held theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "ae8c…", "hold_id": "c1d2e3f4…", "chain": "tron", "asset": "usdt", "tx_id": "8a5b3c…", "risk": "Severe", "status": "held" } ``` ```json crypto_deposit_alert theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "ae8c…", "chain": "tron", "asset": "usdt", "tx_id": "9c6d4e…", "risk": "High", "status": "credited" } ``` ```json crypto_withdrawal_status_changed theme={"theme":{"light":"github-light","dark":"github-dark"}} { "withdrawal_id": "5e8c…", "account_id": "ae8c…", "chain": "tron", "asset": "USDT", "tx_id": "7d3f01aa…", "status": "completed", "amount": "100.000000" } ``` ```json banking_customer_status_changed theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "ae8c…", "customer_id": "9f2b…", "customer_kind": "third_party", "third_party_id": "77aa…", "kyc_status": "approved" } ``` In `banking_customer_status_changed`, `customer_kind` tells your own profile (`self`) apart from a third party you registered (`third_party`, with its `third_party_id` — the same id as `GET /v1/banking/third-parties/{id}`). ```json banking_operation_status_changed theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "ae8c…", "customer_id": "9f2b…", "operation_id": "7e8a…", "type": "withdraw", "status": "completed" } ``` ```json card_transaction theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "ae8c…", "card_id": "3c2b…", "transaction_id": "5e4d…", "status": "authorized", "amount_usdt": "16.170000", "merchant": "AMZN Mktp" } ``` ```json card_status_changed theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "ae8c…", "card_id": "3c2b…", "status": "frozen", "reason": "monthly_fee_unpaid" } ``` ```json card_stored theme={"theme":{"light":"github-light","dark":"github-dark"}} { "stored_card_id": "a9b8…", "account_id": "ae8c…", "payer_reference": "payer@email.com", "brand": "VISA", "last4": "1234", "country": "BO", "currency": "BOB", "seed_payin_id": "9c2a…" } ``` ```json stored_card_revoked theme={"theme":{"light":"github-light","dark":"github-dark"}} { "stored_card_id": "a9b8…", "account_id": "ae8c…", "payer_reference": "payer@email.com", "brand": "VISA", "last4": "1234" } ``` ```json subscription_status_changed theme={"theme":{"light":"github-light","dark":"github-dark"}} { "subscription_id": "4f1e…", "account_id": "ae8c…", "stored_card_id": "a9b8…", "status": "past_due", "period": 3, "next_charge_at": "2026-08-01T12:00:00Z", "payer_reference": "payer@email.com", "reason": "dunning_exhausted", "failed_attempts": 3 } ``` ```json kyc_verification_status_changed theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "ae8c…", "kind": "kyc", "event": "approved", "submission_id": "c3d4…", "external_customer_id": "cust_789", "status": "approved", "risk_band": "low", "decision": "approved", "decision_source": "auto" } ``` When the event carries a final decision (`approved`, `rejected` or `changes_requested`), the payload includes `decision_source`: `"auto"` if the automatic decision engine resolved it (clean applications are approved in seconds without human intervention), `"admin"` if a compliance officer decided it from the review console. The field is omitted for older submissions without engine data. ```json kyb_link_completed theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "ae8c…", "kind": "kyb", "event": "link_completed", "link_id": "b2c3…", "submission_id": "d4e5…", "external_customer_id": "cust_456", "status": "completed" } ``` ```json kyc_document_validated theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "ae8c…", "kind": "kyc", "submission_id": "c3d4…", "external_customer_id": "cust_789", "category": "identity", "outcome": "MATCH", "score": 0.97, "summary": "Document matches the submitted identity" } ``` ```json kyc_liveness_completed theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "ae8c…", "kind": "kyc", "submission_id": "c3d4…", "external_customer_id": "cust_789", "outcome": "PASS", "passed": true } ``` ```json aml_screening_updated theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "ae8c…", "screening_event": "compliance_risk_changed", "customer_id": "cus_8f2e…", "data": { "risk_level": "high" } } ``` ```json wallet_deposit_received theme={"theme":{"light":"github-light","dark":"github-dark"}} { "wallet_id": "b7e3…", "account_id": "ae8c…", "chain": "tron", "asset": "USDT", "tx_id": "b1946ac9…", "amount_raw": "125000000", "from_address": "TDonor…" } ``` ```json wallet_send_status_changed theme={"theme":{"light":"github-light","dark":"github-dark"}} { "send_id": "9c8b…", "wallet_id": "b7e3…", "account_id": "ae8c…", "chain": "tron", "asset": "USDT", "tx_id": "b1946ac9…", "status": "completed", "amount_raw": "25500000" } ``` ```json wallet_key_exported theme={"theme":{"light":"github-light","dark":"github-dark"}} { "wallet_id": "b7e3…", "account_id": "ae8c…", "chain": "tron", "asset": "USDT", "address": "TRmSZRaMAqLEevAdGwo3R43bRBXamWR5bd" } ``` ```json wallet_external_movement theme={"theme":{"light":"github-light","dark":"github-dark"}} { "wallet_id": "b7e3…", "account_id": "ae8c…", "chain": "tron", "asset": "USDT", "direction": "out", "tx_id": "9a3c1e5f…", "amount_raw": "25000000", "custody": "client" } ``` ```json wallet_key_compromise_suspected theme={"theme":{"light":"github-light","dark":"github-dark"}} { "wallet_id": "b7e3…", "account_id": "ae8c…", "chain": "tron", "asset": "USDT", "direction": "out", "tx_id": "9a3c1e5f…", "amount_raw": "25000000", "custody": "cbpay" } ``` ```json risk_report_ready theme={"theme":{"light":"github-light","dark":"github-dark"}} { "report_id": "9f1c…", "subject_id": "6aa2…", "doc_id": "76.123.456-7", "country": "CL", "subject_type": "person", "score": 712, "band": "B", "verify_code": "Q9f1c2e7a5b6d4c8e9a0f1d2b3c4d5e6f0123456789ab" } ``` ```json risk_score_changed theme={"theme":{"light":"github-light","dark":"github-dark"}} { "subject_id": "6aa2…", "report_id": "9f1c…", "old_score": 688, "new_score": 712, "old_band": "C", "new_band": "B" } ``` ```json risk_monitoring_alert theme={"theme":{"light":"github-light","dark":"github-dark"}} { "monitoring_id": "7c9e2f14-5b6a-4c8d-9e1f-3a7b5c2d8e91", "subject_id": "8f5fb0d2-1d45-4a0e-9d5c-2d39f2b7a112", "doc_id": "12.345.678-5", "country": "CL", "subject_type": "person", "triggers": ["score_drop_below", "new_records"], "score": 487, "previous_score": 512, "band": "D", "record_count": 3, "detected_at": "2026-08-08T18:41:12Z", "new_records": [ { "source": "res_chile", "record_type": "debt_collection", "reported_at": "2026-08-05T00:00:00Z", "amount": "450000", "currency": "CLP", "status": "open" } ] } ``` ```json risk_batch_completed theme={"theme":{"light":"github-light","dark":"github-dark"}} { "batch_id": "b7f2c1a4-3e5d-4f8a-9c2b-1d0e6a8f4c5d", "status": "completed_with_errors", "total_items": 4, "succeeded_items": 3, "failed_items": 1, "country": "CL", "purpose": "credit_evaluation" } ``` ```json risk_consent_granted theme={"theme":{"light":"github-light","dark":"github-dark"}} { "consent_id": "c3a1e9b2-7f4d-4c8a-9e1b-2a5c6d7e8f9a", "subject_id": "s8b2c4d6-1e3f-4a5b-9c7d-8e9f0a1b2c3d", "country": "CL", "doc_id": "76123456-8", "subject_type": "person", "purpose": "credit_evaluation", "status": "granted", "previous_status": "pending", "holder_name": "Maria Jose Contreras Soto", "openfinance_link_id": "9f1a2b3c-4d5e-4f6a-8b9c-0d1e2f3a4b5c", "granted_at": "2026-08-09T15:42:10Z" } ``` ```json risk_consent_revoked theme={"theme":{"light":"github-light","dark":"github-dark"}} { "consent_id": "c3a1e9b2-7f4d-4c8a-9e1b-2a5c6d7e8f9a", "subject_id": "s8b2c4d6-1e3f-4a5b-9c7d-8e9f0a1b2c3d", "country": "CL", "doc_id": "76123456-8", "subject_type": "person", "purpose": "credit_evaluation", "status": "revoked", "previous_status": "granted", "revoked_at": "2026-08-11T09:05:33Z" } ``` ```json corridor_status_changed theme={"theme":{"light":"github-light","dark":"github-dark"}} { "flow": "payout", "country": "VE", "currency": "VES", "method": "bank_transfer", "status": "down", "previous_status": "operational", "since": "2026-07-24T22:10:00Z", "reason": "consecutive infrastructure failures" } ``` ```json balance_adjusted theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "ae8c…", "asset": "USDT", "amount": "25.000000", "direction": "credit", "reason": "goodwill credit", "available": "1025.000000", "held": "0.000000" } ``` ```json account_status_changed theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "ae8c…", "status": "blocked", "previous_status": "active" } ``` ```json member_security_event theme={"theme":{"light":"github-light","dark":"github-dark"}} { "account_id": "ae8c…", "member_id": "3f7b…", "event_type": "password_changed", "ip": "200.83.14.7", "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)" } ``` In `payout_status_changed` and `crypto_withdrawal_status_changed`, `status` can be `completed` or `failed` (with `failed`, the debit has already been refunded by the time you receive the event). ## Delivery format Every delivery is a JSON `POST` with these headers: | Header | Content | | ----------------------- | ------------------------------------------- | | `X-Webhook-Event` | Event type | | `X-Webhook-Event-ID` | Unique event ID | | `X-Webhook-Delivery-ID` | This delivery's ID (changes across retries) | | `X-Webhook-Timestamp` | Unix timestamp (seconds, UTC) | | `X-Webhook-Signature` | HMAC signature (see below) | ## Verify the signature ``` X-Webhook-Signature = hex( HMAC-SHA256( secret, timestamp + "." + body ) ) ``` ```javascript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} const crypto = require("crypto"); function verifyWebhook(req, secret) { const ts = req.headers["x-webhook-timestamp"]; const sig = req.headers["x-webhook-signature"]; const expected = crypto .createHmac("sha256", secret) .update(ts + "." + req.rawBody) .digest("hex"); return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); } ``` ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import hashlib, hmac def verify_webhook(headers, raw_body: bytes, secret: str) -> bool: ts = headers["X-Webhook-Timestamp"] sig = headers["X-Webhook-Signature"] expected = hmac.new( secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(sig, expected) ``` Compute the HMAC over the **raw body** (bytes as received), not over re-serialized JSON. Reject old timestamps (> 5 minutes) to prevent replay. ## Retries and idempotency * Your endpoint must respond **2xx** within the timeout; anything else is retried. * Up to **5 attempts** with incremental backoff: | Attempt | 1 | 2 | 3 | 4 | 5 | | ------------ | --------- | ---- | ----- | ----- | ----- | | Approx. wait | immediate | \~5s | \~20s | \~45s | \~80s | * Use `X-Webhook-Event-ID` to deduplicate: the same event can arrive more than once (at-least-once delivery). * If all 5 attempts fail the event is not resent — recover the state with the resource's `GET` (which is why no flow should depend ONLY on the webhook). ## Best practices * Respond `200` immediately and process in the background. * Log the `X-Webhook-Delivery-ID` for traceability. * Don't rely solely on webhooks for critical states: you can always query the object by API (`GET /v1/payouts/{id}`, etc.). # Change my login email Source: https://docs.cbpayapp.com/api-reference/account/change-my-login-email /openapi.yaml post /v1/me/email/change Starts a login-email change. A verification code is sent to the NEW email; confirm with `POST /v1/me/email/confirm`. When the OTP policy requires it, an `X-OTP-Token` for the `email_change` action is also required. Verifying the new email is mandatory — it prevents an attacker from pointing the login at a mailbox they do not control. # Change my password Source: https://docs.cbpayapp.com/api-reference/account/change-my-password /openapi.yaml post /v1/me/password Changes the calling member's password. Requires the current password (members created via social login without a password set their first one here, leaving `current_password` empty) and, when the OTP policy requires it, an `X-OTP-Token` for the `password_change` action. All other sessions are revoked and a fresh session is returned. # Confirm an email change or verification Source: https://docs.cbpayapp.com/api-reference/account/confirm-an-email-change-or-verification /openapi.yaml post /v1/me/email/confirm Confirms a pending email change (or verification) with the code received. On a change, the login email — and, for owners, the account contact email — are updated and the old email is notified. # Delete my profile photo Source: https://docs.cbpayapp.com/api-reference/account/delete-my-profile-photo /openapi.yaml delete /v1/me/avatar # Download a file you uploaded Source: https://docs.cbpayapp.com/api-reference/account/download-a-file-you-uploaded /openapi.yaml get /v1/me/txn-reviews/{reviewID}/files/{fileID}/download Downloads a file previously uploaded by the account for this review. Returns the raw binary with the original `Content-Type`. Files from other accounts or other reviews answer `404 not_found` (never `403`, so existence is not leaked). # Enabled services Source: https://docs.cbpayapp.com/api-reference/account/enabled-services /openapi.yaml get /v1/services Effective map of which products this account can use right now (payouts, payins, transfers, crypto, banking, kyc, cards). When a service is disabled, its action endpoints answer 403 service_disabled; reads and money already in flight are never affected. Use it to decide what to show in your UI. # Get a review Source: https://docs.cbpayapp.com/api-reference/account/get-a-review /openapi.yaml get /v1/me/txn-reviews/{reviewID} Detail of one review of the account, with the information request message (`info_request`) and the files already uploaded. The internal hold reason and compliance notes are never exposed (anti tipping-off). A rejected review also includes `decision_note` (the same text as the rejection email) and `decided_at`. # Get an account's avatar Source: https://docs.cbpayapp.com/api-reference/account/get-an-accounts-avatar /openapi.yaml get /v1/avatars/{accountID} Serves the avatar image of an account in the same organization (used for the recipient preview in transfers and contacts). Avatars published to the public CDN answer with a `302` redirect to the CDN URL; legacy avatars are served directly. # Get my settlement settings Source: https://docs.cbpayapp.com/api-reference/account/get-my-settlement-settings /openapi.yaml get /v1/settlement Returns which virtual balance your operations (payouts and service fees) debit from by default, the balance your payins settle into by default (`default_payin_asset`), which assets your organization allows, and the per-operation limit (USDT equivalent) applied to volatile assets (BTC/GOLD). # List my active sessions Source: https://docs.cbpayapp.com/api-reference/account/list-my-active-sessions /openapi.yaml get /v1/me/sessions Lists the member's sessions (devices) with IP, user agent, login method and whether each is the current one. # List my transaction reviews Source: https://docs.cbpayapp.com/api-reference/account/list-my-transaction-reviews /openapi.yaml get /v1/me/txn-reviews Money operations of the account held by the transactional firewall (payouts, crypto withdrawals, payins, banking transfers) with their review status. Filter by `status` and date range (`from`/`to`, organization timezone, both inclusive). When a review moves to `info_requested`, upload the requested documents with `POST /v1/me/txn-reviews/{reviewID}/files` — the operation stays held until the compliance team decides. # My profile QR code Source: https://docs.cbpayapp.com/api-reference/account/my-profile-qr-code /openapi.yaml get /v1/me/qr Returns the account's immutable QR token, its payload URI (`cbpay:pay?to=`) and a ready-to-render PNG. The QR only lets others SEND money to you; it never changes. # My security activity Source: https://docs.cbpayapp.com/api-reference/account/my-security-activity /openapi.yaml get /v1/me/security/events Append-only history of security events for the account (logins, credential changes, factors added/removed). Requires `from`/`to` date filters. # Resolve a recipient by alias or QR Source: https://docs.cbpayapp.com/api-reference/account/resolve-a-recipient-by-alias-or-qr /openapi.yaml get /v1/resolve Returns a minimal preview (alias, display name, type, avatar) of an account in the same organization, to confirm before sending. Provide `alias` or `qr` (the raw token or the full `cbpay:pay?to=…` payload). Returns a uniform 404 and is rate-limited to prevent enumeration. # Revoke all my other sessions Source: https://docs.cbpayapp.com/api-reference/account/revoke-all-my-other-sessions /openapi.yaml post /v1/me/sessions/revoke-all Signs out every session except the current one. # Revoke one of my sessions Source: https://docs.cbpayapp.com/api-reference/account/revoke-one-of-my-sessions /openapi.yaml delete /v1/me/sessions/{sessionID} Signs out a specific device. # Set my default balances (payouts and payins) Source: https://docs.cbpayapp.com/api-reference/account/set-my-default-balances-payouts-and-payins /openapi.yaml put /v1/settlement Changes your default balances. `default_settlement_asset` is the balance your payouts and service fees debit from when a request does not send an explicit `settlement_asset`. `default_payin_asset` is the balance your payins settle into — credits still enter in USDT and the net amount is auto-converted through the swap engine at the real price, with no extra swap spread (the payin already paid its fee and rate; checkout and POS payins keep their per-link asset). Any of USDT, USDC, BTC or GOLD (if enabled for your organization). Send one or both fields. The change applies to new operations only; nothing in flight is re-quoted. # Set my permanent alias Source: https://docs.cbpayapp.com/api-reference/account/set-my-permanent-alias /openapi.yaml put /v1/me/alias Sets the account's public alias ONCE. The alias is permanent and cannot be changed. Format: 4-20 chars (a-z, 0-9, dot, underscore, hyphen), starting and ending alphanumeric; reserved words are rejected. Others can send you money by alias. # Upload a file for an information request Source: https://docs.cbpayapp.com/api-reference/account/upload-a-file-for-an-information-request /openapi.yaml post /v1/me/txn-reviews/{reviewID}/files Uploads a supporting document for a review in `info_requested`. Send the raw binary body with its `Content-Type` (PDF, PNG, JPEG, WEBP, TXT, CSV, DOC(X) or XLS(X), up to 50 MB) and the filename in the `name` query param. Uploading a file moves the review back to `in_review` so the compliance team re-evaluates it. # Upload my profile photo Source: https://docs.cbpayapp.com/api-reference/account/upload-my-profile-photo /openapi.yaml put /v1/me/avatar Uploads (or replaces) the account avatar. Body is the raw image bytes (JPEG, PNG or WebP), max 512 KB. The content type is detected from the file's magic bytes, never the header. When the image is published to the public CDN, `avatar_url` is an absolute URL that loads without authentication; otherwise it is the authenticated API path. # Verify my current email Source: https://docs.cbpayapp.com/api-reference/account/verify-my-current-email /openapi.yaml post /v1/me/email/verify Sends a code to the member's CURRENT email to mark it verified (required to use the email OTP channel for money actions). Confirm with `POST /v1/me/email/confirm`. # Cities catalog for a country Source: https://docs.cbpayapp.com/api-reference/aml-screening/cities-catalog-for-a-country /openapi.yaml get /v1/aml/catalogs/cities Returns the cities of one ISO 3166-1 alpha-2 country, grouped by subdivision: `states` keys are the same ISO 3166-2 codes as `country_subdivisions` in `GET /v1/aml/catalogs`, and `country_cities` lists the cities whose region could not be mapped to a subdivision (offer them too). Neither field is ever `null`. A country without coverage answers 200 with empty lists — fall back to a free-text city field. Static data served with `Cache-Control: public, max-age=86400`; safe to cache for a day. One call per country, then filter by state on the client. # Compliance form catalogs Source: https://docs.cbpayapp.com/api-reference/aml-screening/compliance-form-catalogs /openapi.yaml get /v1/aml/catalogs Returns every catalog (list of value/label pairs) the front should use to build consistent compliance and verification forms: genders, company status, address types, company legal forms (global list plus per-country cascade), income/wealth sources, industry standards with their per-country default, and the full ISO-3166 country and subdivision lists. Static data — safe to cache for hours. `value` is what you send to the API; `label` is what you display. # Download the AML screening report (PDF) Source: https://docs.cbpayapp.com/api-reference/aml-screening/download-the-aml-screening-report-pdf /openapi.yaml get /v1/aml/screenings/{screeningID}/report Branded PDF report of the screening — executive decision page, risk indicators (sanctions, watchlists, PEP, adverse media...), consolidated matches, aliases, glossary and a final backing section with the international data sources consulted. Trilingual via `lang` (default English). Pure read — no fee, no idempotency key. An id that belongs to another account returns 404. # Enable or disable AML monitoring Source: https://docs.cbpayapp.com/api-reference/aml-screening/enable-or-disable-aml-monitoring /openapi.yaml patch /v1/aml/monitoring Toggles continuous compliance monitoring for the account's verified identity. Enabling bills the `compliance_monitoring` fee if configured; disabling is free. Requires a prior screening. Each state change is stored in the audit history (`GET /v1/aml/screenings`, filter kind monitoring). When the state actually changes, `idempotency_key` is required — replaying with the same key returns the original event with `idempotency_hit` true. Toggling to the current state returns unchanged true without charging. # Get an AML screening Source: https://docs.cbpayapp.com/api-reference/aml-screening/get-an-aml-screening /openapi.yaml get /v1/aml/screenings/{screeningID} Detail of a screening, including the full stored result. An id that belongs to another account returns 404. # List AML screenings Source: https://docs.cbpayapp.com/api-reference/aml-screening/list-aml-screenings /openapi.yaml get /v1/aml/screenings History of the account's AML screenings (person, company and rescreens), newest first. Requires the from/to date range; supports pagination and a risk filter. # Look up city and state by postal code Source: https://docs.cbpayapp.com/api-reference/aml-screening/look-up-city-and-state-by-postal-code /openapi.yaml get /v1/aml/catalogs/postal-code Resolves a postal code to its city and state from an embedded public dataset — today only US ZIP codes are covered. Use it to autofill the city/state fields while the user types the ZIP in address forms: a 404 means "unknown ZIP" (or a country without a dataset) and the fields should simply stay manual. Static data served with `Cache-Control: public, max-age=86400`; safe to cache for a day. # Re-run an AML screening Source: https://docs.cbpayapp.com/api-reference/aml-screening/re-run-an-aml-screening /openapi.yaml post /v1/aml/rescreen Re-runs the account's screening. Requires a prior screening. Bills the `compliance_rescreen` fee if configured; refunded on upstream failure. Each rescreen is stored in the audit history (`GET /v1/aml/screenings`). `idempotency_key` is required — replaying with the same key returns the original rescreen with `idempotency_hit` true and never charges twice. # Submit an AML screening Source: https://docs.cbpayapp.com/api-reference/aml-screening/submit-an-aml-screening /openapi.yaml post /v1/aml/screenings Screens a person or company (detected from the payload) against sanctions, PEP and adverse-media lists via the compliance service. This is list screening only — identity verification with documents and liveness lives under /v1/kyc and /v1/kyb. All identity fields are optional and forwarded whole to the screening engine; the more identity data you send (date of birth, countries, strong identifiers, aliases), the more precise the analysis. If a compliance fee is configured, it is debited before the call and refunded if the screening fails. The result is stored locally for audit history (`GET /v1/aml/screenings`). `idempotency_key` is required — replaying with the same key returns the original screening with `idempotency_hit` true and never charges twice. # Complete two-step login Source: https://docs.cbpayapp.com/api-reference/authentication/complete-two-step-login /openapi.yaml post /v1/auth/login/otp Exchanges the `pending_token` from `POST /v1/auth/login` plus the OTP code received over SMS/WhatsApp for the real session token. The pending token expires after 10 minutes and can never call the API by itself. # Exchange a handoff token for a test session Source: https://docs.cbpayapp.com/api-reference/authentication/exchange-a-handoff-token-for-a-test-session /openapi.yaml post /v1/auth/handoff Test environment only (live always answers 404). Exchanges the single-use token issued by `POST /v1/auth/environment-handoff` in live for a session of the test environment. If the account does not exist in test yet, its mirror is provisioned automatically — the test mode always exists for every user. # Get a test-mode handoff token Source: https://docs.cbpayapp.com/api-reference/authentication/get-a-test-mode-handoff-token /openapi.yaml post /v1/auth/environment-handoff Issues a single-use token (60-second TTL) that moves the CURRENT member session to the test environment — this is what powers the dashboard's one-click test/live switch, with no second login and no new OTP challenge. Only the live environment issues handoffs, and only member sessions (JWT) can request one: API keys never switch environments (each environment manages its own keys). # Log in Source: https://docs.cbpayapp.com/api-reference/authentication/log-in /openapi.yaml post /v1/auth/login # Refresh the session Source: https://docs.cbpayapp.com/api-reference/authentication/refresh-the-session /openapi.yaml post /v1/auth/refresh Exchanges a single-use refresh token (`rt_…`, issued with every login) for a fresh access token + refresh token pair without asking for credentials again. Strict rotation: the previous access token of that device is revoked on exchange, and presenting an already-exchanged refresh token revokes the device's entire token chain (theft response) and records a `refresh_token_reuse` security event. Refresh tokens last 30 days per rotation with an absolute cap of 90 days from the original login; signing out, revoking sessions or changing the password also invalidates them. # Register an account Source: https://docs.cbpayapp.com/api-reference/authentication/register-an-account /openapi.yaml post /v1/auth/register Creates a person or company account with its owner login member and returns a session token. One endpoint for both types: `type` is data. # Request a password reset code Source: https://docs.cbpayapp.com/api-reference/authentication/request-a-password-reset-code /openapi.yaml post /v1/auth/password/forgot Sends a reset code to the member's email (default) or to their verified phone (`channel:"sms"`). Always returns 200 with the same body regardless of whether the account exists — this prevents account enumeration. Complete the reset with `POST /v1/auth/password/reset`. # Reset the password with a code Source: https://docs.cbpayapp.com/api-reference/authentication/reset-the-password-with-a-code /openapi.yaml post /v1/auth/password/reset Validates the code sent by `forgot` and sets a new password. All existing sessions are revoked. A single generic error (`invalid_code`) covers unknown org/member/code so nothing about account existence leaks. # Account statement (JSON, PDF or Excel) Source: https://docs.cbpayapp.com/api-reference/balances/account-statement-json-pdf-or-excel /openapi.yaml get /v1/reports/statement Consolidates every movement of the period — payouts, payins, crypto deposits and withdrawals, internal transfers and service charges — into one auditable statement with an exact reconciliation: opening_balance + total_in − total_out = closing_balance, verified against the ledger (`balanced`). The summary and movements cover the USDT operating balance; every other virtual balance (USDC, BTC, GOLD) with activity reconciles independently inside `assets`. Request `format=pdf` or `format=xlsx` to download the CBPay-branded file (Content-Disposition: attachment). # Get balance history Source: https://docs.cbpayapp.com/api-reference/balances/get-balance-history /openapi.yaml get /v1/balances/history Daily evolution of the account's balances for dashboard charts: one series per asset (the closing available balance of each day, carried forward on days without movements) plus an aggregated USD series that values BTC/GOLD at each day's historical reference price. Includes the period's total inflows/outflows and the current snapshot (available + held), so a front can render a Mercury-style balance card with its percentage change. The series tracks the **available** balance (holds have no history); `current` carries today's `held` per asset. Days valued with today's spot price (before historical prices exist) are disclosed in `spot_priced_dates` — values are never invented. The `assets` map also includes the banking account mirrors (`BANK_USD`, `BANK_EUR`) as their own series in their own currency (2 decimals). They are **not** part of the `total_usd` aggregate, which only covers the operational balances (USDT, USDC, BTC, GOLD). # Get balances Source: https://docs.cbpayapp.com/api-reference/balances/get-balances /openapi.yaml get /v1/balances The four independent virtual balances of the calling account (USDT — the operating currency —, USDC, BTC and GOLD in grams of fine gold). Every supported asset is always present, with zeros if the account has not used that currency yet. If the account uses Banking, its BANK_USD/BANK_EUR mirror balances are also listed with the `custody` marker set to `banking` (the authoritative balance lives at the bank; these balances are not spendable inside the platform). # Get FX rate history Source: https://docs.cbpayapp.com/api-reference/balances/get-fx-rate-history /openapi.yaml get /v1/rates/history Time series of the FX rates that apply to the calling account, for rate-evolution charts. Each point carries the same rates you would get operating at that time — `rate` (payout side) and `payin_rate` (deposit side) — and every country block includes `first`, `last` and the signed `change_pct` between them, ready for a "+3.4% / -3.0%" badge. `asset_prices` carries the USD reference series for BTC and GOLD. Buckets without data carry the previous value forward; days before history started are omitted (never invented). # Get my FX rates and fees Source: https://docs.cbpayapp.com/api-reference/balances/get-my-fx-rates-and-fees /openapi.yaml get /v1/rates Returns the exchange rates that apply to the calling account for each country — the same rates used when operations execute (`local_amount / rate = USDT`) — plus the account's **effective** fee configuration — org defaults already resolved against your account's overrides — so clients can compute the exact cost of an operation before creating it. `rate` is the payout (dispersal) side and `payin_rate` the payin (deposit) side. `asset_prices` carries the USD reference price of each virtual balance asset (BTC per unit, GOLD per gram; stablecoins are 1 by convention) for display/valuation. The `settlement` block shows, per enabled asset, the **effective settlement price** your account would get right now if it paid an operation from that balance (`settlement_rate`, spread included) and whether the asset is currently `available` — use it to estimate before sending `settlement_asset` in a payout. # List movements Source: https://docs.cbpayapp.com/api-reference/balances/list-movements /openapi.yaml get /v1/movements Immutable ledger history of the calling account. # Add a destination to a contact Source: https://docs.cbpayapp.com/api-reference/contacts/add-a-destination-to-a-contact /openapi.yaml post /v1/contacts/{contactID}/destinations Saves a reusable destination manually: `payout` (country + method + beneficiary), `crypto` (chain + address) or `cbpay` (requires the contact to be linked). Destinations are deduplicated: repeating one refreshes its last use. # Create a contact Source: https://docs.cbpayapp.com/api-reference/contacts/create-a-contact /openapi.yaml post /v1/contacts Adds a contact manually. The phone is normalized to E.164 (local numbers use your account's country) and matched against accounts of your operator to fill `has_cbpay`. # Delete a contact Source: https://docs.cbpayapp.com/api-reference/contacts/delete-a-contact /openapi.yaml delete /v1/contacts/{contactID} Deletes the contact and its saved destinations. # Delete a saved destination Source: https://docs.cbpayapp.com/api-reference/contacts/delete-a-saved-destination /openapi.yaml delete /v1/contacts/{contactID}/destinations/{destinationID} # Get a contact Source: https://docs.cbpayapp.com/api-reference/contacts/get-a-contact /openapi.yaml get /v1/contacts/{contactID} The contact plus its saved destinations (CBPay account, payout beneficiaries per corridor, on-chain addresses). # Import the phone address book Source: https://docs.cbpayapp.com/api-reference/contacts/import-the-phone-address-book /openapi.yaml post /v1/contacts/import Uploads up to 1,000 contacts per request (paginate beyond that). Phones are normalized to E.164; existing contacts are never duplicated; `has_cbpay` tells you which contacts already have an active account of your same operator (match by phone). # List contacts Source: https://docs.cbpayapp.com/api-reference/contacts/list-contacts /openapi.yaml get /v1/contacts Your account's private contact book, ordered by favorites and last use. Contacts are created automatically by every send (transfer, payout, crypto withdrawal), by manual CRUD or by importing the phone's address book. # Update a contact Source: https://docs.cbpayapp.com/api-reference/contacts/update-a-contact /openapi.yaml patch /v1/contacts/{contactID} Partial update (name, alias, phone, email, favorite). Changing the phone re-runs the CBPay match. # Create an on-chain withdrawal Source: https://docs.cbpayapp.com/api-reference/crypto/create-an-on-chain-withdrawal /openapi.yaml post /v1/crypto/withdrawals Sends USDT, USDC or BTC on-chain from its own balance. Supported pairs: `tron`/`USDT`, `eth`/`USDT`, `eth`/`USDC`, `btc`/`BTC` (GOLD has no on-chain rail). `amount + withdrawal fee` is debited atomically and held; final state arrives via the `crypto_withdrawal_status_changed` webhook. `failed` refunds the full debit. # Get a withdrawal Source: https://docs.cbpayapp.com/api-reference/crypto/get-a-withdrawal /openapi.yaml get /v1/crypto/withdrawals/{withdrawalID} # List my wallets Source: https://docs.cbpayapp.com/api-reference/crypto/list-my-wallets /openapi.yaml get /v1/crypto/wallets Returns every wallet of the calling account, oldest first. # List on-chain activity Source: https://docs.cbpayapp.com/api-reference/crypto/list-on-chain-activity /openapi.yaml get /v1/crypto/transactions Funding credits and withdrawals of the calling account. # Restore a missing deposit wallet Source: https://docs.cbpayapp.com/api-reference/crypto/restore-a-missing-deposit-wallet /openapi.yaml post /v1/crypto/wallets Every account — person and company — holds exactly **one deposit wallet per supported pair** (`tron`/`usdt`, `eth`/`usdt`, `eth`/`usdc`, `btc`/`btc`), provisioned automatically and free of charge on registration. Deposit wallets only **receive** crypto that credits the account's virtual balance: they cannot send, and cannot be exported or imported (use segregated wallets for that). This endpoint exists only to restore a missing pair (an exceptional case): with the pair already provisioned it responds `422 wallet_limit_reached` for every account type. A manual restoration bills the `wallet_creation` fee when configured by CBPay (fixed per wallet; 0 = free, the default); birth wallets are always free. The charge is refunded automatically if creation fails. # Active collection (pull) Source: https://docs.cbpayapp.com/api-reference/payins/active-collection-pull /openapi.yaml post /v1/payins/collect Charges the payer directly in corridors that support pull collections (e.g. Venezuela `c2p` / `debito_inmediato`). Synchronous: if the charge is approved the deposit converts at your current `payin_rate` and is credited in the same call, net of the payin fee. If the payer declines, `paid` is false, the payin is marked failed and nothing is charged. # Create a dedicated deposit account Source: https://docs.cbpayapp.com/api-reference/payins/create-a-dedicated-deposit-account /openapi.yaml post /v1/payins/deposit-accounts Provisions a fixed deposit destination bound to your account in corridors that support it (e.g. a Mexican CLABE or an Argentine CVU). Every transfer arriving to it is credited automatically, net of the payin fee. Creation is free. # Create a payin (top-up charge) Source: https://docs.cbpayapp.com/api-reference/payins/create-a-payin-top-up-charge /openapi.yaml post /v1/payins Creates a fiat top-up. With `method: "qr"` (default) an active QR charge is generated through the processor — in Bolivia the local interoperable QR, in Brazil a dynamic PIX QR whose `charge` carries the QR image and the "copia e cola" payload. With `method: "bank_transfer"` the deposit is announced and the response carries the reference the sender must include so the arriving transfer is matched and credited automatically — a short 12-character alphanumeric code that fits any bank concept field (some rails cap it at 20 characters with no special characters). With `method: "fintoc"` (Chile) the response carries a hosted `payment_url`: the payer opens it and transfers from any Chilean bank or wallet; the deposit is detected, validated and credited automatically. With `method: "card"` the response carries a hosted `payment_url` for a 3-D Secure card checkout branded with your organization — Bolivia (BOB) and international cards in USD (`country: "US"`, Visa, Mastercard, American Express, Discover and Diners issued anywhere); card data never touches your integration. With `method: "checkout"` the response carries a public `checkout_url` — a branded universal checkout link denominated in the virtual balance of your choice (`settlement_asset`: USDT, USDC, BTC or GOLD). The payer picks any country with a live payin corridor (quoted local amount), any of the 4 crypto options (exclusive deposit address with a scannable QR), or pays instantly from the CBPay app via the merchant QR/alias; every payment is auto-converted to the settlement asset unless paid in the same asset. The first method that completes the payment wins. Charging is free; the payin fee applies when the deposit is credited. # Get a payin Source: https://docs.cbpayapp.com/api-reference/payins/get-a-payin /openapi.yaml get /v1/payins/{payinID} # Get a refund Source: https://docs.cbpayapp.com/api-reference/payins/get-a-refund /openapi.yaml get /v1/payin-refunds/{refundID} # List my deposit accounts Source: https://docs.cbpayapp.com/api-reference/payins/list-my-deposit-accounts /openapi.yaml get /v1/payins/deposit-accounts Lists the dedicated deposit destinations bound to the calling account. # List payin methods Source: https://docs.cbpayapp.com/api-reference/payins/list-payin-methods /openapi.yaml get /v1/payins/methods Available payin corridors. Every item carries `availability` (`operational` | `degraded` | `down`) — the live health of the corridor (see the service status guide). # List payins Source: https://docs.cbpayapp.com/api-reference/payins/list-payins /openapi.yaml get /v1/payins # List refunds Source: https://docs.cbpayapp.com/api-reference/payins/list-refunds /openapi.yaml get /v1/payin-refunds Every refund of the account. Filter by `payin_id`, `status` (`pending` | `completed` | `failed`) and `kind` (`refund` | `void` | `chargeback`). # List refunds of a payin Source: https://docs.cbpayapp.com/api-reference/payins/list-refunds-of-a-payin /openapi.yaml get /v1/payins/{payinID}/refunds # Preview the deposit instructions for a corridor Source: https://docs.cbpayapp.com/api-reference/payins/preview-the-deposit-instructions-for-a-corridor /openapi.yaml get /v1/payins/deposit-instructions Returns the bank account text your organization publishes for a `bank_transfer` corridor — same content that gets embedded automatically in an announced payin's response. Useful to render a "where do I send the money" screen before the payer creates the payin, or to show the current instructions on a static page. Only corridors with an active, admin-configured instruction return content; everything else responds `404 not_found` (nothing to preview — the corridor either doesn't require it or nobody configured it yet). # Refund a card payin Source: https://docs.cbpayapp.com/api-reference/payins/refund-a-card-payin /openapi.yaml post /v1/payins/{payinID}/refunds Returns money to the cardholder and reverses the credit on your balance. Only card-acquired payins in status `credited` can be refunded (`refund_not_supported` on the other rails) — and only once their balance is actually available: a credited payin whose settlement is still pending (`settlement_pending: true`, balance lands at `settle_at`) is declined with `422 settlement_pending`. The gross amount is debited: the fee and the FX spread of the original payin are NOT refundable. `idempotency_key` is mandatory; a replay returns the same refund with `idempotency_hit` and never sends a second refund to the processor. Requires OTP when the caller is a session (API keys are exempt). # Refund receipt (PDF) Source: https://docs.cbpayapp.com/api-reference/payins/refund-receipt-pdf /openapi.yaml get /v1/payin-refunds/{refundID}/receipt Branded PDF receipt with a public verification code. Use `?lang=es|en`. # Request a collection OTP Source: https://docs.cbpayapp.com/api-reference/payins/request-a-collection-otp /openapi.yaml post /v1/payins/collect/otp Requests the one-time password some pull methods need before the charge (e.g. immediate debit). Free of charge. # Confirm a QR payout (Bolivia, Brazil) Source: https://docs.cbpayapp.com/api-reference/payouts/confirm-a-qr-payout-bolivia-brazil /openapi.yaml post /v1/payouts/qr/confirm Step 2 of the QR payout (Bolivia and Brazil/PIX), charged exactly like a regular payout: the local amount converts at **your account's rate** and `usdt_amount + fixed fee` is debited. The result is **synchronous** — the response carries the final state (`completed`, or `failed` with an automatic refund). Requires an idempotency key like every money-moving operation. In Bolivia the scanned reference is single-use (one QR = one payment); in Brazil a **static PIX QR is reusable by design** — pay it again with a different key, and a failed attempt never burns the QR. `amount` is always required; in Brazil, if the PIX QR carries a fixed amount it must match exactly — a mismatch answers `422` with the payout in `status: failed` and the refund already applied (open-amount QRs pay whatever you send). # Create a payout Source: https://docs.cbpayapp.com/api-reference/payouts/create-a-payout /openapi.yaml post /v1/payouts Disperses fiat to a local bank account. The local amount converts to USDT at **your account's rate** (the same one returned by `GET /v1/rates`); `usdt_amount` plus the fixed fee (when configured) is debited and held until the dispersal completes. If it fails, the full debit is refunded. **Pay from any balance**: by default the debit comes from your account's default settlement asset (USDT unless changed via `PUT /v1/settlement`). Send `settlement_asset` (USDT, USDC, BTC or GOLD) to pay this specific operation from another balance: the USDT total converts to that asset at the effective settlement price of the moment (see the `settlement` block in `GET /v1/rates`) and the debit, hold and — if it fails — the refund all live in that asset for the exact same amount. If the execution price for BTC/GOLD is unavailable the request returns `503 pricing_unavailable`; volatile assets also have a per-operation limit (`422 settlement_limit_exceeded`). Requires an idempotency key (body field or `Idempotency-Key` header). Retrying with the same key returns the original payout (`200` with `idempotency_hit: true`). # Get a payout Source: https://docs.cbpayapp.com/api-reference/payouts/get-a-payout /openapi.yaml get /v1/payouts/{payoutID} # List destination banks Source: https://docs.cbpayapp.com/api-reference/payouts/list-destination-banks /openapi.yaml get /v1/payouts/banks Bank catalog for a country's payouts. Without `method` it returns the union of every payout method's banks for the country (deduplicated by code); pass `method` to get a single channel's catalog. Some methods have no bank catalog (e.g. QR) and contribute no entries. # List payout methods Source: https://docs.cbpayapp.com/api-reference/payouts/list-payout-methods /openapi.yaml get /v1/payouts/methods Available payout corridors (country, currency, method) offered by CBPay. Pass-through of the processing core catalog. Every item carries `availability` (`operational` | `degraded` | `down`) — the live health of the corridor, so you can decide at render time whether to show, warn about or hide a channel (see the service status guide). A corridor with no recorded incidents is always `operational`. # List payouts Source: https://docs.cbpayapp.com/api-reference/payouts/list-payouts /openapi.yaml get /v1/payouts Returns the payouts of the calling account. # Look up a bank by routing number or SWIFT/BIC Source: https://docs.cbpayapp.com/api-reference/payouts/look-up-a-bank-by-routing-number-or-swiftbic /openapi.yaml get /v1/payouts/bank-directory/lookup Autocompletes the beneficiary bank from an embedded public bank directory — pass exactly one of `routing_number` (9 digits, US only) or `swift` (8 or 11 characters; an `XXX` suffix is normalized to the head office). Use it to autodetect the bank while the sender types the routing number or SWIFT/BIC in payout or counterparty forms: a 404 means "not in the directory" and the form should simply stay manual. Static data served with `Cache-Control: public, max-age=86400`; safe to cache for a day. # Scan a payout QR (Bolivia, Brazil) Source: https://docs.cbpayapp.com/api-reference/payouts/scan-a-payout-qr-bolivia-brazil /openapi.yaml post /v1/payouts/qr/scan Step 1 of the QR payout — Bolivia (local interoperable QR) and Brazil (PIX QR, including the "copia e cola" code): reads a collection QR and returns the recipient's data plus the `provider_reference` needed to confirm. **Free of charge** — nothing is debited until the confirm step. Defaults to Bolivia; for Brazil send `country: "BR"` and `currency: "BRL"`. Brazil supports **static** PIX QRs (key embedded); a dynamic QR (PSP URL payload) answers `400` — ask the beneficiary for their PIX key and use the `pix` method. An empty `amount` in the scan response means an open-amount QR. # Upload a payout supporting document Source: https://docs.cbpayapp.com/api-reference/payouts/upload-a-payout-supporting-document /openapi.yaml post /v1/payouts/documents Uploads the supporting document (invoice/receipt) that the US/USD corridor requires on EVERY outbound transfer (`ach`, `wire` and `swift`). Send the raw binary body with its `Content-Type` (PDF, PNG, JPEG, WEBP, TXT, CSV, DOC(X) or XLS(X), up to 50 MB) and the filename in the `name` query param. The returned `document_key` travels as `options.supporting_document_key` when creating the payout (`POST /v1/payouts`); the optional `options.document_reference_number` is sent to the bank on `ach`/`wire` only. A US/USD payout created without the document fails with `400 supporting_document_required`; a key uploaded by another account fails with `400 invalid_document_key`. # Get a POS merchant Source: https://docs.cbpayapp.com/api-reference/qr-crypto-pos/get-a-pos-merchant /openapi.yaml get /v1/pos/merchants/{merchantID} Merchant detail. Only the owning account (or the org admin) can read it. # List POS charges Source: https://docs.cbpayapp.com/api-reference/qr-crypto-pos/list-pos-charges /openapi.yaml get /v1/pos/charges Charges of the account with merchant_id/status filters and a mandatory date range — the per-client reconciliation view. # List POS merchants Source: https://docs.cbpayapp.com/api-reference/qr-crypto-pos/list-pos-merchants /openapi.yaml get /v1/pos/merchants Paginated list of the merchants registered by the account (processor). # Register a POS merchant Source: https://docs.cbpayapp.com/api-reference/qr-crypto-pos/register-a-pos-merchant /openapi.yaml post /v1/pos/merchants Registers a merchant (restaurant, hotel, store) bound to an APPROVED third-party KYC/KYB verification — the identity comes from the verification. fee_percent/fee_fixed are the processor's informative commission to the merchant (computed per paid charge and in the summary; never charged by the platform). # Update a POS merchant Source: https://docs.cbpayapp.com/api-reference/qr-crypto-pos/update-a-pos-merchant /openapi.yaml patch /v1/pos/merchants/{merchantID} Updates status (active/disabled — a disabled merchant cannot generate new charges), the informative commission and the external reference. Identity is never edited: it comes from the verification. # Link a provider to my account Source: https://docs.cbpayapp.com/api-reference/social-login/link-a-provider-to-my-account /openapi.yaml post /v1/me/identities # List enabled social providers Source: https://docs.cbpayapp.com/api-reference/social-login/list-enabled-social-providers /openapi.yaml get /v1/auth/oauth/providers Public. Returns the social providers enabled for the org and their `client_id`, so the front end can render the right buttons. # List my linked providers Source: https://docs.cbpayapp.com/api-reference/social-login/list-my-linked-providers /openapi.yaml get /v1/me/identities # Sign in or register with a social provider Source: https://docs.cbpayapp.com/api-reference/social-login/sign-in-or-register-with-a-social-provider /openapi.yaml post /v1/auth/oauth Token exchange. The front end obtains the provider credential (Google/Apple/Microsoft `id_token`, Facebook `access_token`) and posts it here. The API verifies it against the provider and returns the CBPay session — creating the account on first use. Respects the account's OTP-on-login policy (may return `otp_required`). # Unlink a provider Source: https://docs.cbpayapp.com/api-reference/social-login/unlink-a-provider /openapi.yaml delete /v1/me/identities/{provider} Blocked with `409 last_login_method` if it is the only sign-in method (no password and no other linked provider). # Create a swap Source: https://docs.cbpayapp.com/api-reference/swaps/create-a-swap /openapi.yaml post /v1/swaps Converts balance between two of your currencies, synchronously and atomically — your balance changes instantly and the money never leaves your account (no OTP). Volatile pairs (BTC/GOLD) share the per-operation and 24h volume limits with payouts and card purchases. Requires an idempotency key: replaying returns the original swap and never re-executes. # Get a swap Source: https://docs.cbpayapp.com/api-reference/swaps/get-a-swap /openapi.yaml get /v1/swaps/{swapID} # List swaps Source: https://docs.cbpayapp.com/api-reference/swaps/list-swaps /openapi.yaml get /v1/swaps # Quote a swap Source: https://docs.cbpayapp.com/api-reference/swaps/quote-a-swap /openapi.yaml get /v1/swaps/quote Free indicative quote to convert between your USDT, USDC, BTC and GOLD balances (any pair, source different from destination). The rate you see is your account's execution rate — quoted = received, no separate fees. Indicative: execution uses the price at the POST moment (stablecoin pairs are stable). # Create an internal transfer Source: https://docs.cbpayapp.com/api-reference/transfers/create-an-internal-transfer /openapi.yaml post /v1/transfers Moves balance between two CBPay accounts, atomically and always free of charge — any combination works: person to person, person to company, company to person or company to company. Works with the four virtual balances (`USDT` default, `USDC`, `BTC`, `GOLD`) and always between balances of the **same currency** (no conversion). The destination is resolved by `to_account_id`, `to_email`, `to_phone` (OTP-verified phones only) or `to_contact_id`. Every transfer saves the recipient as a contact automatically (opt out with `save_contact: false`). The recipient's webhook subscribers receive `transfer_received`. # Get a transfer Source: https://docs.cbpayapp.com/api-reference/transfers/get-a-transfer /openapi.yaml get /v1/transfers/{transferID} Returns one transfer; visible only to its two parties. # List transfers Source: https://docs.cbpayapp.com/api-reference/transfers/list-transfers /openapi.yaml get /v1/transfers Lists the account's transfers (sent and received), newest first. # Get an address screening Source: https://docs.cbpayapp.com/api-reference/wallet-screening/get-an-address-screening /openapi.yaml get /v1/screenings/addresses/{screeningID} Detail of a screening, including the full stored assessment. An id that belongs to another account returns 404. # List address screenings Source: https://docs.cbpayapp.com/api-reference/wallet-screening/list-address-screenings /openapi.yaml get /v1/screenings/addresses History of the account's screenings, newest first. Requires the from/to date range; supports pagination and a risk filter. # Screen a blockchain address Source: https://docs.cbpayapp.com/api-reference/wallet-screening/screen-a-blockchain-address /openapi.yaml post /v1/screenings/addresses Assesses the AML risk of a blockchain address (sanctioned entity, illicit-fund exposure) against global on-chain intelligence and returns a normalized risk level (Low/Medium/High/Severe) with the full evidence. Network-agnostic: the address is evaluated across every supported chain at once; `chain` only labels your record. Bills the fixed `address_screening` fee (refunded automatically if the screening fails), so `idempotency_key` is required — replaying with the same key returns the original screening with `idempotency_hit: true` and never charges twice. Requires the `screenings` service enabled and an approved account verification. # A payer saved a card on the hosted checkout explicit consent on a 3 d secure approved seed payment Source: https://docs.cbpayapp.com/api-reference/a-payer-saved-a-card-on-the-hosted-checkout-explicit-consent-on-a-3-d-secure-approved-seed-payment /openapi.yaml webhook card_stored # A stored card was revoked by you via delete v1stored cards Source: https://docs.cbpayapp.com/api-reference/a-stored-card-was-revoked-by-you-via-delete-v1stored-cards- /openapi.yaml webhook stored_card_revoked # A subscription changed status active paused past due after dunning or canceled Source: https://docs.cbpayapp.com/api-reference/a-subscription-changed-status-active-paused-past_due-after-dunning-or-canceled /openapi.yaml webhook subscription_status_changed # Account summary for dashboards Source: https://docs.cbpayapp.com/api-reference/analytics/account-summary-for-dashboards /openapi.yaml get /v1/analytics/summary Aggregated view of the whole account flow in a single call: gross volume in/out, transaction counts, new banking users, per-service sections (payouts, payins, deposits, withdrawals, transfers, swaps, cards, banking, verifications, aml, contacts) with their dimensions (country, currency, method, status, chain, merchant), service spending and valued balances. Amounts are USD decimal strings; empty buckets are zero-filled. Account credential only. # Add an account to a beneficiary Source: https://docs.cbpayapp.com/api-reference/banking/add-an-account-to-a-beneficiary /openapi.yaml post /v1/banking/counterparties/{counterpartyID}/accounts # Create my banking profile Source: https://docs.cbpayapp.com/api-reference/banking/create-my-banking-profile /openapi.yaml post /v1/banking/customer Opens the account's banking profile (once per account). Omitted `type`/`name`/`email` autofill from the CBPay account. Billed as the fixed `banking_customer` fee from your settlement asset (USDT by default); refunded if the corridor rejects. # Get a bank account Source: https://docs.cbpayapp.com/api-reference/banking/get-a-bank-account /openapi.yaml get /v1/banking/accounts/{bankAccountID} Returns the live details of one of your bank accounts (name, currency, status and the receiving requisites — wire/local rails — under `data`). Use it to render the deposit instructions of a specific account. If the bank cannot serve the account live, the endpoint returns the last mirrored snapshot and `source` is `mirror` (normally `live`). # Get a bank account balance Source: https://docs.cbpayapp.com/api-reference/banking/get-a-bank-account-balance /openapi.yaml get /v1/banking/accounts/{bankAccountID}/balance # Get a bank payment Source: https://docs.cbpayapp.com/api-reference/banking/get-a-bank-payment /openapi.yaml get /v1/banking/operations/{operationID} Operation with live status and the enriched neutral view: the optional fields direction, amount (net decimal string), currency, counterparty and reference appear only when the banking provider reports them. # Get a third-party banking user (live KYC status) Source: https://docs.cbpayapp.com/api-reference/banking/get-a-third-party-banking-user-live-kyc-status /openapi.yaml get /v1/banking/third-parties/{thirdPartyID} # Get my banking profile Source: https://docs.cbpayapp.com/api-reference/banking/get-my-banking-profile /openapi.yaml get /v1/banking/customer Returns the account's banking profile with its live verification status. # List my bank accounts Source: https://docs.cbpayapp.com/api-reference/banking/list-my-bank-accounts /openapi.yaml get /v1/banking/accounts # List my bank payments Source: https://docs.cbpayapp.com/api-reference/banking/list-my-bank-payments /openapi.yaml get /v1/banking/operations History of your bank operations, including inbound deposits and charges discovered automatically. Items carry the enriched neutral view: the optional fields direction, amount (net decimal string — credited amount for `in`, net charge for `out`), currency, counterparty and reference appear only when the banking provider reports them. # List my beneficiaries Source: https://docs.cbpayapp.com/api-reference/banking/list-my-beneficiaries /openapi.yaml get /v1/banking/counterparties # List the third party's bank accounts Source: https://docs.cbpayapp.com/api-reference/banking/list-the-third-partys-bank-accounts /openapi.yaml get /v1/banking/third-parties/{thirdPartyID}/accounts # List third-party banking users Source: https://docs.cbpayapp.com/api-reference/banking/list-third-party-banking-users /openapi.yaml get /v1/banking/third-parties # Open a bank account Source: https://docs.cbpayapp.com/api-reference/banking/open-a-bank-account /openapi.yaml post /v1/banking/accounts Opens a bank account for the approved profile (one per currency as enabled). Billed as the fixed `banking_account` fee; refunded if the corridor rejects. `data` carries the receiving details (account number/IBAN, routing, bank). # Open a bank account for the third party Source: https://docs.cbpayapp.com/api-reference/banking/open-a-bank-account-for-the-third-party /openapi.yaml post /v1/banking/third-parties/{thirdPartyID}/accounts # Quote a bank payment Source: https://docs.cbpayapp.com/api-reference/banking/quote-a-bank-payment /openapi.yaml post /v1/banking/operations/prepare Validates and quotes an operation (fees, balances) without moving money. Free. # Register a beneficiary Source: https://docs.cbpayapp.com/api-reference/banking/register-a-beneficiary /openapi.yaml post /v1/banking/counterparties Registers a third-party beneficiary with its banking details. Free; the beneficiary account goes through moderation before it can receive WITHDRAW payments. # Register a third-party banking user (companies only) Source: https://docs.cbpayapp.com/api-reference/banking/register-a-third-party-banking-user-companies-only /openapi.yaml post /v1/banking/third-parties Company accounts register end clients as separate banking users with accounts in their name. Requires the verification_id of an APPROVED KYC/KYB verification of the third party: the type comes from the verification kind (KYC = INDIVIDUAL, KYB = COMPANY), identity data auto-fills from the verified profile (explicit fields win) and the already-validated documents are re-delivered automatically to the banking provider (documents_synced). Charges the banking profile fee (refunded if the core rejects). Person accounts get 403 company_required. # Send a bank payment Source: https://docs.cbpayapp.com/api-reference/banking/send-a-bank-payment /openapi.yaml post /v1/banking/operations Executes a TRANSFER (between your own bank accounts) or WITHDRAW (to a registered beneficiary). Requires an idempotency key; retries with the same key return the original operation without re-charging. Billed as the fixed `banking_operation` fee; refunded if the corridor rejects. The final state arrives via the banking_operation_status_changed webhook. # Submit my profile for review Source: https://docs.cbpayapp.com/api-reference/banking/submit-my-profile-for-review /openapi.yaml post /v1/banking/customer/submit Sends the banking profile to verification. Free. Track progress via GET /v1/banking/customer or the banking_customer_status_changed webhook. # Submit the third party to verification (free) Source: https://docs.cbpayapp.com/api-reference/banking/submit-the-third-party-to-verification-free /openapi.yaml post /v1/banking/third-parties/{thirdPartyID}/submit # Third party's bank account balance Source: https://docs.cbpayapp.com/api-reference/banking/third-partys-bank-account-balance /openapi.yaml get /v1/banking/third-parties/{thirdPartyID}/accounts/{bankAccountID}/balance # Upload a KYC document for the third party (free) Source: https://docs.cbpayapp.com/api-reference/banking/upload-a-kyc-document-for-the-third-party-free /openapi.yaml post /v1/banking/third-parties/{thirdPartyID}/documents # Upload a verification document Source: https://docs.cbpayapp.com/api-reference/banking/upload-a-verification-document /openapi.yaml post /v1/banking/customer/documents Uploads one KYC document (base64) to the banking profile. Free of charge. # Activate a physical card Source: https://docs.cbpayapp.com/api-reference/cards/activate-a-physical-card /openapi.yaml post /v1/cards/{cardID}/activate Confirms the holder received the physical card. Only cards in `pending_activation`. # Business activity catalog Source: https://docs.cbpayapp.com/api-reference/cards/business-activity-catalog /openapi.yaml get /v1/cards/catalog/business-activities Official issuer economic-activity codes (for `kind_of_business` when creating a company cardholder). Search with `q`. Use the `code` value; a free-text value is rejected with 400 invalid_kind_of_business. # Cancel a card Source: https://docs.cbpayapp.com/api-reference/cards/cancel-a-card /openapi.yaml post /v1/cards/{cardID}/cancel Cancels the card permanently (irreversible). Bills the `card_cancellation` fee when configured (0 = free); refunded if the cancellation fails upstream. # Create a card Source: https://docs.cbpayapp.com/api-reference/cards/create-a-card /openapi.yaml post /v1/cards Issues a card (virtual or physical) that spends Just-In-Time from the account's central balance in the card's spending asset (`spending_asset`: USDT/USDC 1:1 with the USD, or BTC/GOLD converted at the price of the moment of each event; USDT by default) — no prefunding: every purchase is authorized in real time against the available balance and the card's own limits. Person accounts can hold **1 virtual + 1 physical** card; company accounts can create **unlimited** cards, for the company itself or for designated persons (e.g. employees, passing their identity documents). Issuance bills the `card_creation_virtual` / `card_creation_physical` fee when configured (0 = free); the charge is refunded automatically if issuance fails. Physical cards are born `pending_activation` until the holder confirms reception. # Get a card Source: https://docs.cbpayapp.com/api-reference/cards/get-a-card /openapi.yaml get /v1/cards/{cardID} # List card transactions Source: https://docs.cbpayapp.com/api-reference/cards/list-card-transactions /openapi.yaml get /v1/cards/{cardID}/transactions Purchases and their lifecycle: `authorized` (real-time hold), `settled` (confirmed at clearing), `reversed` (annulled, funds returned to the same balance) and `declined` (with the reason: insufficient_funds, card_limit_exceeded, card_frozen...). `spend_asset` / `spend_amount` show which balance the purchase debited and how much in that asset; `amount_usd` / `amount_usdt` remain the USD reference value. When the operator configures a per-purchase fee (`card_purchase_virtual` / `card_purchase_physical`), `fee_asset` / `fee_amount` expose the fee (estimated while `authorized`, definitive once `settled`) and `fee_refunded_amount` appears once reversals or downward adjustments prorate a refund; without fee configuration the `fee_*` fields are omitted. # List cards Source: https://docs.cbpayapp.com/api-reference/cards/list-cards /openapi.yaml get /v1/cards Account credentials list the account's cards; org admin credentials list every card in the organization (filter with `account_id`). # Occupation catalog Source: https://docs.cbpayapp.com/api-reference/cards/occupation-catalog /openapi.yaml get /v1/cards/catalog/occupations Official issuer occupation codes (for the `occupation` field when designating a person cardholder). Search with `q` (matches code or label). Use the `code` value; a free-text occupation is rejected with 400 invalid_occupation. # Reveal PAN and CVV Source: https://docs.cbpayapp.com/api-reference/cards/reveal-pan-and-cvv /openapi.yaml post /v1/cards/{cardID}/reveal Returns the card's sensitive data (PAN, CVV, expiration) as a one-time pass-through for display to the holder. Only the owning account can call it (never the org admin). **Never store or log this response** — the platform does not persist it either (PCI). # Update limits, spending asset or freeze/unfreeze Source: https://docs.cbpayapp.com/api-reference/cards/update-limits-spending-asset-or-freezeunfreeze /openapi.yaml patch /v1/cards/{cardID} Updates the card's spending limits (send "0" to remove a limit), changes the balance it spends from (`spending_asset`, future purchases only) and freezes or unfreezes it. Frozen cards decline every authorization instantly. # Decline the consent request (public, holder) Source: https://docs.cbpayapp.com/api-reference/consent/decline-the-consent-request-public-holder /openapi.yaml post /consent/{token}/decline The holder rejects the request: the consent becomes `revoked`, the legal evidence is sealed and the `risk_consent_revoked` webhook is emitted. An already decided consent returns `409 already_decided`. # Grant the consent after connecting the bank (public, holder) Source: https://docs.cbpayapp.com/api-reference/consent/grant-the-consent-after-connecting-the-bank-public-holder /openapi.yaml post /consent/{token}/complete Completes the flow after the holder connected their bank in the widget: the platform exchanges the `exchange_token`, requires the banking link to be `active` (`409 link_inactive`) and requires the holder document verified by the bank to match the subject document exactly (`409 holder_mismatch` — an account owned by a different document can never grant the consent). On success the consent becomes `granted`, the legal evidence (IP and user agent) is sealed, the positive-data derivation is triggered and the `risk_consent_granted` webhook is emitted. # Read a consent request (public, holder) Source: https://docs.cbpayapp.com/api-reference/consent/read-a-consent-request-public-holder /openapi.yaml get /consent/{token} Public view of the consent request behind a `consent_url`. The URL token IS the capability (128 random bits, unique per consent): no login is required, and an unknown token answers a generic 404 (anti-enumeration). The response is minimal on purpose: `doc_id` is masked to its last 4 characters, and the email, holder identity and internal ids are never exposed. Rate limited per IP. # Start the bank connection (public, holder) Source: https://docs.cbpayapp.com/api-reference/consent/start-the-bank-connection-public-holder /openapi.yaml post /consent/{token}/begin Opens the bank-connection session for the holder: returns the `widget_token` and `public_key` the open-finance widget needs to render the institution picker. Only a `pending` consent can begin; any other status returns `409 already_decided`. Rate limited per IP. # Begin passkey registration Source: https://docs.cbpayapp.com/api-reference/passkeys/begin-passkey-registration /openapi.yaml post /v1/me/passkeys/register/begin Starts a WebAuthn registration ceremony. Requires the current password (if the member has one) — a new sign-in factor is never added from a stolen session. Pass `options.publicKey` to `navigator.credentials.create()`. # Begin passwordless passkey login Source: https://docs.cbpayapp.com/api-reference/passkeys/begin-passwordless-passkey-login /openapi.yaml post /v1/auth/passkey/login/begin Starts a WebAuthn discoverable login ceremony. Pass the returned `options.publicKey` to `navigator.credentials.get()` and send the result to `finish`. The challenge is single-use and expires in 2 minutes. # Confirm and activate the authenticator app Source: https://docs.cbpayapp.com/api-reference/passkeys/confirm-and-activate-the-authenticator-app /openapi.yaml post /v1/me/totp/confirm Activates TOTP with the first valid code and returns 10 one-time backup codes (shown only once). # Finish passkey registration Source: https://docs.cbpayapp.com/api-reference/passkeys/finish-passkey-registration /openapi.yaml post /v1/me/passkeys/register/finish Validates the attestation and stores the passkey under a friendly name. # Finish passwordless passkey login Source: https://docs.cbpayapp.com/api-reference/passkeys/finish-passwordless-passkey-login /openapi.yaml post /v1/auth/passkey/login/finish Validates the authenticator assertion and issues a session. A passkey is already two factors (device + biometrics), so this login does not go through the login OTP. # List my passkeys Source: https://docs.cbpayapp.com/api-reference/passkeys/list-my-passkeys /openapi.yaml get /v1/me/passkeys # My authenticator app status Source: https://docs.cbpayapp.com/api-reference/passkeys/my-authenticator-app-status /openapi.yaml get /v1/me/totp Whether an authenticator app (TOTP) is active and how many backup codes remain. # Regenerate backup codes Source: https://docs.cbpayapp.com/api-reference/passkeys/regenerate-backup-codes /openapi.yaml post /v1/me/totp/recovery-codes Issues a fresh set of backup codes and invalidates the previous ones. Requires a valid authenticator or backup code. # Remove a passkey Source: https://docs.cbpayapp.com/api-reference/passkeys/remove-a-passkey /openapi.yaml delete /v1/me/passkeys/{passkeyID} Removes a passkey. Requires the current password (if the member has one) and is blocked if it is the only sign-in method left. # Remove my authenticator app Source: https://docs.cbpayapp.com/api-reference/passkeys/remove-my-authenticator-app /openapi.yaml delete /v1/me/totp Disables TOTP. Requires a valid authenticator (or backup) code and respects the org floor — if the organization mandates the authenticator app it cannot be removed. # Start authenticator app enrollment Source: https://docs.cbpayapp.com/api-reference/passkeys/start-authenticator-app-enrollment /openapi.yaml post /v1/me/totp/enroll Generates a TOTP secret and returns the `otpauth://` URI plus a QR PNG to scan in Google Authenticator / Authy. Requires the current password (if the member has one). Confirm with `POST /v1/me/totp/confirm`. # Payin credited Source: https://docs.cbpayapp.com/api-reference/payin-credited /openapi.yaml webhook payin_credited A fiat collection was received and credited to the account in USDT. For universal checkout links (`kind: checkout`) the payload also carries `settled_via` (e.g. `qr`, `crypto:tron:usdt`, `cbpay`), `settlement_asset` and `asset_amount`; crypto payments add `crypto_amount` and CBPay app payments add `transfer_id`, `asset` and `amount`. When the account configured a `default_payin_asset` other than USDT the payload also carries `settlement_asset` and `conversion_status` for the automatic post-credit conversion. Every delivery includes `X-Webhook-Event`, `X-Webhook-Event-ID`, `X-Webhook-Delivery-ID`, `X-Webhook-Timestamp` and `X-Webhook-Signature = hex(HMAC-SHA256(secret, timestamp + "." + body))`. Respond 2xx; up to 5 attempts with incremental backoff. # Payin expired Source: https://docs.cbpayapp.com/api-reference/payin-expired /openapi.yaml webhook payin_expired An active collection (QR or hosted checkout) reached a final state without receiving the payment: the payin moves from `pending` to `expired` (or `failed`). No funds move; create a new payin to retry the charge. Every delivery includes `X-Webhook-Event`, `X-Webhook-Event-ID`, `X-Webhook-Delivery-ID`, `X-Webhook-Timestamp` and `X-Webhook-Signature = hex(HMAC-SHA256(secret, timestamp + "." + body))`. Respond 2xx; up to 5 attempts with incremental backoff. # Payin refunded Source: https://docs.cbpayapp.com/api-reference/payin-refunded /openapi.yaml webhook payin_refunded A refund of a card payin reached a final state, or the issuer imposed a chargeback. `status: completed` means the money went back to the cardholder and the debit is settled on your balance; `status: failed` means the processor declined it and the debit was reversed in full. A `kind: chargeback` is booked automatically and can leave `balance_after` negative (a debt netted against future credits). Every delivery includes `X-Webhook-Event`, `X-Webhook-Event-ID`, `X-Webhook-Delivery-ID`, `X-Webhook-Timestamp` and `X-Webhook-Signature = hex(HMAC-SHA256(secret, timestamp + "." + body))`. Respond 2xx; up to 5 attempts with incremental backoff. # Payin settlement scheduled Source: https://docs.cbpayapp.com/api-reference/payin-settlement-scheduled /openapi.yaml webhook payin_settlement_scheduled A card payin was paid and its balance was scheduled for a future `settle_at` because your organization configured a settlement delay (`settlement_hours`) on the `payin_card` service. Emitted exactly once when the payment is confirmed (idempotent — a retry of the same confirmation does not re-emit it). `status` is `credited` from the start: the payin is confirmed and `payin_credited` fires right away — what waits until `settle_at` is only the BALANCE. `usdt_net` is the amount that lands when due (gross − fee); amount fields may come back empty (`""`) on historical rows without gross/fee. When `settle_at` is reached, the settlement worker releases the balance (the payin then carries `settled_at` instead of `settlement_pending`). Every delivery includes `X-Webhook-Event`, `X-Webhook-Event-ID`, `X-Webhook-Delivery-ID`, `X-Webhook-Timestamp` and `X-Webhook-Signature = hex(HMAC-SHA256(secret, timestamp + "." + body))`. Respond 2xx; up to 5 attempts with incremental backoff. # Create a POS charge (crypto QR with amount) Source: https://docs.cbpayapp.com/api-reference/qr-crypto-pos/create-a-pos-charge-crypto-qr-with-amount /openapi.yaml post /v1/pos/charges Creates an amount-bearing crypto QR charge for a merchant. Same contract as the universal checkout link: amount + settlement_asset (account default when omitted); the due the customer pays is quoted in the QR's crypto at creation (the payer covers any conversion — you receive your exact target). The response carries the exclusive deposit address, the raw-address QR (qr_payload + qr_png_base64) and the frozen due, ready for the POS. idempotency_key is required: a retry returns the SAME charge and address. # Get a POS charge (POS polling) Source: https://docs.cbpayapp.com/api-reference/qr-crypto-pos/get-a-pos-charge-pos-polling /openapi.yaml get /v1/pos/charges/{chargeID} Charge detail for the POS polling loop. While pending, if an on-chain deposit is already detected but not yet confirmed, the response adds confirming:true + detected_amount (UX signal only — credit happens on confirmation). Partial payments accumulate in received; late payments into an expired charge still credit and show here. # List the refunds of a POS charge Source: https://docs.cbpayapp.com/api-reference/qr-crypto-pos/list-the-refunds-of-a-pos-charge /openapi.yaml get /v1/pos/charges/{chargeID}/refunds Refunds of the charge; each status follows its withdrawal lifecycle (a failed withdrawal refunds the debit and releases the cap). # POS reconciliation summary per merchant Source: https://docs.cbpayapp.com/api-reference/qr-crypto-pos/pos-reconciliation-summary-per-merchant /openapi.yaml get /v1/pos/summary Per-merchant aggregate over a date range: charge counts, gross collected (target of paid charges), the processor's informative commission, refunded totals and the net to distribute to each merchant. # Refund a POS charge Source: https://docs.cbpayapp.com/api-reference/qr-crypto-pos/refund-a-pos-charge /openapi.yaml post /v1/pos/charges/{chargeID}/refund Returns (part of) what the charge received to the payer as a regular crypto withdrawal from the account balance (hold, withdrawal fee and every compliance control of the rail). to_address is always explicit — never auto-refunded to the deposit origin. Hard cap: the sum of refunds can never exceed what the charge received. idempotency_key required. # Event detail Source: https://docs.cbpayapp.com/api-reference/real-time-events/event-detail /openapi.yaml get /v1/events/{eventID} Detail of a single event by its public `event_id` (the same one that travels in the webhook). An event outside your scope returns `404`, never `403`. # Event history Source: https://docs.cbpayapp.com/api-reference/real-time-events/event-history /openapi.yaml get /v1/events/history The same log that feeds the stream, queryable. Requires `from`/`to` date filters. Retention is 90 days: the log is a notification buffer, the financial facts live in their own immutable resources (movements, payins, payouts). # Real-time event stream (SSE) Source: https://docs.cbpayapp.com/api-reference/real-time-events/real-time-event-stream-sse /openapi.yaml get /v1/events Opens a `text/event-stream` connection that delivers every event of the account in real time — the same events that travel by webhook, without exposing a public endpoint. Each event carries `id:` (monotonic cursor), `event:` (the event type) and `data:` (JSON with the same `event_id` as the webhook, so both channels cross-reference). Reconnect with the `Last-Event-ID` header (or `?last_event_id=`) and the server replays what you missed before resuming live delivery. Native `EventSource` cannot send an `Authorization` header: use `fetch` with `ReadableStream`. Scope follows the credential: an account credential receives its own events; an org-admin credential with `ops:read` receives the organization-wide channel and can narrow it with `?account_id=`. The connection sends `: ping` every 20s, revalidates the session every 60s (a revoked session closes the stream with the `unauthorized` control event) and closes after 30 minutes with a `reconnect` control event. # Get an OTP challenge Source: https://docs.cbpayapp.com/api-reference/security-otp/get-an-otp-challenge /openapi.yaml get /v1/otp/challenges/{challengeID} # List my OTP challenges Source: https://docs.cbpayapp.com/api-reference/security-otp/list-my-otp-challenges /openapi.yaml get /v1/otp/challenges # My 2FA preferences Source: https://docs.cbpayapp.com/api-reference/security-otp/my-2fa-preferences /openapi.yaml get /v1/otp/preferences Shows the effective 2FA policy for the account with, per action, whether it is locked by the organization (the org policy is the floor the account cannot go below). # My OTP policy Source: https://docs.cbpayapp.com/api-reference/security-otp/my-otp-policy /openapi.yaml get /v1/otp/settings Effective OTP policy for the calling account — whether the feature is enabled, which actions require a code and over which channel, plus the (masked) phone and its verification state. # Request an OTP code Source: https://docs.cbpayapp.com/api-reference/security-otp/request-an-otp-code /openapi.yaml post /v1/otp/challenges Sends a one-time code to the account's phone over the channel configured for the action. Requires a user session (JWT) — API keys receive `403 session_required`. Hourly send limits apply. # Update my 2FA preferences Source: https://docs.cbpayapp.com/api-reference/security-otp/update-my-2fa-preferences /openapi.yaml put /v1/otp/preferences Lets the account owner configure its own 2FA. You can always harden (enable actions, move to a stronger channel: totp > email > sms/whatsapp) but never go below the organization floor. Weakening (disabling an active action or lowering the channel) requires an `X-OTP-Token` for the `security_settings` action. Choosing `channel:totp` requires the authenticator app already enrolled. Enabling 2FA for the `login` action over a phone channel (`sms`/`whatsapp`) requires the account phone number to be verified first (complete any SMS/WhatsApp OTP challenge); otherwise the request is rejected with `409 phone_verification_required` — this prevents locking yourself out with a mistyped number. # Verify the code Source: https://docs.cbpayapp.com/api-reference/security-otp/verify-the-code /openapi.yaml post /v1/otp/challenges/{challengeID}/verify Validates the code received over SMS/WhatsApp and returns the single-use `otp_token` that authorizes the protected action (send it in the `X-OTP-Token` header). 5 attempts per challenge. # Configure auto-forward Source: https://docs.cbpayapp.com/api-reference/segregated-wallets/configure-auto-forward /openapi.yaml post /v1/segregated-wallets/{walletID}/auto-forward Forwards everything that arrives at the wallet to an address of yours. Redirects future funds, so it requires a verified account and OTP. # Create a segregated wallet Source: https://docs.cbpayapp.com/api-reference/segregated-wallets/create-a-segregated-wallet /openapi.yaml post /v1/segregated-wallets Creates a new on-chain wallet owned by the calling account. Its balance lives on-chain (never in the ledger) and it is exempt from the treasury sweep. Supported pairs: `tron`/`usdt`, `eth`/`usdt`, `eth`/`usdc`, `btc`/`btc`. Company accounts can create unlimited wallets; person accounts hold **1 per network+asset pair** (a second one responds `422 wallet_limit_reached`). An optional `Idempotency-Key` (or `idempotency_key` in the body) makes retries safe: a repeat returns the same wallet without creating a second one. May bill the `wallet_creation` fee (refunded if creation fails). # Export the private key Source: https://docs.cbpayapp.com/api-reference/segregated-wallets/export-the-private-key /openapi.yaml post /v1/segregated-wallets/{walletID}/export Returns the wallet's private key (shared custody: the wallet stays operational in the platform after export). The most sensitive operation of the product: requires a signed-in user session with 2FA (API keys rejected), a verified account and a `reason` of at least 20 characters kept in the audit trail. Each export fires the `wallet_key_exported` webhook. Bills the `wallet_export` fee. # Get a segregated wallet Source: https://docs.cbpayapp.com/api-reference/segregated-wallets/get-a-segregated-wallet /openapi.yaml get /v1/segregated-wallets/{walletID} # Get a send Source: https://docs.cbpayapp.com/api-reference/segregated-wallets/get-a-send /openapi.yaml get /v1/segregated-wallets/{walletID}/sends/{sendID} # Get the auto-forward rule Source: https://docs.cbpayapp.com/api-reference/segregated-wallets/get-the-auto-forward-rule /openapi.yaml get /v1/segregated-wallets/{walletID}/auto-forward # Get the live on-chain balance Source: https://docs.cbpayapp.com/api-reference/segregated-wallets/get-the-live-on-chain-balance /openapi.yaml get /v1/segregated-wallets/{walletID}/balance Returns the wallet's live on-chain balance, including native gas (TRX/ETH) so you can tell whether it can pay a network fee. # Import an external wallet Source: https://docs.cbpayapp.com/api-reference/segregated-wallets/import-an-external-wallet /openapi.yaml post /v1/segregated-wallets/import Imports a wallet you already control by providing its `private_key_hex`. The key travels only to the custodian and is **never stored or logged** in the platform. Requires a signed-in user session with 2FA (API keys are rejected with `human_session_required`), a verified account and OTP. Bills the `wallet_import` fee (refunded if the core rejects). # List my segregated wallets Source: https://docs.cbpayapp.com/api-reference/segregated-wallets/list-my-segregated-wallets /openapi.yaml get /v1/segregated-wallets Lists the calling account's segregated wallets, newest first. # List on-chain activity Source: https://docs.cbpayapp.com/api-reference/segregated-wallets/list-on-chain-activity /openapi.yaml get /v1/segregated-wallets/{walletID}/transactions Full on-chain activity (deposits and sends) of the wallet. # List on-chain deposits Source: https://docs.cbpayapp.com/api-reference/segregated-wallets/list-on-chain-deposits /openapi.yaml get /v1/segregated-wallets/{walletID}/deposits # List sends from the wallet Source: https://docs.cbpayapp.com/api-reference/segregated-wallets/list-sends-from-the-wallet /openapi.yaml get /v1/segregated-wallets/{walletID}/sends # Send crypto from the wallet Source: https://docs.cbpayapp.com/api-reference/segregated-wallets/send-crypto-from-the-wallet /openapi.yaml post /v1/segregated-wallets/{walletID}/sends Sends crypto **from the wallet itself** (real source address, signed by the custodian). `idempotency_key` is required. Gas is on the client: if the wallet lacks native gas the send is rejected with `422 insufficient_gas` before any charge. May bill the `wallet_send` fee (from the account ledger settlement balance, never the wallet's on-chain funds; refunded if the core rejects). Requires a verified account and OTP. On an ambiguous failure the send stays `pending` — retry with the same key, never re-sent. # Public status feed (JSON) Source: https://docs.cbpayapp.com/api-reference/status/public-status-feed-json /openapi.yaml get /v1/status/{orgToken} The same data behind the status page as JSON, for your own status tooling or monitoring: overall status, per-corridor state with 90-day uptime, and the recent incident history (state transitions). Public, rate limited per IP. # Public status page (HTML) Source: https://docs.cbpayapp.com/api-reference/status/public-status-page-html /openapi.yaml get /status/{orgToken} Hosted, branded status page of the organization: live state of every payment corridor, 90-day uptime and incident history. Public (no credentials), safe to share with your own customers or embed in your product. The `orgToken` is the opaque token shared by the operator (org admins read the full URL as `status_page_url` in `GET /v1/org/branding`). Invalid token answers 404. Rate limited per IP. # Service health Source: https://docs.cbpayapp.com/api-reference/status/service-health /openapi.yaml get /healthz Public liveness check of the API and its database. # Charge a stored card (merchant-initiated) Source: https://docs.cbpayapp.com/api-reference/stored-cards/charge-a-stored-card-merchant-initiated /openapi.yaml post /v1/stored-cards/{storedCardID}/charges Charges the saved card WITHOUT the payer present (MIT under the card-brand mandate, citing the original consented 3-D Secure seed). Use `recurring: true` for fixed-interval subscription charges; false (default) for unscheduled charges. Requires `idempotency_key` — a retry with the same key returns the original payin and never charges twice. The approved charge credits the balance automatically (`payin_credited` webhook); a declined charge responds 422 with the payin marked failed. # Get a stored card Source: https://docs.cbpayapp.com/api-reference/stored-cards/get-a-stored-card /openapi.yaml get /v1/stored-cards/{storedCardID} # List stored cards Source: https://docs.cbpayapp.com/api-reference/stored-cards/list-stored-cards /openapi.yaml get /v1/stored-cards Cards saved by your payers on the hosted card checkout (COF mandate): only display data (brand, last 4, expiry) — the card number never exists on this platform. Filter by `payer_reference` to show a returning customer their cards. Requires `from`/`to`. # Revoke a stored card Source: https://docs.cbpayapp.com/api-reference/stored-cards/revoke-a-stored-card /openapi.yaml delete /v1/stored-cards/{storedCardID} Revokes the stored credential upstream and marks the mirror `revoked` (history is never deleted). Merchant-initiated charges and saved-card payments stop working immediately. Emits the `stored_card_revoked` webhook. # Cancel a subscription Source: https://docs.cbpayapp.com/api-reference/subscriptions/cancel-a-subscription /openapi.yaml post /v1/subscriptions/{subscriptionID}/cancel Terminal. Revoking the stored card also cancels its subscriptions automatically. # Create a subscription Source: https://docs.cbpayapp.com/api-reference/subscriptions/create-a-subscription /openapi.yaml post /v1/subscriptions Recurring charges on a saved card (the platform runs the schedule for you). The first period is charged synchronously on creation unless start_at is in the future (trial). Each charge is a normal card payin (credited via payin_credited); a decline retries daily up to 3 times, then the subscription becomes past_due. idempotency_key is required (a retry returns the original subscription). Requires an active stored card of the payer. # Get a subscription Source: https://docs.cbpayapp.com/api-reference/subscriptions/get-a-subscription /openapi.yaml get /v1/subscriptions/{subscriptionID} # List subscriptions Source: https://docs.cbpayapp.com/api-reference/subscriptions/list-subscriptions /openapi.yaml get /v1/subscriptions Your subscriptions (org admins see the whole org with account_id). Filter by status, stored_card_id or payer_reference. Requires from/to. # Pause a subscription Source: https://docs.cbpayapp.com/api-reference/subscriptions/pause-a-subscription /openapi.yaml post /v1/subscriptions/{subscriptionID}/pause Stops charges (from active). Resuming does NOT catch up missed periods. # Resume a subscription Source: https://docs.cbpayapp.com/api-reference/subscriptions/resume-a-subscription /openapi.yaml post /v1/subscriptions/{subscriptionID}/resume Reactivates from paused or past_due. From past_due it retries the pending period once (a fresh decline returns to past_due). From paused with a future date it keeps the original schedule (no early charge). # Create a webhook subscription Source: https://docs.cbpayapp.com/api-reference/webhooks/create-a-webhook-subscription /openapi.yaml post /v1/webhooks/subscriptions Subscribes an HTTPS callback to the calling account's events. Deliveries are signed: `X-Webhook-Signature = hex(HMAC-SHA256(secret, timestamp + "." + body))` with the timestamp in `X-Webhook-Timestamp`. Up to 5 attempts with incremental backoff. # Disable or reactivate a webhook subscription Source: https://docs.cbpayapp.com/api-reference/webhooks/disable-or-reactivate-a-webhook-subscription /openapi.yaml patch /v1/webhooks/subscriptions/{subscriptionID} Toggles one of the account's subscriptions between `active` and `disabled`. A `disabled` subscription stops receiving NEW events; deliveries already queued are still sent. Idempotent: repeating the current status is a no-op `200`. Subscriptions are never deleted — disabling is how you unsubscribe. Ownership is enforced: a subscription of another account answers `404` (it is indistinguishable from an inexistent one). # List webhook subscriptions Source: https://docs.cbpayapp.com/api-reference/webhooks/list-webhook-subscriptions /openapi.yaml get /v1/webhooks/subscriptions Lists the account's webhook subscriptions (the secret is never returned). # Account security event Source: https://docs.cbpayapp.com/api-reference/account-security-event /openapi.yaml webhook member_security_event A security fact of the account (sign-in, password change, factor added or removed, session revoked). Same fields as `GET /v1/me/security/events`. # Account status changed Source: https://docs.cbpayapp.com/api-reference/account-status-changed /openapi.yaml webhook account_status_changed The administrative status of the account changed (`active`, `blocked`, `closed`). Resending the same status does not emit. # Aml screening updated Source: https://docs.cbpayapp.com/api-reference/aml-screening-updated /openapi.yaml webhook aml_screening_updated Fires when the AML screening finishes, a case changes, the risk changes or a monitored transaction is reviewed. # Applied balance adjustment Source: https://docs.cbpayapp.com/api-reference/applied-balance-adjustment /openapi.yaml webhook balance_adjusted An administrative adjustment credited or debited the balance. Emitted only when the entry was actually applied: an adjustment awaiting a second approver, or the replay of an already-used idempotency key, does not emit. # Bank payment status changed Source: https://docs.cbpayapp.com/api-reference/bank-payment-status-changed /openapi.yaml webhook banking_operation_status_changed Fires when a bank payment reaches a new state (processing, completed, failed). # Banking profile verification changed Source: https://docs.cbpayapp.com/api-reference/banking-profile-verification-changed /openapi.yaml webhook banking_customer_status_changed Fires when the banking profile verification state changes (submitted, under_review, approved, rejected) — for your own banking profile (`customer_kind: self`) and for the third parties you registered (`customer_kind: third_party`, with `third_party_id` matching GET /v1/banking/third-parties/{id}). # Card status changed Source: https://docs.cbpayapp.com/api-reference/card-status-changed /openapi.yaml webhook card_status_changed Fires when a card changes state (active, frozen, cancelled), including automatic freezes when the monthly fee cannot be charged. # Card transaction Source: https://docs.cbpayapp.com/api-reference/card-transaction /openapi.yaml webhook card_transaction Fires on every card transaction lifecycle change: authorized (real-time hold), reversed (funds returned) and settled adjustments. Same signature headers as every delivery. # Corridor availability changed Source: https://docs.cbpayapp.com/api-reference/corridor-availability-changed /openapi.yaml webhook corridor_status_changed A payment corridor changed availability (`operational` / `degraded` / `down`) — fired on outages AND recoveries. Broadcast event: it is not tied to one of your operations, so it carries no `account_id`. Consume it idempotently (dedupe by delivery id). See the service status guide. # Crypto deposit credited Source: https://docs.cbpayapp.com/api-reference/crypto-deposit-credited /openapi.yaml webhook crypto_deposit_credited An on-chain deposit was confirmed and credited (net of the funding fee) to the balance of the wallet's asset (`USDT` or `USDC`). Every delivery includes `X-Webhook-Event`, `X-Webhook-Event-ID`, `X-Webhook-Delivery-ID`, `X-Webhook-Timestamp` and `X-Webhook-Signature = hex(HMAC-SHA256(secret, timestamp + "." + body))`. Respond 2xx; up to 5 attempts with incremental backoff. # Crypto deposit credited with a risk alert Source: https://docs.cbpayapp.com/api-reference/crypto-deposit-credited-with-a-risk-alert /openapi.yaml webhook crypto_deposit_alert An incoming deposit was credited normally, but the sender address shows high risk. Informational — no action is required on the funds. # Crypto deposit held for compliance review Source: https://docs.cbpayapp.com/api-reference/crypto-deposit-held-for-compliance-review /openapi.yaml webhook crypto_deposit_held An incoming on-chain deposit was NOT credited because the sender address shows severe risk (sanctions / direct illicit funds). The operator's compliance team reviews the hold and releases (credits) or rejects it. # Crypto withdrawal status changed Source: https://docs.cbpayapp.com/api-reference/crypto-withdrawal-status-changed /openapi.yaml webhook crypto_withdrawal_status_changed An on-chain withdrawal reached a new state. Every delivery includes `X-Webhook-Event`, `X-Webhook-Event-ID`, `X-Webhook-Delivery-ID`, `X-Webhook-Timestamp` and `X-Webhook-Signature = hex(HMAC-SHA256(secret, timestamp + "." + body))`. Respond 2xx; up to 5 attempts with incremental backoff. # External movement on a segregated wallet Source: https://docs.cbpayapp.com/api-reference/external-movement-on-a-segregated-wallet /openapi.yaml webhook wallet_external_movement The activity sync detected an on-chain movement of a segregated wallet that did not go through the platform (expected under client custody, e.g. after importing or exporting the key). The wallet's activity record stays complete either way. # Kyb document ocr finished Source: https://docs.cbpayapp.com/api-reference/kyb-document-ocr-finished /openapi.yaml webhook kyb_document_validated Fires when the OCR validation of a KYB document finishes. # Kyb link completed Source: https://docs.cbpayapp.com/api-reference/kyb-link-completed /openapi.yaml webhook kyb_link_completed Fires when your customer completes a hosted KYB link; the submission is created. # Kyb verification status changed Source: https://docs.cbpayapp.com/api-reference/kyb-verification-status-changed /openapi.yaml webhook kyb_verification_status_changed Fires on every KYB submission lifecycle change, including AML/risk/report signals from the review. decision_source tells whether the decision was made by the automatic engine (auto) or by a human reviewer (admin); it is present on final decisions. # Kyc document ocr finished Source: https://docs.cbpayapp.com/api-reference/kyc-document-ocr-finished /openapi.yaml webhook kyc_document_validated Fires when the OCR validation of a document uploaded through the API finishes. # Kyc link completed Source: https://docs.cbpayapp.com/api-reference/kyc-link-completed /openapi.yaml webhook kyc_link_completed Fires when your customer completes a hosted KYC link; the submission is created. # Kyc verification status changed Source: https://docs.cbpayapp.com/api-reference/kyc-verification-status-changed /openapi.yaml webhook kyc_verification_status_changed Fires on every KYC submission lifecycle change (submitted, in_review, changes_requested, more_info_required, escalated, approved, approved_partial, rejected). Your own onboarding arrives with self_onboarding: true. decision_source tells whether the decision was made by the automatic engine (auto) or by a human reviewer (admin); it is present on final decisions. # Liveness check completed Source: https://docs.cbpayapp.com/api-reference/liveness-check-completed /openapi.yaml webhook kyc_liveness_completed Fires when the video liveness check is completed from a liveness link. # Payout status changed Source: https://docs.cbpayapp.com/api-reference/payout-status-changed /openapi.yaml webhook payout_status_changed A payout reached a new state (processing, completed or failed). Every delivery includes `X-Webhook-Event`, `X-Webhook-Event-ID`, `X-Webhook-Delivery-ID`, `X-Webhook-Timestamp` and `X-Webhook-Signature = hex(HMAC-SHA256(secret, timestamp + "." + body))`. Respond 2xx; up to 5 attempts with incremental backoff. # Qscore batch scoring completed Source: https://docs.cbpayapp.com/api-reference/qscore-batch-scoring-completed /openapi.yaml webhook risk_batch_completed A batch scoring job created with `POST /v1/qscore/batches` finished processing all of its items. This is the single completion signal of the batch: the individual reports do **not** emit their own `risk_report_ready` webhook (and no per-item email is sent). `status` is `completed` when every item succeeded, `completed_with_errors` when at least one item failed terminally (its fee was refunded) and `failed` when the batch itself could not be processed. The counters always add up: `succeeded_items + failed_items = total_items`. Read the per-item results with `GET /v1/qscore/batches/{id}/items` or download them with `GET /v1/qscore/batches/{id}/results.csv`. Every delivery includes `X-Webhook-Event`, `X-Webhook-Event-ID`, `X-Webhook-Delivery-ID`, `X-Webhook-Timestamp` and `X-Webhook-Signature = hex(HMAC-SHA256(secret, timestamp + "." + body))`. Respond 2xx; up to 5 attempts with incremental backoff. # Qscore consent granted by the holder Source: https://docs.cbpayapp.com/api-reference/qscore-consent-granted-by-the-holder /openapi.yaml webhook risk_consent_granted A consent link created with `POST /v1/qscore/consents` was granted: the holder opened the public page, connected their bank through open finance and authorized the request (their verified document matched the subject document). The consent now unlocks the positive banking data of the subject in new Qscore reports. `previous_status` is `pending` in practice; it is carried so a replay is easy to reconcile. Every delivery includes `X-Webhook-Event`, `X-Webhook-Event-ID`, `X-Webhook-Delivery-ID`, `X-Webhook-Timestamp` and `X-Webhook-Signature = hex(HMAC-SHA256(secret, timestamp + "." + body))`. Respond 2xx; up to 5 attempts with incremental backoff. # Qscore consent revoked Source: https://docs.cbpayapp.com/api-reference/qscore-consent-revoked /openapi.yaml webhook risk_consent_revoked A consent link was revoked, either by the holder declining the request on the public page or by your account calling `POST /v1/qscore/consents/{id}/revoke`. A revoked consent no longer feeds positive banking data into new Qscore reports. Every delivery includes `X-Webhook-Event`, `X-Webhook-Event-ID`, `X-Webhook-Delivery-ID`, `X-Webhook-Timestamp` and `X-Webhook-Signature = hex(HMAC-SHA256(secret, timestamp + "." + body))`. Respond 2xx; up to 5 attempts with incremental backoff. # Qscore credit report ready Source: https://docs.cbpayapp.com/api-reference/qscore-credit-report-ready /openapi.yaml webhook risk_report_ready A Qscore credit report purchased with `POST /v1/qscore/reports` finished generating successfully. The payload carries the report identity, the resulting score/band and the public `verify_code` (shareable with the subject as proof of authenticity at `GET /verify/qscore/{code}`). If the generation fails the report shows `status: failed` and no event is emitted. The event is also emitted when the purchased report was already available for the subject (idempotent reuse). The same event fires for a SELF report generated by the account holder with `POST /v1/qscore/my-report`; in that case the payload adds `purpose: "self_access"` (absent for purchased reports). Every delivery includes `X-Webhook-Event`, `X-Webhook-Event-ID`, `X-Webhook-Delivery-ID`, `X-Webhook-Timestamp` and `X-Webhook-Signature = hex(HMAC-SHA256(secret, timestamp + "." + body))`. Respond 2xx; up to 5 attempts with incremental backoff. # Qscore monitoring alert Source: https://docs.cbpayapp.com/api-reference/qscore-monitoring-alert /openapi.yaml webhook risk_monitoring_alert A subject the account monitors via `PUT /v1/qscore/subjects/{docID}/monitoring` changed since the last check. `triggers` lists what fired the alert: `score_drop_below` (the score crossed the subscription's `monitor_since_score` threshold downward: previous >= threshold, new < threshold), `new_records` (the bureau registered new events; with `only_material: true` only materially negative events — open delinquency, protest, bankruptcy, lawsuit — count) or `records_removed` (the bureau now shows fewer events than the baseline, i.e. a record was closed or deleted). Active subscriptions are re-checked every ~5 minutes; the first pass after subscribing only seeds the baseline and never alerts on the history the purchased report already showed. `score` and `previous_score` are omitted while the subject has no classifiable score, and `new_records` is omitted when no new event fired. Every delivery includes `X-Webhook-Event`, `X-Webhook-Event-ID`, `X-Webhook-Delivery-ID`, `X-Webhook-Timestamp` and `X-Webhook-Signature = hex(HMAC-SHA256(secret, timestamp + "." + body))`. Respond 2xx; up to 5 attempts with incremental backoff. # Qscore score changed Source: https://docs.cbpayapp.com/api-reference/qscore-score-changed /openapi.yaml webhook risk_score_changed A newly generated Qscore report produced a score different from the subject's previous known score (e.g. on a monitoring re-pull or a new purchase). Emitted only when the score actually changes; the payload carries the old and new score/band pair so you can decide whether to act. Subscribe to this event to monitor a subject over time without polling. Every delivery includes `X-Webhook-Event`, `X-Webhook-Event-ID`, `X-Webhook-Delivery-ID`, `X-Webhook-Timestamp` and `X-Webhook-Signature = hex(HMAC-SHA256(secret, timestamp + "." + body))`. Respond 2xx; up to 5 attempts with incremental backoff. # Segregated wallet deposit received Source: https://docs.cbpayapp.com/api-reference/segregated-wallet-deposit-received /openapi.yaml webhook wallet_deposit_received An on-chain deposit was confirmed at a segregated wallet. It does **not** touch the ledger (the balance lives on-chain in the wallet). Every delivery includes `X-Webhook-Event`, `X-Webhook-Event-ID`, `X-Webhook-Delivery-ID`, `X-Webhook-Timestamp` and `X-Webhook-Signature = hex(HMAC-SHA256(secret, timestamp + "." + body))`. Respond 2xx. # Segregated wallet key exported Source: https://docs.cbpayapp.com/api-reference/segregated-wallet-key-exported /openapi.yaml webhook wallet_key_exported Security alert: the private key of a segregated wallet was exported (shared custody). The payload never includes the key. # Segregated wallet send status changed Source: https://docs.cbpayapp.com/api-reference/segregated-wallet-send-status-changed /openapi.yaml webhook wallet_send_status_changed A send from a segregated wallet reached a new state (processing, completed or failed). # Suspected key compromise critical alarm Source: https://docs.cbpayapp.com/api-reference/suspected-key-compromise-critical-alarm /openapi.yaml webhook wallet_key_compromise_suspected CRITICAL: funds left a platform-custody segregated wallet without going through the platform. By design this is impossible unless the private key leaked. Treat the key as compromised, move remaining funds to a new wallet and contact support immediately. # Transaction review status changed Source: https://docs.cbpayapp.com/api-reference/transaction-review-status-changed /openapi.yaml webhook txn_review_status_changed A transaction of the account changed its review status (`in_review` when the transactional firewall holds it, `info_requested`, `released`, `rejected`). The payload is deliberately neutral: status and operation summary only — internal review reasons or compliance notes are never included. A `rejected` status can also come from the organization's review deadline sweep (automatic rejection); the email and the review detail carry the standard deadline notice. Every delivery includes `X-Webhook-Event`, `X-Webhook-Event-ID`, `X-Webhook-Delivery-ID`, `X-Webhook-Timestamp` and `X-Webhook-Signature = hex(HMAC-SHA256(secret, timestamp + "." + body))`. Respond 2xx; up to 5 attempts with incremental backoff. # Transfer received Source: https://docs.cbpayapp.com/api-reference/transfer-received /openapi.yaml webhook transfer_received The account received an internal transfer. Every delivery includes `X-Webhook-Event`, `X-Webhook-Event-ID`, `X-Webhook-Delivery-ID`, `X-Webhook-Timestamp` and `X-Webhook-Signature = hex(HMAC-SHA256(secret, timestamp + "." + body))`. Respond 2xx; up to 5 attempts with incremental backoff.