# Caibo Global API — Full documentation > Every Caibo Global API documentation page, concatenated as Markdown for LLM consumption. Source: https://docs.caiboglobal.com/ · OpenAPI spec: https://docs.caiboglobal.com/v1.yaml --- # Introduction The Caibo Global API lets your platform send stablecoin payouts programmatically — screened, verified, and settled on-chain. The Caibo Global API is a REST API for **outbound payouts**. You create a payout, we screen the destination, move the funds on-chain, and notify you when it settles. It is built for platforms that need to pay out to many recipients reliably — marketplaces, payroll and contractor platforms, remittance apps, and treasury operations. Every request is authenticated with an API key, scoped to your organization. You integrate once against a stable, versioned contract and get idempotent writes, cursor pagination, consistent error objects, and signed webhooks. ## What you can do today The current API surface (`v1`) covers the full payout lifecycle: - **Payouts** — create, retrieve, list, and cancel stablecoin payouts. - **Balances** — read your organization's balance per asset. - **Rates** — read conversion rates for a currency pair. - **Catalog** — read supported countries and payment methods. - **Webhooks** — register signed endpoints and subscribe to payout events. - **API keys** — create, list, and revoke keys for your organization. - **Organization & KYB** — onboard your business and submit verification. ## The payout rail Payouts settle over the **stablecoin rail**: USDC and USDT on a set of supported networks. When you create a payout, Caibo signs and broadcasts the transfer from treasury to your recipient's address. The supported networks are: - **EVM**: Polygon, Arbitrum, Optimism, Base, BSC, Ethereum (USDC, USDT) - **Tron** (USDT) - **Solana** (USDC, USDT) > There is no fiat payout rail yet. The API only sends stablecoin payouts. Anything not listed in this documentation does not exist — these guides describe only what the API actually does today. ## Base URL & conventions All requests go to the versioned base URL over HTTPS: ```bash https://api.caiboglobal.com/v1 ``` A few conventions hold across every endpoint: - **Money is strings.** Amounts are decimal strings like `"100.50"`, never floats — this avoids binary rounding. - **Timestamps are ISO 8601 UTC**, e.g. `2026-07-23T14:03:00Z`. - **IDs are prefixed** so they are self-describing: `org_`, `po_` (payout), `whe_` (webhook endpoint), `kyb_`. - **Writes are idempotent** via the `Idempotency-Key` header. - **Lists are cursor-paginated** and return `{ data, has_more, next_cursor }`. ## Test and live Every organization has two modes, distinguished by the API key prefix. `ck_test_` keys run in a full sandbox that never touches the blockchain but returns realistic states and webhooks. `ck_live_` keys move real funds and require a verified business (KYB). You can build and test your entire integration in sandbox before requesting live access. ## How this fits together A typical integration looks like this: 1. Create your organization and get a `ck_test_` key. 1. Build against the sandbox: create payouts, handle webhooks, verify signatures. 1. Submit KYB to unlock live mode. 1. Create a `ck_live_` key, register a production webhook endpoint, and start sending real payouts. ## Next steps - [Quickstart](/quickstart.md) — send your first sandbox payout in a few minutes. - [Authentication](/authentication.md) — API keys, modes, and scopes. - [Payout lifecycle](/payout-lifecycle.md) — the full state machine. - [API reference](/api-reference.md) — every endpoint, in detail. --- # Quickstart Send your first payout in the sandbox. Everything here runs against a ck_test_ key and never moves real money. This guide takes you from zero to a completed payout in the sandbox. You'll create a payout, watch it settle, and see the webhook that confirms it. No blockchain funds are moved and no KYB is required — `ck_test_` keys are a full simulation. ## 1. Get a test API key Sign in to the [Business dashboard](https://app.caiboglobal.com/dashboard/business), create your organization, and open **API keys → Create key** in **Test** mode. The full key is shown once — copy it now. It looks like this: ```bash ck_test_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6 ``` > Treat the key like a password. Use it only from your backend — never ship it to a browser or commit it to source control. See [Authentication](/authentication.md). ## 2. Set your key as an environment variable ```bash export CAIBO_API_KEY="ck_test_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" ``` ## 3. Create a payout Send a `USDC` payout on Polygon. The `Idempotency-Key` header is required on payout creation — pick a unique value per logical payout (a UUID is ideal). ```bash curl https://api.caiboglobal.com/v1/payouts \ -H "X-API-Key: $CAIBO_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 6b2f1e0a-7c3d-4a91-9f2e-1a2b3c4d5e6f" \ -d '{ "amount": "25.00", "currency": "USDC", "rail": "stablecoin", "destination": { "network": "polygon", "address": "0x1234abcd5678ef901234abcd5678ef9012345678" }, "metadata": { "invoice_id": "INV-1001" } }' ``` You get back the payout, already moving through its lifecycle: ```json { "id": "po_9f8e7d6c5b4a", "object": "payout", "status": "processing", "mode": "test", "amount": "25.00", "currency": "USDC", "rail": "stablecoin", "destination": { "network": "polygon", "address": "0x1234abcd5678ef901234abcd5678ef9012345678" }, "screening_status": "clear", "tx_hash": null, "metadata": { "invoice_id": "INV-1001" }, "created_at": "2026-07-23T14:03:00Z" } ``` ## 4. Retrieve it Poll the payout by id until it reaches a terminal state (in the sandbox this is near-instant): ```bash curl https://api.caiboglobal.com/v1/payouts/po_9f8e7d6c5b4a \ -H "X-API-Key: $CAIBO_API_KEY" ``` ```json { "id": "po_9f8e7d6c5b4a", "object": "payout", "status": "completed", "tx_hash": "0xSIMULATED8f3a9c2b1d4e5f60", "amount": "25.00", "currency": "USDC", "created_at": "2026-07-23T14:03:00Z" } ``` > Polling works, but webhooks are better. Instead of asking "is it done yet?", register an endpoint and let Caibo tell you. See step 6. ## 5. Check your balance ```bash curl https://api.caiboglobal.com/v1/balances \ -H "X-API-Key: $CAIBO_API_KEY" ``` ## 6. Receive a webhook Register an HTTPS endpoint and subscribe to payout events. Every delivery is signed — you must verify the signature. The full flow, with verification snippets in Node, Python, and Go, is in [Webhooks](/webhooks.md). ```bash curl https://api.caiboglobal.com/v1/webhooks \ -H "X-API-Key: $CAIBO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.example.com/caibo/webhook", "events": ["payout.completed", "payout.failed", "payout.blocked"] }' ``` ## 7. Exercise failure paths The sandbox has special destination addresses so you can test every branch deterministically. An address ending in `0blocked` is blocked by screening; one ending in `0fail` fails to broadcast and is refunded: ```bash # This payout will end up "blocked" (screening) curl https://api.caiboglobal.com/v1/payouts \ -H "X-API-Key: $CAIBO_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "amount": "10.00", "currency": "USDC", "rail": "stablecoin", "destination": { "network": "polygon", "address": "0x00000000000000000000000000000000000blocked" } }' ``` See [Environments](/environments.md) for the full list of sandbox test addresses. ## You're integrated From here: - [Payouts API reference](/payouts-api.md) — every parameter, response field, and error for `POST /payouts`. - [Payout lifecycle](/payout-lifecycle.md) — understand every state and how to react. - [Errors](/errors.md) — handle failures cleanly. - [Going live](/going-live.md) — the checklist to move from test to live. --- # Authentication Every request is authenticated with an API key that identifies your organization, its mode, and its scopes. The API authenticates with **API keys**. A key belongs to exactly one organization; every request you make with it is automatically scoped to that organization's data. There are no separate account IDs to pass — the key is the identity. ## Passing the key Send the key in the `X-API-Key` header: ```bash curl https://api.caiboglobal.com/v1/organization \ -H "X-API-Key: ck_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" ``` A `Bearer` token also works, if that fits your HTTP client better: ```bash curl https://api.caiboglobal.com/v1/organization \ -H "Authorization: Bearer ck_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" ``` A missing key returns `401 authentication_error` with code `api_key_missing`; an unknown or revoked key returns code `api_key_invalid`. See [Errors](/errors.md). ## Key format & modes A key encodes its mode in the prefix. The rest of the key is a random secret; only a short prefix is ever shown again after creation. | Prefix | Mode | What it does | | --- | --- | --- | | `ck_test_` | Test (sandbox) | Full simulation. Never touches the blockchain. Available immediately. | | `ck_live_` | Live | Moves real funds. Requires a verified business (KYB). | Test and live are fully separate: a payout created with a test key never appears under a live key, and vice-versa. Build against test, then switch to a live key when you go to production. See [Environments](/environments.md). ## Scopes Each key carries a set of scopes. An endpoint checks for the scope it needs and returns `403 permission_error` (code `scope_insufficient`) if the key lacks it. Grant a key only the scopes it needs. | Scope | Grants | | --- | --- | | `payouts:write` | Create and cancel payouts. | | `payouts:read` | Retrieve and list payouts. | | `balances:read` | Read balances and rates. | | `webhooks:write` | Create and delete webhook endpoints. | | `webhooks:read` | List webhook endpoints. | | `organization:read` | Read organization and KYB status. | | `organization:write` | Submit KYB and manage API keys. | ## Creating and rotating keys Create keys from the Business dashboard or via `POST /v1/api-keys`. The full secret is returned **once** at creation — store it in your secret manager immediately. Listing keys only ever returns the prefix, never the secret. Rotate keys periodically and whenever a team member with access leaves. To rotate with zero downtime: 1. Create a new key with the same scopes. 1. Deploy it to your backend. 1. Confirm traffic is using the new key (check `last_used_at`). 1. Revoke the old key. Revoking is immediate — a revoked key is rejected on the very next request. ## Keeping keys safe - **Backend only.** API keys must never reach a browser, mobile app, or any client the user controls. A leaked live key can move money. - **Never commit keys.** Keep them out of source control; load them from environment variables or a secret manager. - **Least privilege.** Give each key the minimum scopes it needs. - **Separate keys per service.** So you can rotate or revoke one without disrupting others. ## If a key is leaked Act immediately: 1. **Revoke** the key (dashboard or `DELETE /v1/api-keys/{id}`). This stops it working instantly. 1. **Create a replacement** and deploy it. 1. **Review recent activity** — list recent payouts to confirm nothing unexpected was created. > The panel that manages keys, KYB, payouts, and webhooks runs on Firebase-authenticated sessions — it does not use API keys in the browser. API keys are exclusively for your server-to-server integration. --- # Idempotency Safely retry payout creation without ever sending the same money twice. Networks fail. A request times out, your process restarts mid-call, a load balancer retries — and you can't tell whether the payout was created. Idempotency makes retrying safe: the same request sent twice creates **one** payout, not two. ## How it works Send an `Idempotency-Key` header on `POST /v1/payouts`. It is **required** on payout creation. The key is a unique value *you* generate per logical payout — a UUID is ideal. ```bash curl https://api.caiboglobal.com/v1/payouts \ -H "X-API-Key: $CAIBO_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 6b2f1e0a-7c3d-4a91-9f2e-1a2b3c4d5e6f" \ -d '{ "amount": "25.00", "currency": "USDC", "rail": "stablecoin", "destination": { "network": "polygon", "address": "0x1234...5678" } }' ``` The server keys the payout on `(organization, idempotency_key)` and behaves like this: - **First request** with a given key → the payout is created and returned. - **Retry with the same key and the same request body** → the *original* payout is returned. No second payout is created. - **Reuse of the key with a different body** → `409 Conflict`, code `idempotency_conflict`. No payout is created. ## What "same request" means A retry is considered the same when the amount, currency, and destination match the original. Reusing a key for a genuinely different payout is treated as a mistake and rejected with `idempotency_conflict` — this protects you from accidentally overwriting one payout's intent with another's. ```json { "error": { "type": "invalid_request_error", "code": "idempotency_conflict", "message": "Idempotency key was reused with a different request", "request_id": "req_2b8f1c9d" } } ``` ## Generating keys Pick a key that is unique to the payout's *intent*. Two good strategies: - **A fresh UUID per attempt-set.** Generate it once, before the first send, and reuse that same value for every retry of that one payout. - **A deterministic key from your own data**, e.g. `payout:invoice_1001`. This guarantees that even a completely fresh process retrying the same business action produces the same key. > Generate the key **before** the first request and persist it with your record of the payout. If you generate a new key on each retry, retries are no longer idempotent. ## A safe create-and-retry pattern ```javascript async function createPayout(payout, idempotencyKey) { const attempt = () => fetch("https://api.caiboglobal.com/v1/payouts", { method: "POST", headers: { "X-API-Key": process.env.CAIBO_API_KEY, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, // SAME value on every retry }, body: JSON.stringify(payout), }); for (let i = 0; i < 3; i++) { const res = await attempt(); if (res.ok) return res.json(); // Retry only on transient errors; a 4xx (e.g. 409) is terminal. if (res.status < 500) throw new Error(await res.text()); await new Promise((r) => setTimeout(r, 500 * 2 ** i)); } throw new Error("payout creation failed after retries"); } ``` ## Scope of idempotency - Idempotency applies to **payout creation**, the one endpoint where a duplicate would move money twice. - Read endpoints (`GET`) are naturally idempotent and need no key. - Cancel is safe to call more than once: cancelling an already-cancelled payout is a no-op error, never a double effect. --- # Pagination List endpoints are cursor-paginated. Walk the cursor to read an entire collection reliably, even while it changes. Endpoints that return a collection — such as `GET /v1/payouts` — are paginated with a **cursor**. Cursor pagination is stable under inserts: new items appearing at the top won't cause you to skip or double-read rows, which offset pagination can. ## Request parameters | Parameter | Description | | --- | --- | | `limit` | Page size, 1–100. Defaults to 25. | | `starting_after` | A resource id. Returns the page of results immediately after that id. | List endpoints may also accept resource-specific filters (for example, payouts accept a `status` and a `created_gte` / `created_lte` range). Filters combine with pagination. ## Response shape Every list response has the same envelope: ```json { "object": "list", "data": [ { "id": "po_9f8e7d6c5b4a", "object": "payout", "status": "completed" } ], "has_more": true, "next_cursor": "po_9f8e7d6c5b4a" } ``` | Field | Meaning | | --- | --- | | `data` | The array of resources for this page, newest first. | | `has_more` | `true` if there are more results after this page. | | `next_cursor` | The id to pass as `starting_after` for the next page. `null` when `has_more` is `false`. | ## Reading the first page ```bash curl "https://api.caiboglobal.com/v1/payouts?limit=25" \ -H "X-API-Key: $CAIBO_API_KEY" ``` ## Reading the next page Pass the previous response's `next_cursor` as `starting_after`: ```bash curl "https://api.caiboglobal.com/v1/payouts?limit=25&starting_after=po_9f8e7d6c5b4a" \ -H "X-API-Key: $CAIBO_API_KEY" ``` ## Walking the whole collection Loop until `has_more` is `false`: ```javascript async function* listAllPayouts(params = {}) { let cursor = null; do { const qs = new URLSearchParams({ limit: "100", ...params }); if (cursor) qs.set("starting_after", cursor); const res = await fetch( `https://api.caiboglobal.com/v1/payouts?${qs}`, { headers: { "X-API-Key": process.env.CAIBO_API_KEY } }, ); if (!res.ok) throw new Error(await res.text()); const page = await res.json(); for (const payout of page.data) yield payout; cursor = page.has_more ? page.next_cursor : null; } while (cursor); } // Usage for await (const payout of listAllPayouts({ status: "completed" })) { console.log(payout.id, payout.amount, payout.currency); } ``` ## Tips - Use the largest reasonable `limit` (up to 100) to reduce round-trips. - Persist `next_cursor` if you page across requests or jobs — you can resume exactly where you left off. - Filter server-side (e.g. by `status` or date range) rather than fetching everything and filtering locally. --- # Errors Every error is a structured JSON object with a stable type and code. Handle them by code, show the message, and log the request_id. The API uses conventional HTTP status codes and returns a consistent error object on every failure. Branch your logic on `type` and `code` (stable, machine-readable), surface `message` to your logs, and record `request_id` so support can trace the exact request. ## Error object ```json { "error": { "type": "compliance_error", "code": "screening_blocked", "message": "Payout blocked by sanctions/blacklist screening", "param": "destination.address", "request_id": "req_2b8f1c9d3e4a" } } ``` | Field | Description | | --- | --- | | `type` | High-level category (see below). Use it for coarse handling. | | `code` | Specific, stable machine-readable code. Use it for precise handling. | | `message` | Human-readable description. For logs and dashboards — do not parse it. | | `param` | Present when the error is tied to a specific request field. | | `request_id` | Identifier for this exact request. Include it in support requests. | ## Error types | Type | Typical status | Meaning | | --- | --- | --- | | `invalid_request_error` | 400 / 404 / 409 / 422 | The request was malformed, missing a field, or referenced something that doesn't exist. | | `authentication_error` | 401 | The API key is missing or invalid. | | `permission_error` | 403 | The key is valid but lacks the required scope, or the org is suspended. | | `rate_limit_error` | 429 | Too many requests. Back off and retry. | | `compliance_error` | 403 / 503 | Blocked by screening, limits, or a KYB requirement. | | `api_error` | 500 / 503 | Something went wrong on our side. Safe to retry with backoff. | ## Common codes | Code | Status | What it means / what to do | | --- | --- | --- | | `api_key_missing` | 401 | No key was sent. Add the `X-API-Key` header. | | `api_key_invalid` | 401 | Unknown or revoked key. Check the key and its mode. | | `scope_insufficient` | 403 | The key lacks the scope this endpoint needs. | | `org_suspended` | 403 | The organization is suspended. Contact support. | | `kyb_required` | 403 | Live mode needs a verified business. Submit KYB. | | `screening_blocked` | 403 | The destination was blocked by screening. See [Compliance & screening](/compliance.md). | | `limit_exceeded` | 403 | The payout would breach a per-payout, daily, or monthly limit. | | `insufficient_balance` | 402 | Your organization's balance is below the payout amount. | | `unsupported_destination` | 422 | The network/asset combination isn't supported. | | `idempotency_conflict` | 409 | The idempotency key was reused with a different request. | | `payout_not_cancelable` | 409 | The payout has advanced past the point where it can be cancelled. | | `resource_missing` | 404 | The resource doesn't exist (or isn't yours). | | `sanctions_unavailable` | 503 | Screening is temporarily unavailable. In live mode the payout is not sent; retry later. | ## Handling errors ```javascript const res = await fetch(url, options); if (!res.ok) { const { error } = await res.json(); switch (error.code) { case "idempotency_conflict": // You reused a key for a different payout — this is a bug in your code. throw new Error("idempotency key reused: " + error.message); case "insufficient_balance": return notifyOps("Top up the org balance"); case "screening_blocked": case "limit_exceeded": case "kyb_required": return handleCompliance(error); // don't retry — a human must act case "sanctions_unavailable": return retryLater(); // transient default: if (res.status >= 500) return retryWithBackoff(); // our side, safe to retry throw new Error(`${error.code}: ${error.message} (${error.request_id})`); } } ``` ## Which errors to retry - **Retry with backoff:** `429` (rate limit) and `5xx` (`api_error`, `sanctions_unavailable`). Always reuse the same `Idempotency-Key` when retrying a create. - **Do not retry:** `4xx` other than 429 — the request itself needs to change. A blind retry will fail identically. - **Never retry** `idempotency_conflict` — it means your key generation is wrong. > Log `request_id` on every failure. It is the fastest way for support to find exactly what happened to a given request. --- # Rate limits Requests are rate-limited per organization. Handle 429s by backing off and retrying. To keep the platform responsive for everyone, requests are rate-limited **per organization**. Limits are generous for normal integration traffic; you'll typically only encounter them during a burst — a backfill, a batch of payouts, or a retry storm. ## What counts Rate limits are applied against your organization (identified by your API key), not against a single key or IP — so spreading calls across multiple keys does not raise your ceiling. If you have a genuine need for higher sustained throughput (for example, large scheduled payout batches), contact us and we can raise your organization's limit. ## When you hit a limit Over-limit requests are rejected with `429 Too Many Requests` and a `rate_limit_error`: ```json { "error": { "type": "rate_limit_error", "code": "rate_limited", "message": "Rate limit exceeded", "request_id": "req_5c1a9f2b" } } ``` A `429` is safe to retry — no payout was created. When retrying a payout creation, reuse the same [`Idempotency-Key`](/idempotency.md) so the retry can never duplicate the payout. ## Backing off Retry with **exponential backoff and jitter**. If the response includes a `Retry-After` header, wait at least that long before retrying; otherwise start around one second and double each attempt, adding a little randomness so many clients don't retry in lockstep. ```javascript async function requestWithBackoff(doRequest, maxAttempts = 5) { for (let attempt = 0; attempt < maxAttempts; attempt++) { const res = await doRequest(); if (res.status !== 429) return res; const retryAfter = Number(res.headers.get("retry-after")); // seconds, if present const base = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 1000 * 2 ** attempt; const jitter = Math.random() * 250; await new Promise((r) => setTimeout(r, base + jitter)); } throw new Error("rate limited: exhausted retries"); } ``` ## Staying under the limit - **Prefer webhooks over polling.** Don't poll a payout in a tight loop waiting for it to settle — subscribe to [webhook events](/webhooks.md) instead. - **Page with large limits.** Use `limit=100` when listing so you make fewer calls. - **Smooth out batches.** When creating many payouts, add a small delay between requests or a concurrency cap rather than firing them all at once. - **Cap your retries.** A bounded retry with backoff recovers from a transient spike; an unbounded one turns a spike into a sustained overload. --- # Environments Two modes — test and live — on the same base URL. Build in the sandbox, then flip to a live key. There is a single base URL. Your **API key's prefix** selects the environment: `ck_test_` for the sandbox, `ck_live_` for production. You don't change hosts to switch — you change keys. ```bash https://api.caiboglobal.com/v1 ``` ## Test vs live | | Test (`ck_test_`) | Live (`ck_live_`) | | --- | --- | --- | | Moves real funds | No — fully simulated | Yes | | Touches the blockchain | No | Yes (signs & broadcasts on-chain) | | Requires verified KYB | No — available immediately | Yes | | Screening runs | Yes (real screening logic) | Yes | | Webhooks fire | Yes (signed, realistic) | Yes | | Data visibility | Test payouts only | Live payouts only | Test and live data are isolated. A payout created with a test key never appears when you list with a live key, and the reverse holds too — so you can leave test data around without polluting production. ## What the sandbox simulates In test mode your integration exercises the *whole* flow — validation, screening, limits, balance holds, state transitions, and signed webhooks — but the on-chain step is simulated. A completed sandbox payout has a simulated transaction hash prefixed with `0xSIMULATED`: ```json { "id": "po_9f8e7d6c5b4a", "status": "completed", "mode": "test", "tx_hash": "0xSIMULATED8f3a9c2b1d4e5f60" } ``` Test organizations are seeded with a sandbox balance, so you can create payouts without funding anything. ## Sandbox test addresses To let you drive every branch deterministically — like test card numbers on other platforms — the sandbox recognizes special destination addresses by their **suffix**: | Address suffix | Forces | | --- | --- | | …`0blocked` | The payout is `blocked` by screening. | | …`0fail` | The on-chain broadcast `fails`, and the funds are refunded to your balance. | | Any other valid address | The payout `completes` with a simulated `tx_hash`. | ```bash # Force a blocked payout curl https://api.caiboglobal.com/v1/payouts \ -H "X-API-Key: $CAIBO_TEST_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "amount": "10.00", "currency": "USDC", "rail": "stablecoin", "destination": { "network": "polygon", "address": "0x00000000000000000000000000000000000blocked" } }' # Force a failed broadcast (funds refunded) curl https://api.caiboglobal.com/v1/payouts \ -H "X-API-Key: $CAIBO_TEST_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "amount": "10.00", "currency": "USDC", "rail": "stablecoin", "destination": { "network": "polygon", "address": "0x000000000000000000000000000000000000fail" } }' ``` These suffixes are **only** honored in test mode — a live payout to such an address is treated as an ordinary destination and screened normally. ## Moving to live When your integration works end-to-end in the sandbox, submit KYB, create a `ck_live_` key, and swap it in. Everything else stays the same. Walk through the full checklist in [Going live](/going-live.md). --- # Payout lifecycle A payout moves through a well-defined state machine. Know each state, what it means, and how to react. Every payout has a `status`. It starts at `pending` and advances through screening and execution to a terminal state. Design your integration around these states — and let webhooks drive your reactions rather than polling. ## The state machine ```text pending ──▶ screening ──▶ approved ──▶ processing ──▶ completed │ │ │ ▼ ▼ ▼ blocked pending_approval failed (funds refunded) (cancel is possible up to — but not during — processing) ──▶ cancelled ``` ## States | Status | Meaning | Terminal? | | --- | --- | --- | | `pending` | Created and accepted; about to be screened. | No | | `screening` | The destination is being screened (sanctions + blacklist). | No | | `approved` | Screening cleared and the amount is within the auto-approval limit; execution is starting. | No | | `processing` | Being signed and broadcast on-chain. | No | | `completed` | Settled on-chain. `tx_hash` is set. Funds have left to the destination. | **Yes** | | `blocked` | Screening blocked the destination. The payout was *not* sent. | **Yes** | | `failed` | The broadcast failed. Funds were **refunded** to your balance. | **Yes** | | `cancelled` | You cancelled it before it started processing. | **Yes** | | `pending_approval` | Cleared screening but exceeds the org's auto-approval limit, so it's held for manual approval. | No | ## How events map to states Webhook events track the transitions: - `payout.created` → `pending` - `payout.processing` → `processing` - `payout.completed` → `completed` - `payout.failed` → `failed` (with a refund) - `payout.blocked` → `blocked` ## Cancelling You can cancel a payout while it is `pending`, `screening`, `pending_approval`, or `approved` — anytime before it starts `processing`. Once it is `processing` or in a terminal state, cancel returns `409 payout_not_cancelable`. Cancelling releases any held balance. ```bash curl https://api.caiboglobal.com/v1/payouts/po_9f8e7d6c5b4a/cancel \ -X POST -H "X-API-Key: $CAIBO_API_KEY" ``` ## Handling `pending_approval` Each organization has an **auto-approval limit**. Payouts at or below it flow straight through; payouts above it clear screening and then wait in `pending_approval` for a manual approval by an authorized member of your organization. Once approved, the payout continues to `processing` and then `completed`. - Don't treat `pending_approval` as failure — it's a hold, not a rejection. - Surface these payouts to whoever approves them (they can act from the Business dashboard). - You'll receive the completion (or failure) webhook once it resumes. ## Handling `blocked` A `blocked` payout was stopped by screening and never sent — **no funds moved**. Retrying the same destination will block again. Retrieve the payout to read the safe screening summary (status and reasons), and route the case to your compliance process rather than retrying. See [Compliance & screening](/compliance.md). ## Handling `failed` A `failed` payout means the on-chain broadcast didn't go through; the amount was **refunded to your balance**. Failures are typically transient (network conditions). You can create a new payout to the same destination — use a **new** [idempotency key](/idempotency.md), since it's a new payout, and read `failure_reason` for context. ## Recommended pattern - Persist each payout's `id` and current `status` when you create it. - Update your record from webhook events; re-fetch by id to confirm before doing anything irreversible. - Reconcile periodically by listing payouts and comparing against your records. --- # Compliance & screening Every payout is screened before it's sent, and live access requires a verified business. Here's what that means for your integration. Caibo is a regulated money-movement platform, so compliance is built into the payout flow rather than bolted on. Two things gate a live payout: your business must be **verified** (KYB), and every destination is **screened** before any funds move. ## Screening: what happens before a payout is sent When you create a payout, the destination address is screened **before** anything is broadcast on-chain. Screening is in-line and blocking — there is no path where a payout is sent without passing it. If the destination is flagged, the payout becomes `blocked` and no funds move. Screening checks the destination against: - **Sanctions lists** — addresses associated with sanctioned entities. - **An internal blacklist** — addresses your platform or Caibo has flagged. ## What a `blocked` payout means A `blocked` status means screening stopped the payout. **No money left your balance.** Retrieving the payout returns a safe screening summary — the outcome and the reason(s) — so you can see *why* it was blocked, without exposing any raw provider detail: ```json { "id": "po_1a2b3c4d", "status": "blocked", "screening_status": "blocked", "screening_result": { "status": "blocked", "reasons": ["ofac_sdn"] } } ``` | Reason | Meaning | | --- | --- | | `ofac_sdn` | The destination matched a sanctions list. | | `internal_blacklist` | The destination is on the internal blacklist. | Do not retry a blocked payout to the same destination — it will block again. Route it to your compliance workflow instead. > In **live** mode, if screening is temporarily unavailable, the payout is *not* sent — you'll get a `sanctions_unavailable` error rather than an unscreened payout. Retry later. This fail-closed behavior is intentional. ## KYB: verifying your business for live access `ck_test_` keys work immediately. To create `ck_live_` keys and send real payouts, your organization must complete **KYB** (Know Your Business) and reach `verified`. Until then, requesting a live key or a live payout returns `kyb_required`. Submit KYB from the Business dashboard or via `POST /v1/kyb`. You provide: - Legal name, registration number, and country of incorporation. - **Beneficial owners (UBOs)** — each owner's name, date of birth, ownership percentage, and country. Total ownership can't exceed 100%. - Supporting **documents**, referenced by URL/reference. KYB moves through `unverified → pending → verified` (or `rejected`). You can check status anytime: ```bash curl https://api.caiboglobal.com/v1/kyb \ -H "X-API-Key: $CAIBO_API_KEY" ``` ## Limits Each organization has payout limits — **per-payout**, **daily**, and **monthly**. A payout that would breach a limit is rejected up front with `limit_exceeded` (no funds move). Read your current limits from `GET /v1/organization`. If you need higher limits, contact us. Separately, the **auto-approval limit** governs which payouts execute automatically and which pause for manual approval — see [Payout lifecycle](/payout-lifecycle.md). ## What this means for your integration - Handle `blocked` and `kyb_required` as **compliance outcomes**, not transient errors — a human decision is needed, so never auto-retry them. - Handle `limit_exceeded` by spacing out or splitting payouts, or by requesting higher limits. - Handle `sanctions_unavailable` as transient — retry later. - Keep destination data clean: screening acts on the exact address you submit. --- # Webhooks Get notified about payout events on your own endpoint. Every delivery is signed — verify it before you trust it. Webhooks push payout state changes to your backend so you don't have to poll. You register an HTTPS endpoint, subscribe to the events you care about, and Caibo `POST`s a signed JSON payload each time a subscribed event occurs. ## Events | Event | Fires when | | --- | --- | | `payout.created` | A payout has been created and accepted. | | `payout.processing` | The payout is being broadcast on-chain. | | `payout.completed` | The payout settled. `tx_hash` is set. | | `payout.failed` | The broadcast failed; funds were refunded to your balance. | | `payout.blocked` | Screening blocked the payout. It was not sent. | See [Payout lifecycle](/payout-lifecycle.md) for how these events map to the payout state machine. ## Registering an endpoint Create an endpoint over HTTPS and choose your events. The response includes a **signing secret** shown **once** — store it securely; you'll use it to verify every delivery. ```bash curl https://api.caiboglobal.com/v1/webhooks \ -H "X-API-Key: $CAIBO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.example.com/caibo/webhook", "events": ["payout.completed", "payout.failed", "payout.blocked"] }' ``` ```json { "object": "webhook_endpoint", "id": "whe_1a2b3c4d", "url": "https://api.example.com/caibo/webhook", "events": ["payout.completed", "payout.failed", "payout.blocked"], "active": true, "secret": "whsec_9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c", "created_at": "2026-07-23T14:03:00Z" } ``` ## Payload The request body is a JSON object describing the event and the payout it concerns: ```json { "event": "payout.completed", "object": "payout", "id": "po_9f8e7d6c5b4a", "status": "completed", "amount": "25.00", "currency": "USDC", "mode": "live", "destination": { "network": "polygon", "address": "0x1234...5678" }, "tx_hash": "0x8f3a9c2b1d4e5f60...", "created_at": "2026-07-23T14:03:00Z" } ``` Two headers accompany every delivery: | Header | Value | | --- | --- | | `Caibo-Event` | The event name, e.g. `payout.completed`. | | `Caibo-Signature` | The signature: `t=,v1=`. | ## Verifying the signature Before trusting a delivery, verify its signature. The `Caibo-Signature` header contains a timestamp `t` and a signature `v1`, where `v1 = HMAC-SHA256(secret, ".")` in lowercase hex. To verify: 1. Read the **raw request body** — the exact bytes received, before any JSON parsing. 1. Parse `t` and `v1` from the header. 1. **Reject replays:** if `|now − t| > 5 minutes`, discard the request. 1. Compute the expected HMAC and compare it to `v1` in constant time. ### Node.js (Express) ```javascript import express from "express"; import crypto from "crypto"; const app = express(); const SECRET = process.env.CAIBO_WEBHOOK_SECRET; function verify(secret, header, rawBody) { const parts = Object.fromEntries(header.split(",").map((p) => p.split("="))); const t = Number(parts.t); if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // replay guard const expected = crypto .createHmac("sha256", secret) .update(`${t}.${rawBody}`) .digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(parts.v1 ?? "", "utf8"); return a.length === b.length && crypto.timingSafeEqual(a, b); } // IMPORTANT: use the RAW body for verification, not a parsed object. app.post("/caibo/webhook", express.raw({ type: "application/json" }), (req, res) => { const raw = req.body.toString("utf8"); if (!verify(SECRET, req.get("Caibo-Signature") ?? "", raw)) { return res.status(400).send("invalid signature"); } const event = JSON.parse(raw); // ... handle event (idempotently) ... res.sendStatus(200); }); ``` ### Python (Flask) ```python import hmac, hashlib, time from flask import Flask, request app = Flask(__name__) SECRET = "whsec_..." def verify(secret: str, header: str, raw_body: bytes) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) t = int(parts.get("t", "0")) if not t or abs(time.time() - t) > 300: # replay guard return False message = f"{t}.".encode() + raw_body expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", "")) @app.post("/caibo/webhook") def webhook(): raw = request.get_data() # raw bytes, before JSON parsing if not verify(SECRET, request.headers.get("Caibo-Signature", ""), raw): return "invalid signature", 400 event = request.get_json() # ... handle event (idempotently) ... return "", 200 ``` ### Go ```go func verify(secret, header string, body []byte) bool { var t int64 var sig string for _, part := range strings.Split(header, ",") { kv := strings.SplitN(strings.TrimSpace(part), "=", 2) if len(kv) != 2 { continue } switch kv[0] { case "t": t, _ = strconv.ParseInt(kv[1], 10, 64) case "v1": sig = kv[1] } } if t == 0 || math.Abs(float64(time.Now().Unix()-t)) > 300 { // replay guard return false } mac := hmac.New(sha256.New, []byte(secret)) fmt.Fprintf(mac, "%d.", t) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(sig)) } ``` ## Responding Return a `2xx` status as soon as you've stored the event. Do the real work asynchronously — don't block the response on slow downstream calls. Any non-`2xx` response (or a timeout) is treated as a failed delivery and retried. ## Retries & delivery Delivery is **at least once**. If your endpoint doesn't return `2xx`, Caibo retries with exponential backoff — after roughly **1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours** — for up to **5 attempts**. After that the delivery is marked failed and won't be retried automatically. ## Idempotency on your side Because delivery is at-least-once, your endpoint may receive the same event more than once (for example, if your `2xx` was lost on the way back). Make handling idempotent: dedupe on the pair of `event` + payout `id`, and treat an already-processed event as a no-op. ```javascript const key = `${event.event}:${event.id}`; // e.g. "payout.completed:po_9f8e7d6c5b4a" if (await alreadyProcessed(key)) return res.sendStatus(200); await markProcessed(key); await handle(event); ``` ## Best practices - **Always verify** the signature and the timestamp before acting on a payload. - **Use the raw body** for verification — re-serializing parsed JSON changes the bytes and breaks the HMAC. - **Keep the secret secret.** It's shown once at creation; store it in your secret manager. If it leaks, delete the endpoint and register a new one. - **Subscribe narrowly.** Only request the events you handle. - **Return fast, process async.** Acknowledge quickly, then do the work in a queue. - **Don't trust payload contents alone.** For anything critical, re-fetch the payout by id to confirm its current state. --- # Going live A checklist to move from the sandbox to real payouts with confidence. You've built and tested your integration against `ck_test_`. Going live is mostly swapping the key — but a few things must be in place first. Work through this checklist before your first real payout. ## 1. Complete KYB Live mode requires a `verified` business. Submit KYB (legal details, beneficial owners, documents) from the Business dashboard or `POST /v1/kyb`, and wait for `verified`. Until then, live keys and live payouts return `kyb_required`. See [Compliance & screening](/compliance.md). ```bash curl https://api.caiboglobal.com/v1/organization \ -H "X-API-Key: $CAIBO_API_KEY" # confirm "kyb_status": "verified" ``` ## 2. Create a live API key Once verified, create a `ck_live_` key with only the scopes your service needs. Store it in your secret manager — the full key is shown once. Keep it server-side only. See [Authentication](/authentication.md). ## 3. Register a production webhook endpoint Register your production HTTPS endpoint with a live key, subscribe to the events you handle, and store the signing secret. Confirm end-to-end that: - Your endpoint **verifies the signature** and rejects stale timestamps (> 5 min). - It reads the **raw body** for verification. - It responds `2xx` quickly and processes asynchronously. - Handling is **idempotent** (dedupe on `event` + payout `id`). See [Webhooks](/webhooks.md). ## 4. Review your limits Read `GET /v1/organization` and confirm the per-payout, daily, and monthly limits match your expected volume. If your launch needs more headroom, request higher limits before you start. ## 5. Handle `pending_approval` Decide how large payouts (above your auto-approval limit) get approved, and make sure someone can approve them from the Business dashboard. Your integration should treat `pending_approval` as a hold and wait for the eventual completion or failure webhook. See [Payout lifecycle](/payout-lifecycle.md). ## 6. Make error and retry handling production-grade - Retry `429` and `5xx` with exponential backoff; reuse the same idempotency key on payout retries. - Route `blocked`, `kyb_required`, and `limit_exceeded` to a human — never auto-retry. - Log `request_id` on every failure. See [Errors](/errors.md). ## 7. Fund your balance Live payouts draw on your organization's balance. A payout above your available balance returns `insufficient_balance`. Make sure the balance is funded for the asset you're paying out. ## 8. Monitor - **Reconcile** regularly: list payouts and compare against your own records. - **Alert** on `failed`, `blocked`, and `insufficient_balance`. - **Watch webhook health** — track deliveries your endpoint rejected so none are silently missed. - **Track balances** so payouts never stall on funding. ## Launch checklist - ☐ KYB `verified` - ☐ Live API key created, stored in a secret manager, scoped minimally - ☐ Production webhook endpoint registered, verifying signatures, idempotent - ☐ Limits reviewed for launch volume - ☐ `pending_approval` approval path in place - ☐ Retry/backoff and error routing implemented - ☐ Balance funded - ☐ Monitoring, alerting, and reconciliation live When every box is checked, send your first live payout — start small, confirm the webhook and the on-chain `tx_hash`, then scale up. --- # API reference The complete v1 reference: base URL, conventions, and every resource object. Each resource has its own page with request, response, and error details. This reference documents every endpoint of the Caibo Global `v1` API. Start with the conventions below, then jump to a resource. If you're new, read the [guides](/introduction.md) first — the quickstart walks you through your first payout end to end. **Download:** the machine-readable [OpenAPI 3.1 specification (v1.yaml)](/v1.yaml). These docs are also available as plain Markdown for LLMs — see [llms.txt](/llms.txt) and [llms-full.txt](/llms-full.txt). ## Base URL ```bash https://api.caiboglobal.com/v1 ``` All endpoints are relative to this URL and served over HTTPS only. ## Authentication Every endpoint except `POST /organizations` authenticates with an [API key](/authentication.md) sent as `X-API-Key` (or `Authorization: Bearer`). Each endpoint also requires a specific **scope**, noted on its page. ```bash -H "X-API-Key: ck_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" ``` ## Conventions - **Money is strings.** Amounts (e.g. `amount`) are decimal strings like `"100.50"`. - **Timestamps are ISO 8601 UTC**, e.g. `2026-07-23T14:03:00Z`. - **IDs are prefixed:** `org_`, `po_`, `key_`, `whe_`, `kyb_`. - **Objects carry an `object` field** naming their type (e.g. `"payout"`). - **Lists** return `{ object: "list", data, has_more, next_cursor }` — see [Pagination](/pagination.md). - **Errors** return `{ error: { type, code, message, param, request_id } }` — see [Errors](/errors.md). - **Idempotency:** `POST /payouts` requires an `Idempotency-Key` header — see [Idempotency](/idempotency.md). ## Endpoints | Method & path | Scope | Reference | | --- | --- | --- | | `POST /payouts` | `payouts:write` | [Payouts](/payouts-api.md) | | `GET /payouts` | `payouts:read` | [Payouts](/payouts-api.md) | | `GET /payouts/{id}` | `payouts:read` | [Payouts](/payouts-api.md) | | `POST /payouts/{id}/cancel` | `payouts:write` | [Payouts](/payouts-api.md) | | `GET /balances` | `balances:read` | [Balances & rates](/balances-rates-api.md) | | `GET /rates` | `balances:read` | [Balances & rates](/balances-rates-api.md) | | `GET /countries` | `organization:read` | [Catalog](/catalog-api.md) | | `GET /payment-methods` | `organization:read` | [Catalog](/catalog-api.md) | | `POST /webhooks` | `webhooks:write` | [Webhooks](/webhooks-api.md) | | `GET /webhooks` | `webhooks:read` | [Webhooks](/webhooks-api.md) | | `DELETE /webhooks/{id}` | `webhooks:write` | [Webhooks](/webhooks-api.md) | | `POST /api-keys` | `organization:write` | [API keys](/api-keys-api.md) | | `GET /api-keys` | `organization:read` | [API keys](/api-keys-api.md) | | `DELETE /api-keys/{id}` | `organization:write` | [API keys](/api-keys-api.md) | | `GET /organization` | `organization:read` | [Organization & KYB](/organization-api.md) | | `POST /organizations` | Firebase (no API key) | [Organization & KYB](/organization-api.md) | | `POST /kyb` | `organization:write` | [Organization & KYB](/organization-api.md) | | `GET /kyb` | `organization:read` | [Organization & KYB](/organization-api.md) | ## The objects ### Payout | Field | Type | Description | | --- | --- | --- | | `id` | string | Payout id, e.g. `po_9f8e7d6c5b4a`. | | `object` | string | Always `"payout"`. | | `status` | enum | `pending`, `screening`, `approved`, `processing`, `completed`, `failed`, `blocked`, `cancelled`, `pending_approval`. See [Payout lifecycle](/payout-lifecycle.md). | | `mode` | enum | `test` or `live`. | | `amount` | string | Decimal amount, e.g. `"25.00"`. | | `currency` | string | Asset: `USDC`, `USDT`, or `ETH`. | | `rail` | string | Always `"stablecoin"`. | | `destination` | object | `{ network, address }`. | | `screening_status` | enum | `pending`, `clear`, or `blocked`. | | `tx_hash` | string \| null | On-chain transaction hash once `completed`; otherwise `null`. | | `failure_reason` | string | Present when `status` is `failed`. | | `metadata` | object | Your key/value data, echoed back. Present if you set it. | | `created_at` | string | ISO 8601 UTC. | ### Organization | Field | Type | Description | | --- | --- | --- | | `id` | string | `org_...` | | `object` | string | Always `"organization"`. | | `legal_name` | string | Registered legal name. | | `display_name` | string | Display name. | | `country` | string | ISO-3166 alpha-2. | | `status` | enum | `pending`, `active`, `suspended`, `closed`. | | `kyb_status` | enum | `unverified`, `pending`, `verified`, `rejected`. | | `created_at` | string | ISO 8601 UTC. | ### APIKey | Field | Type | Description | | --- | --- | --- | | `id` | string | `key_...` | | `object` | string | Always `"api_key"`. | | `prefix` | string | Visible prefix, e.g. `ck_live_a1b2`. | | `mode` | enum | `test` or `live`. | | `name` | string \| null | Optional label. | | `scopes` | string[] | Granted scopes. | | `revoked` | boolean | Whether the key has been revoked. | | `created_at` | string | ISO 8601 UTC. | | `last_used_at` | string \| null | Last time the key authenticated a request. | | `key` | string | The full secret. Returned **only** in the create response, once. | ### WebhookEndpoint | Field | Type | Description | | --- | --- | --- | | `id` | string | `whe_...` | | `object` | string | Always `"webhook_endpoint"`. | | `url` | string | Your HTTPS endpoint. | | `events` | string[] | Subscribed event names. | | `active` | boolean | Whether deliveries are enabled. | | `created_at` | string | ISO 8601 UTC. | | `secret` | string | Signing secret (`whsec_...`). Returned **only** in the create response, once. | ### KybSubmission | Field | Type | Description | | --- | --- | --- | | `id` | string | `kyb_...` | | `object` | string | Always `"kyb_submission"`. | | `org_id` | string | Owning organization. | | `status` | enum | `pending`, `in_review`, `verified`, `rejected`. | | `legal_name` | string | Legal name. | | `registration_no` | string | Business registration number. | | `incorporation_country` | string | ISO-3166 alpha-2. | | `beneficial_owners` | object[] | Each `{ name, dob, ownership_pct, country }`. | | `documents` | object[] | Each `{ type, reference }`. | | `review_notes` | string | Present when `rejected`. | | `created_at` | string | ISO 8601 UTC. | | `reviewed_at` | string | Present once reviewed. | --- # Payouts API Create, retrieve, list, and cancel stablecoin payouts. A payout sends stablecoin from your organization's balance to an on-chain address. See the [payout lifecycle](/payout-lifecycle.md) for how statuses progress and the [reference overview](/api-reference.md) for the payout object. ## Create a payout `POST /v1/payouts` — Scope: `payouts:write` Creates a payout and starts its lifecycle: it is screened, then (if it clears and is within the auto-approval limit) executed on-chain. Use it whenever you need to pay a recipient. ### Headers | Header | Required | Description | | --- | --- | --- | | `X-API-Key` | Yes | Your API key. | | `Content-Type` | Yes | `application/json` | | `Idempotency-Key` | **Yes** | A unique value per logical payout. Retries with the same key return the original payout. See [Idempotency](/idempotency.md). | ### Body parameters | Field | Type | Required | Description | | --- | --- | --- | --- | | `amount` | string | Yes | Decimal amount > 0, e.g. `"25.00"`. Must be within your organization's limits and balance. | | `currency` | string | Yes | Asset to send. Must be supported on `destination.network` (see the matrix below). | | `rail` | string | No | Defaults to `"stablecoin"` (the only supported value). | | `destination` | object | Yes | Where to send. See sub-fields below. | | `destination.network` | string | Yes | One of `polygon`, `arbitrum`, `optimism`, `base`, `bsc`, `ethereum`, `tron`, `solana`. | | `destination.address` | string | Yes | The recipient address, valid for the network. | | `metadata` | object | No | Arbitrary key/value pairs echoed back on the payout. Use it to attach your own references. | ### Supported network / asset combinations | Network | Assets | | --- | --- | | `polygon`, `arbitrum`, `optimism`, `base`, `bsc`, `ethereum` | `USDC`, `USDT`, `ETH` | | `tron` | `USDT` | | `solana` | `USDC`, `USDT` | A combination outside this matrix returns `422 unsupported_destination`. ### Request ```bash curl https://api.caiboglobal.com/v1/payouts \ -H "X-API-Key: $CAIBO_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 6b2f1e0a-7c3d-4a91-9f2e-1a2b3c4d5e6f" \ -d '{ "amount": "25.00", "currency": "USDC", "rail": "stablecoin", "destination": { "network": "polygon", "address": "0x1234abcd5678ef901234abcd5678ef9012345678" }, "metadata": { "invoice_id": "INV-1001" } }' ``` ### Response — 201 Created Returns the payout. If the amount exceeds your auto-approval limit, the status is `pending_approval` and the HTTP status is **202 Accepted** instead of 201. ```json { "id": "po_9f8e7d6c5b4a", "object": "payout", "status": "processing", "mode": "live", "amount": "25.00", "currency": "USDC", "rail": "stablecoin", "destination": { "network": "polygon", "address": "0x1234abcd5678ef901234abcd5678ef9012345678" }, "screening_status": "clear", "tx_hash": null, "metadata": { "invoice_id": "INV-1001" }, "created_at": "2026-07-23T14:03:00Z" } ``` ### Behavior - **Screening runs first, in-line.** The destination is screened before anything is broadcast. A flagged destination yields `403 screening_blocked` and a payout with status `blocked`; no funds move. See [Compliance & screening](/compliance.md). - **Auto-approval.** Amounts within your organization's auto-approval limit execute immediately; larger ones return `202` with status `pending_approval` and wait for a manual approval. - **Idempotent.** Reusing the `Idempotency-Key` with the same body returns the original payout; with a different body it returns `409 idempotency_conflict`. - **Webhooks.** A successful create fires `payout.created`, then `payout.processing` and finally `payout.completed` (or `payout.failed` with a refund). A blocked payout fires `payout.blocked`. See [Webhook events](/webhook-events.md). ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 400 | invalid_request_error | bad_request | Missing `Idempotency-Key`, malformed body, or an invalid `amount`. | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `payouts:write`. | | 403 | compliance_error | kyb_required | Live mode with an unverified business. | | 403 | compliance_error | screening_blocked | The destination was blocked by screening. | | 403 | compliance_error | limit_exceeded | The payout would breach a per-payout, daily, or monthly limit. | | 402 | invalid_request_error | insufficient_balance | Balance below the amount. | | 409 | invalid_request_error | idempotency_conflict | The idempotency key was reused with a different request. | | 422 | invalid_request_error | unsupported_destination | Unsupported network/asset combination or an empty/invalid destination. | | 429 | rate_limit_error | rate_limited | Too many requests. Back off and retry. | | 503 | compliance_error | sanctions_unavailable | Live-mode screening is temporarily unavailable; the payout was not sent. Retry later. | ## List payouts `GET /v1/payouts` — Scope: `payouts:read` Returns your organization's payouts, newest first, cursor-paginated. ### Query parameters | Parameter | Type | Description | | --- | --- | --- | | `limit` | integer | Page size, 1–100. Defaults to 25. | | `starting_after` | string | A payout id; returns the page after it. See [Pagination](/pagination.md). | | `status` | string | Filter by a single status, e.g. `completed`. | ### Request ```bash curl "https://api.caiboglobal.com/v1/payouts?limit=25&status=completed" \ -H "X-API-Key: $CAIBO_API_KEY" ``` ### Response — 200 OK ```json { "object": "list", "data": [ { "id": "po_9f8e7d6c5b4a", "object": "payout", "status": "completed", "mode": "live", "amount": "25.00", "currency": "USDC", "rail": "stablecoin", "destination": { "network": "polygon", "address": "0x1234...5678" }, "screening_status": "clear", "tx_hash": "0x8f3a9c2b1d4e5f60...", "created_at": "2026-07-23T14:03:00Z" } ], "has_more": true, "next_cursor": "po_9f8e7d6c5b4a" } ``` ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `payouts:read`. | | 429 | rate_limit_error | rate_limited | Too many requests. | ## Retrieve a payout `GET /v1/payouts/{id}` — Scope: `payouts:read` Fetches a single payout by id. Returns `404 resource_missing` if it doesn't exist or isn't yours. ```bash curl https://api.caiboglobal.com/v1/payouts/po_9f8e7d6c5b4a \ -H "X-API-Key: $CAIBO_API_KEY" ``` ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `payouts:read`. | | 404 | invalid_request_error | resource_missing | No such payout for your organization. | ## Cancel a payout `POST /v1/payouts/{id}/cancel` — Scope: `payouts:write` Cancels a payout that hasn't started processing. Cancellable statuses are `pending`, `screening`, `pending_approval`, and `approved`. Once the payout is `processing` or in a terminal state, cancel returns `409 payout_not_cancelable`. Cancelling releases any held balance. ```bash curl -X POST https://api.caiboglobal.com/v1/payouts/po_9f8e7d6c5b4a/cancel \ -H "X-API-Key: $CAIBO_API_KEY" ``` ### Response — 200 OK ```json { "id": "po_9f8e7d6c5b4a", "object": "payout", "status": "cancelled", "amount": "25.00", "currency": "USDC", "created_at": "2026-07-23T14:03:00Z" } ``` ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `payouts:write`. | | 404 | invalid_request_error | resource_missing | No such payout for your organization. | | 409 | invalid_request_error | payout_not_cancelable | The payout is already processing or terminal. | --- # Balances & rates Read your organization's balances and quote conversion rates. ## Get balances `GET /v1/balances` — Scope: `balances:read` Returns your organization's balance for each asset it holds — the `available` amount you can pay out and the `held` amount reserved by in-flight payouts. Takes no parameters. ### Request ```bash curl https://api.caiboglobal.com/v1/balances \ -H "X-API-Key: $CAIBO_API_KEY" ``` ### Response — 200 OK ```json { "object": "list", "data": [ { "asset": "USDC", "available": "1200.00", "held": "25.00" }, { "asset": "USDT", "available": "0", "held": "0" } ] } ``` | Field | Type | Description | | --- | --- | --- | | `asset` | string | The asset, e.g. `USDC`. | | `available` | string | Decimal amount available to pay out. | | `held` | string | Decimal amount reserved by payouts that are in flight. | ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `balances:read`. | | 429 | rate_limit_error | rate_limited | Too many requests. | ## Get a rate `GET /v1/rates` — Scope: `balances:read` Returns the current conversion quote for a currency pair, including the effective rate (after spread) and the amount you'd receive for a given input amount. This is a read-only quote — it creates nothing. ### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `from_currency_id` | string | Yes | The source currency's internal id. | | `to_currency_id` | string | Yes | The target currency's internal id. | | `amount` | string | No | Decimal input amount to convert. Defaults to `0`. | > `from_currency_id` and `to_currency_id` are internal currency identifiers. There is no currency-listing endpoint on `v1` today; if you need the ids for your configured pairs, contact support. ### Request ```bash curl "https://api.caiboglobal.com/v1/rates?from_currency_id=cur_cad&to_currency_id=cur_usdc&amount=100" \ -H "X-API-Key: $CAIBO_API_KEY" ``` ### Response — 200 OK ```json { "object": "rate", "rate": "0.735", "effective_rate": "0.72765", "fee": "0.50", "amount_in": "100", "amount_out": "72.401175" } ``` | Field | Type | Description | | --- | --- | --- | | `rate` | string | The base conversion rate. | | `effective_rate` | string | The rate after applying the spread. | | `fee` | string | The flat fee subtracted from the input before conversion. | | `amount_in` | string | The input amount you supplied. | | `amount_out` | string | The resulting amount: `max(0, amount_in − fee) × effective_rate`. | ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 400 | invalid_request_error | bad_request | Missing `from_currency_id`/`to_currency_id`, or an invalid `amount`. | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `balances:read`. | | 404 | invalid_request_error | not_found | No active rate exists for the pair. | | 429 | rate_limit_error | rate_limited | Too many requests. | --- # Catalog Read the supported countries and payment methods. The catalog endpoints expose reference data — the active countries and payment methods configured on the platform. They are read-only and take no parameters. ## List countries `GET /v1/countries` — Scope: `organization:read` Returns the active countries, each with its currency code where configured. ### Request ```bash curl https://api.caiboglobal.com/v1/countries \ -H "X-API-Key: $CAIBO_API_KEY" ``` ### Response — 200 OK ```json { "object": "list", "data": [ { "id": "ctry_ca", "code": "CA", "name": "Canada", "currency": "CAD" }, { "id": "ctry_us", "code": "US", "name": "United States", "currency": "USD" } ] } ``` | Field | Type | Description | | --- | --- | --- | | `id` | string | Country id. | | `code` | string | ISO-3166 alpha-2 code. | | `name` | string | Country name. | | `currency` | string | Currency code, when the country has one configured. | ## List payment methods `GET /v1/payment-methods` — Scope: `organization:read` Returns the active payment methods. ### Request ```bash curl https://api.caiboglobal.com/v1/payment-methods \ -H "X-API-Key: $CAIBO_API_KEY" ``` ### Response — 200 OK ```json { "object": "list", "data": [ { "id": "pm_wire", "code": "WIRE", "name": "Wire transfer", "type": "BANK_TRANSFER" } ] } ``` | Field | Type | Description | | --- | --- | --- | | `id` | string | Payment method id. | | `code` | string | Machine code, e.g. `WIRE`. | | `name` | string | Human-readable name. | | `type` | string | Method type. | ## Errors Both endpoints share the same error set: | HTTP | type | code | When | | --- | --- | --- | --- | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `organization:read`. | | 429 | rate_limit_error | rate_limited | Too many requests. | --- # Webhooks Register, list, and delete webhook endpoints. Webhook endpoints receive signed payout events. For the delivery model, signature verification, and best practices, see the [Webhooks guide](/webhooks.md); for each event's payload, see [Webhook events](/webhook-events.md). ## Create an endpoint `POST /v1/webhooks` — Scope: `webhooks:write` Registers an HTTPS endpoint and subscribes it to events. The response includes the **signing secret**, returned **once** — store it to verify deliveries. ### Body parameters | Field | Type | Required | Description | | --- | --- | --- | --- | | `url` | string | Yes | Your endpoint. Must be `https://` with a host. | | `events` | string[] | Yes | At least one event. Each must be one of the known events (below). | ### Subscribable events - `payout.created` - `payout.processing` - `payout.completed` - `payout.failed` - `payout.blocked` ### Request ```bash curl https://api.caiboglobal.com/v1/webhooks \ -H "X-API-Key: $CAIBO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.example.com/caibo/webhook", "events": ["payout.completed", "payout.failed", "payout.blocked"] }' ``` ### Response — 201 Created ```json { "id": "whe_1a2b3c4d", "object": "webhook_endpoint", "url": "https://api.example.com/caibo/webhook", "events": ["payout.completed", "payout.failed", "payout.blocked"], "active": true, "secret": "whsec_9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c", "created_at": "2026-07-23T14:03:00Z" } ``` > The `secret` is shown only in this response. Store it securely — you can't retrieve it again. If it's lost or leaked, delete the endpoint and create a new one. ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 400 | invalid_request_error | bad_request | A non-`https` or malformed `url`, no `events`, or an unknown event name. | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `webhooks:write`. | | 429 | rate_limit_error | rate_limited | Too many requests. | ## List endpoints `GET /v1/webhooks` — Scope: `webhooks:read` Returns your registered endpoints. The signing secret is **never** returned on list. ```bash curl https://api.caiboglobal.com/v1/webhooks \ -H "X-API-Key: $CAIBO_API_KEY" ``` ### Response — 200 OK ```json { "object": "list", "data": [ { "id": "whe_1a2b3c4d", "object": "webhook_endpoint", "url": "https://api.example.com/caibo/webhook", "events": ["payout.completed", "payout.failed", "payout.blocked"], "active": true, "created_at": "2026-07-23T14:03:00Z" } ] } ``` ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `webhooks:read`. | | 429 | rate_limit_error | rate_limited | Too many requests. | ## Delete an endpoint `DELETE /v1/webhooks/{id}` — Scope: `webhooks:write` Deletes an endpoint. It stops receiving deliveries immediately. Returns `204 No Content`. ```bash curl -X DELETE https://api.caiboglobal.com/v1/webhooks/whe_1a2b3c4d \ -H "X-API-Key: $CAIBO_API_KEY" ``` ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `webhooks:write`. | | 404 | invalid_request_error | resource_missing | No such endpoint for your organization. | --- # API keys Create, list, and revoke your organization's API keys. Manage the keys your backend uses to call the API. For key format, modes, scopes, rotation, and security, see [Authentication](/authentication.md). ## Create a key `POST /v1/api-keys` — Scope: `organization:write` Mints a new key. The full secret is returned **once**, in this response — store it immediately. **Live keys require a verified business**: requesting `mode: "live"` for an unverified organization returns `403 kyb_required`. Test keys are always allowed. ### Body parameters | Field | Type | Required | Description | | --- | --- | --- | --- | | `mode` | string | Yes | `test` or `live`. | | `name` | string | No | An optional label to identify the key. | | `scopes` | string[] | No | The scopes to grant. If omitted, the key gets the full default scope set. Valid scopes: `payouts:write`, `payouts:read`, `balances:read`, `webhooks:write`, `webhooks:read`, `organization:read`, `organization:write`. | ### Request ```bash curl https://api.caiboglobal.com/v1/api-keys \ -H "X-API-Key: $CAIBO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "live", "name": "Production server", "scopes": ["payouts:write", "payouts:read", "balances:read"] }' ``` ### Response — 201 Created ```json { "id": "key_7a8b9c0d", "object": "api_key", "prefix": "ck_live_a1b2", "mode": "live", "name": "Production server", "scopes": ["payouts:write", "payouts:read", "balances:read"], "key": "ck_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "created_at": "2026-07-23T14:03:00Z" } ``` > `key` is the full secret and appears **only** here. Store it in your secret manager now — you can't retrieve it later. Listing keys returns only the prefix. ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 400 | invalid_request_error | bad_request | An invalid `mode` or malformed body. | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `organization:write`. | | 403 | compliance_error | kyb_required | `mode: "live"` for an unverified organization. | | 429 | rate_limit_error | rate_limited | Too many requests. | ## List keys `GET /v1/api-keys` — Scope: `organization:read` Returns your organization's keys as metadata only — never the secret. ```bash curl https://api.caiboglobal.com/v1/api-keys \ -H "X-API-Key: $CAIBO_API_KEY" ``` ### Response — 200 OK ```json { "object": "list", "data": [ { "id": "key_7a8b9c0d", "object": "api_key", "prefix": "ck_live_a1b2", "mode": "live", "name": "Production server", "scopes": ["payouts:write", "payouts:read", "balances:read"], "revoked": false, "created_at": "2026-07-23T14:03:00Z", "last_used_at": "2026-07-23T15:20:11Z" } ] } ``` ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `organization:read`. | | 429 | rate_limit_error | rate_limited | Too many requests. | ## Revoke a key `DELETE /v1/api-keys/{id}` — Scope: `organization:write` Revokes a key. It is rejected on its very next request. Revocation is immediate and can't be undone. Returns `204 No Content`. ```bash curl -X DELETE https://api.caiboglobal.com/v1/api-keys/key_7a8b9c0d \ -H "X-API-Key: $CAIBO_API_KEY" ``` ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `organization:write`. | | 404 | invalid_request_error | not_found | No such key, or it's already revoked. | | 429 | rate_limit_error | rate_limited | Too many requests. | --- # Organization & KYB Read your organization, onboard a new one, and submit business verification (KYB). ## Get the organization `GET /v1/organization` — Scope: `organization:read` Returns the organization the API key belongs to. ```bash curl https://api.caiboglobal.com/v1/organization \ -H "X-API-Key: $CAIBO_API_KEY" ``` ### Response — 200 OK ```json { "id": "org_9f8e7d6c", "object": "organization", "legal_name": "Acme Inc", "display_name": "Acme", "country": "CA", "status": "active", "kyb_status": "verified", "created_at": "2026-07-23T14:03:00Z" } ``` ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `organization:read`. | | 429 | rate_limit_error | rate_limited | Too many requests. | ## Create an organization (onboarding) `POST /v1/organizations` > **This is the one endpoint that does NOT use an API key.** It's authenticated with a **Firebase ID token** — the founder signs in first, and the organization is created for that user. It exists because you need a way to create your *first* organization (and thus your first key) before any API key exists. In practice you'll use the [Business dashboard](https://app.caiboglobal.com/dashboard/business) for this rather than calling it directly. ### Headers | Header | Required | Description | | --- | --- | --- | | `Authorization` | Yes | `Bearer ` — not an API key. | | `Content-Type` | Yes | `application/json` | ### Body parameters | Field | Type | Required | Description | | --- | --- | --- | --- | | `legal_name` | string | Yes | The business's legal name. | | `display_name` | string | No | Defaults to `legal_name`. | | `country` | string | Yes | ISO-3166 alpha-2. | ### Request ```bash curl https://api.caiboglobal.com/v1/organizations \ -H "Authorization: Bearer $FIREBASE_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "legal_name": "Acme Inc", "display_name": "Acme", "country": "CA" }' ``` ### Response — 201 Created Returns the new organization and a one-time **test** API key to bootstrap your integration. ```json { "organization": { "id": "org_9f8e7d6c", "object": "organization", "legal_name": "Acme Inc", "display_name": "Acme", "country": "CA", "status": "pending", "kyb_status": "unverified", "created_at": "2026-07-23T14:03:00Z" }, "api_key": { "object": "api_key", "prefix": "ck_test_a1b2", "mode": "test", "key": "ck_test_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" } } ``` > Errors use the standard [envelope](/errors.md). A repeat onboarding for the same user returns `409 org_already_exists`. ## Submit KYB `POST /v1/kyb` — Scope: `organization:write` Submits your business verification and moves the organization to `kyb_status: pending`. Required to unlock live keys and live payouts. You can't resubmit while already `verified` or under review — that returns `409 kyb_not_resubmittable`. ### Body parameters | Field | Type | Required | Description | | --- | --- | --- | --- | | `legal_name` | string | Yes | Legal name. | | `registration_no` | string | Yes | Business registration number. | | `incorporation_country` | string | Yes | ISO-3166 alpha-2. | | `beneficial_owners` | object[] | Yes | At least one UBO. Total ownership can't exceed 100%. Sub-fields below. | | `beneficial_owners[].name` | string | Yes | Full name. | | `beneficial_owners[].dob` | string | Yes | Date of birth, `YYYY-MM-DD`. | | `beneficial_owners[].ownership_pct` | number | Yes | Ownership percentage, greater than 0 and at most 100. | | `beneficial_owners[].country` | string | Yes | ISO-3166 alpha-2. | | `documents` | object[] | Yes | At least one document. Sub-fields below. | | `documents[].type` | string | Yes | Document type, e.g. `incorporation`. | | `documents[].reference` | string | Yes | A URL or reference to the document. | ### Request ```bash curl https://api.caiboglobal.com/v1/kyb \ -H "X-API-Key: $CAIBO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "legal_name": "Acme Inc", "registration_no": "123456789", "incorporation_country": "CA", "beneficial_owners": [ { "name": "Jane Doe", "dob": "1980-01-01", "ownership_pct": 60, "country": "CA" }, { "name": "John Roe", "dob": "1975-05-05", "ownership_pct": 40, "country": "US" } ], "documents": [ { "type": "incorporation", "reference": "https://docs.example/incorp.pdf" } ] }' ``` ### Response — 201 Created ```json { "id": "kyb_1a2b3c4d", "object": "kyb_submission", "org_id": "org_9f8e7d6c", "status": "pending", "legal_name": "Acme Inc", "registration_no": "123456789", "incorporation_country": "CA", "beneficial_owners": [ { "name": "Jane Doe", "dob": "1980-01-01", "ownership_pct": 60, "country": "CA" }, { "name": "John Roe", "dob": "1975-05-05", "ownership_pct": 40, "country": "US" } ], "documents": [ { "type": "incorporation", "reference": "https://docs.example/incorp.pdf" } ], "created_at": "2026-07-23T14:03:00Z" } ``` ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 400 | invalid_request_error | bad_request | Missing/invalid fields: bad country code, an owner with ownership ≤ 0 or > 100, total ownership > 100%, no owners, or no documents. | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `organization:write`. | | 409 | invalid_request_error | kyb_not_resubmittable | The organization is already verified or has a submission under review. | | 429 | rate_limit_error | rate_limited | Too many requests. | ## Get KYB status `GET /v1/kyb` — Scope: `organization:read` Returns your latest KYB submission, or `404 kyb_not_found` if you haven't submitted one. ```bash curl https://api.caiboglobal.com/v1/kyb \ -H "X-API-Key: $CAIBO_API_KEY" ``` ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `organization:read`. | | 404 | invalid_request_error | kyb_not_found | No submission exists yet. | --- # Webhook events Every payout event, its payload, and when it fires. Register for these with the Webhooks API. These are the events Caibo delivers to your registered [webhook endpoints](/webhooks-api.md). Each delivery is a `POST` whose body is a JSON payout event, signed with `Caibo-Signature` and tagged with a `Caibo-Event` header. See the [Webhooks guide](/webhooks.md) for verification, retries, and idempotency. ## Payload shape All events share the same shape. `tx_hash` is present only once the payout is completed. | Field | Type | Description | | --- | --- | --- | | `event` | string | The event name, e.g. `payout.completed`. | | `object` | string | Always `"payout"`. | | `id` | string | The payout id. | | `status` | string | The payout status at the time of the event. | | `amount` | string | Decimal amount. | | `currency` | string | Asset, e.g. `USDC`. | | `mode` | string | `test` or `live`. | | `destination` | object | `{ network, address }`. | | `tx_hash` | string | Present on `payout.completed`. | | `created_at` | string | ISO 8601 UTC. | ## `payout.created` Fires when a payout has been created and accepted. Its status is `pending`. ```json { "event": "payout.created", "object": "payout", "id": "po_9f8e7d6c5b4a", "status": "pending", "amount": "25.00", "currency": "USDC", "mode": "live", "destination": { "network": "polygon", "address": "0x1234...5678" }, "created_at": "2026-07-23T14:03:00Z" } ``` ## `payout.processing` Fires when the payout is being signed and broadcast on-chain. Status is `processing`. ```json { "event": "payout.processing", "object": "payout", "id": "po_9f8e7d6c5b4a", "status": "processing", "amount": "25.00", "currency": "USDC", "mode": "live", "destination": { "network": "polygon", "address": "0x1234...5678" }, "created_at": "2026-07-23T14:03:00Z" } ``` ## `payout.completed` Fires when the payout has settled on-chain. Status is `completed` and `tx_hash` is set. This is the definitive "the money has left" signal. ```json { "event": "payout.completed", "object": "payout", "id": "po_9f8e7d6c5b4a", "status": "completed", "amount": "25.00", "currency": "USDC", "mode": "live", "destination": { "network": "polygon", "address": "0x1234...5678" }, "tx_hash": "0x8f3a9c2b1d4e5f60...", "created_at": "2026-07-23T14:03:00Z" } ``` ## `payout.failed` Fires when the on-chain broadcast failed. Status is `failed` and the amount has been **refunded** to your balance. Failures are typically transient; you may create a new payout (with a new [idempotency key](/idempotency.md)). ```json { "event": "payout.failed", "object": "payout", "id": "po_9f8e7d6c5b4a", "status": "failed", "amount": "25.00", "currency": "USDC", "mode": "live", "destination": { "network": "polygon", "address": "0x1234...5678" }, "created_at": "2026-07-23T14:03:00Z" } ``` ## `payout.blocked` Fires when screening blocked the destination. Status is `blocked` and **no funds moved**. Route these to your compliance process — retrying the same destination will block again. See [Compliance & screening](/compliance.md). ```json { "event": "payout.blocked", "object": "payout", "id": "po_9f8e7d6c5b4a", "status": "blocked", "amount": "25.00", "currency": "USDC", "mode": "live", "destination": { "network": "polygon", "address": "0x1234...5678" }, "created_at": "2026-07-23T14:03:00Z" } ``` > The webhook payload is a snapshot. For anything critical, re-fetch the payout with [GET /v1/payouts/{id}](/payouts-api.md) to confirm its current state (including the safe screening summary on a blocked payout). --- # Flow of funds How money moves through Caibo end to end — pay-in, settlement, and payout. > **Fiat rails (Interac, ACH/EFT) — rolling out.** Stablecoin payouts are live today. The fiat pay-in ([Collections](/collections-api.md)) and local-currency payout ([Fiat Payouts](/fiat-payouts-api.md)) rails are being enabled progressively through regulated collection and licensed payout partners. This page describes the model; the API contracts are stable and ready to build against. Caibo connects a fiat pay-in on one side to a fiat payout on the other, settled over stablecoin rails in between. Your organization's **balance** is the hub: collections settle into it, and payouts draw from it. ## The end-to-end flow ```text Customer (Canada) Caibo Beneficiary ──────────────── ───── ─────────── 1. Interac e-Transfer ─▶ 2. Collection received ─▶ (pay-in) by regulated provider 3. Settlement to USDC credited to your balance 4. Fiat payout drawn from balance, via licensed ─▶ 5. Local currency payout partner credited to account ``` 1. **Pay-in — Interac e-Transfer.** Your customer in Canada funds a [collection](/collections-api.md) with an Interac e-Transfer (ACH/EFT are part of the same rail). You create the collection and show them the returned payment instructions. 2. **Collection.** A regulated collection provider receives the funds. The collection moves `awaiting_payment → received`. 3. **Settlement — USDC.** The received fiat is settled to USDC and credited to your organization's [balance](/balances-rates-api.md). The collection reaches `settled` and fires `collection.settled`. Stablecoin is the settlement rail — not a product you have to touch. 4. **Payout — local currency.** You create a [fiat payout](/fiat-payouts-api.md) to a beneficiary. It is screened, funded from your USDC balance, and handed to a licensed payout partner in the destination country. 5. **Delivery.** The partner credits the beneficiary's local account. The fiat payout reaches `completed` and fires `fiat_payout.completed`. ## Where stablecoin fits Stablecoin (USDC) is the **settlement layer** between pay-in and payout. It is also directly usable: you can send stablecoin [payouts](/payouts-api.md) from the same balance to an on-chain address. So one balance funds two kinds of outbound movement: | Outbound | Endpoint | Destination | | --- | --- | --- | | Local currency | [`POST /v1/fiat-payouts`](/fiat-payouts-api.md) | A beneficiary's bank account, via payout partner. | | Stablecoin | [`POST /v1/payouts`](/payouts-api.md) | An on-chain address on a supported network. | ## Compliance across the flow Every leg is subject to the same controls described in [Compliance & screening](/compliance.md): KYB before live access, sanctions/watchlist screening before funds move, transaction monitoring, and a double-entry ledger behind every movement. Collections and fiat payouts are screened just like stablecoin payouts. ## What is live vs. rolling out | Capability | Status | | --- | --- | | Stablecoin payouts (`/v1/payouts`) | Live | | Balances & rates, webhooks, KYB, sandbox | Live | | Fiat pay-in — Collections (`/v1/collections`) | Rolling out | | Local-currency payout (`/v1/fiat-payouts`) | Rolling out | Build against the full contract now; the fiat rails return `503 provider_unavailable` until switched on for your organization. --- # Collections API Initiate and track fiat pay-ins that fund your Caibo balance. > **Rolling out.** Fiat rails (Interac e-Transfer, ACH/EFT) are being enabled progressively. In production these endpoints return `503 provider_unavailable` until your organization's collection rail is switched on. The contract below is stable — integrate against it now. See the [flow of funds](/flow-of-funds.md) for how a collection settles into your balance. A **collection** is an inbound fiat payment from your customer. You create it, hand the customer the returned payment instructions (an Interac e-Transfer target, or an ACH/EFT virtual account), and Caibo's regulated collection provider receives the funds and settles them to USDC on your organization's [balance](/balances-rates-api.md). That balance then funds outbound [payouts](/payouts-api.md) and [fiat payouts](/fiat-payouts-api.md). ## Create a collection `POST /v1/collections` — Scope: `collections:write` Creates a collection and returns the payment instructions to present to your customer. Interac e-Transfer is the first supported method for Canadian (`CAD`) pay-ins; ACH/EFT are part of the same rail. ### Headers | Header | Required | Description | | --- | --- | --- | | `X-API-Key` | Yes | Your API key. | | `Content-Type` | Yes | `application/json` | | `Idempotency-Key` | **Yes** | A unique value per logical collection. Retries with the same key return the original collection. See [Idempotency](/idempotency.md). | ### Body parameters | Field | Type | Required | Description | | --- | --- | --- | --- | | `amount` | string | Yes | Decimal amount > 0 to collect, e.g. `"500.00"`. | | `currency` | string | Yes | Source fiat currency. `CAD` for Interac e-Transfer. | | `method` | string | Yes | One of `interac_etransfer`, `ach`, `eft`. Must be enabled for your organization. | | `customer_reference` | string | No | Your own identifier for the paying customer. Echoed back. | | `metadata` | object | No | Arbitrary key/value pairs echoed back on the collection. | ### Request ```bash curl https://api.caiboglobal.com/v1/collections \ -H "X-API-Key: $CAIBO_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 3d9c8b7a-1e2f-4a5b-8c9d-0e1f2a3b4c5d" \ -d '{ "amount": "500.00", "currency": "CAD", "method": "interac_etransfer", "customer_reference": "cust_88", "metadata": { "invoice_id": "INV-2043" } }' ``` ### Response — 201 Created Returns the collection with `payment_instructions` for the chosen method. The shape of `payment_instructions` depends on `method`: an Interac e-Transfer target for `interac_etransfer`, or a virtual account (institution/transit/account or routing) for `ach`/`eft`. ```json { "id": "col_7a1b2c3d4e5f", "object": "collection", "status": "awaiting_payment", "mode": "live", "amount": "500.00", "currency": "CAD", "method": "interac_etransfer", "payment_instructions": { "type": "interac_etransfer", "send_to": "collect@caiboglobal.com", "reference": "CAI-7A1B2C3D", "auto_deposit": true }, "customer_reference": "cust_88", "metadata": { "invoice_id": "INV-2043" }, "expires_at": "2026-07-24T14:03:00Z", "created_at": "2026-07-23T14:03:00Z" } ``` Your customer sends an Interac e-Transfer to `send_to` and includes `reference` in the message field. Auto-deposit is enabled, so no security question is required. The collection stays `awaiting_payment` until the provider receives the funds. ### Behavior - **Instructions are per-collection.** Always show the `reference` from the response — it's how an incoming transfer is matched to this collection. - **Settlement funds your balance.** Once received, the pay-in is settled to USDC and credited to your [balance](/balances-rates-api.md). Track this with the `collection.settled` webhook. - **Idempotent.** Reusing the `Idempotency-Key` with the same body returns the original collection; with a different body it returns `409 idempotency_conflict`. - **Expiry.** A collection that isn't paid before `expires_at` moves to `expired`. Create a new one to retry. ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 400 | invalid_request_error | bad_request | Missing `Idempotency-Key`, malformed body, or an invalid `amount`. | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `collections:write`. | | 403 | compliance_error | kyb_required | Live mode with an unverified business. | | 409 | invalid_request_error | idempotency_conflict | The idempotency key was reused with a different request. | | 422 | invalid_request_error | unsupported_method | The `method` or `currency` is not enabled for your organization. | | 429 | rate_limit_error | rate_limited | Too many requests. Back off and retry. | | 503 | api_error | provider_unavailable | The collection rail is not yet enabled for your organization (rolling out) or is temporarily unavailable. | ## Collection state machine A collection has a `status`. It starts at `awaiting_payment` and ends in a terminal state once it settles, fails, or expires. ```text awaiting_payment ──▶ received ──▶ settled (USDC credited to your balance) │ │ ▼ ▼ expired failed │ (cancel before payment) ──▶ cancelled ``` | Status | Meaning | Terminal? | | --- | --- | --- | | `awaiting_payment` | Created; waiting for the customer to send funds. | No | | `received` | The provider has received the customer's payment. | No | | `settled` | Settled to USDC and credited to your balance. | **Yes** | | `failed` | The pay-in failed or was returned by the provider. | **Yes** | | `expired` | No payment arrived before `expires_at`. | **Yes** | | `cancelled` | You cancelled the collection before payment arrived. | **Yes** | ## Webhooks Register for these on your [webhook endpoints](/webhooks-api.md). Each delivery is signed with `Caibo-Signature`; verify it as described in the [Webhooks guide](/webhooks.md). | Event | Fires when | Status | | --- | --- | --- | | `collection.received` | The provider receives the customer's payment. | `received` | | `collection.settled` | Funds are settled to USDC and credited to your balance. | `settled` | | `collection.failed` | The pay-in failed or was returned. | `failed` | | `collection.expired` | No payment arrived before expiry. | `expired` | ```json { "event": "collection.settled", "object": "collection", "id": "col_7a1b2c3d4e5f", "status": "settled", "amount": "500.00", "currency": "CAD", "mode": "live", "method": "interac_etransfer", "settled_asset": "USDC", "settled_amount": "366.00", "created_at": "2026-07-23T14:03:00Z" } ``` > The webhook payload is a snapshot. For anything critical, re-fetch the collection with [GET /v1/collections/{id}](/collections-api.md) to confirm its current state. ## Retrieve a collection `GET /v1/collections/{id}` — Scope: `collections:read` Fetches a single collection by id. Returns `404 resource_missing` if it doesn't exist or isn't yours. ```bash curl https://api.caiboglobal.com/v1/collections/col_7a1b2c3d4e5f \ -H "X-API-Key: $CAIBO_API_KEY" ``` ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `collections:read`. | | 404 | invalid_request_error | resource_missing | No such collection for your organization. | ## List collections `GET /v1/collections` — Scope: `collections:read` Returns your organization's collections, newest first, cursor-paginated. ### Query parameters | Parameter | Type | Description | | --- | --- | --- | | `limit` | integer | Page size, 1–100. Defaults to 25. | | `starting_after` | string | A collection id; returns the page after it. See [Pagination](/pagination.md). | | `status` | string | Filter by a single status, e.g. `settled`. | ### Request ```bash curl "https://api.caiboglobal.com/v1/collections?limit=25&status=settled" \ -H "X-API-Key: $CAIBO_API_KEY" ``` ### Response — 200 OK ```json { "object": "list", "data": [ { "id": "col_7a1b2c3d4e5f", "object": "collection", "status": "settled", "mode": "live", "amount": "500.00", "currency": "CAD", "method": "interac_etransfer", "created_at": "2026-07-23T14:03:00Z" } ], "has_more": true, "next_cursor": "col_7a1b2c3d4e5f" } ``` ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `collections:read`. | | 429 | rate_limit_error | rate_limited | Too many requests. | --- # Fiat Payouts API Disburse local currency to a beneficiary's bank account, funded from your balance. > **Rolling out.** Local-currency payout rails are being enabled progressively through licensed payout partners. In production these endpoints return `503 provider_unavailable` until your organization's payout corridor is switched on. The contract below is stable — integrate against it now. See the [flow of funds](/flow-of-funds.md). A **fiat payout** sends local currency to a beneficiary through a licensed payout partner. It is funded from your organization's USDC [balance](/balances-rates-api.md) — the same balance your [collections](/collections-api.md) settle into. Caibo screens the payout, converts from your balance, and the partner credits the beneficiary's local account. The supported destination countries and the beneficiary fields each one requires are returned by the [catalog](/catalog-api.md) (`GET /v1/countries`, `GET /v1/payment-methods`) — they are not hard-coded here. ## Create a fiat payout `POST /v1/fiat-payouts` — Scope: `payouts:write` Creates a fiat payout and starts its lifecycle: it is screened, then (if it clears and is within the auto-approval limit) sent to the payout partner for delivery. ### Headers | Header | Required | Description | | --- | --- | --- | | `X-API-Key` | Yes | Your API key. | | `Content-Type` | Yes | `application/json` | | `Idempotency-Key` | **Yes** | A unique value per logical payout. Retries with the same key return the original payout. See [Idempotency](/idempotency.md). | ### Body parameters | Field | Type | Required | Description | | --- | --- | --- | --- | | `amount` | string | Yes | Decimal amount > 0 in the destination `currency`, e.g. `"250000.00"`. | | `currency` | string | Yes | Destination local currency, ISO 4217, e.g. `COP`. Must be enabled for `country`. | | `country` | string | Yes | Destination country, ISO-3166 alpha-2, e.g. `CO`. Must be in `GET /v1/countries`. | | `beneficiary` | object | Yes | Who receives the funds. See sub-fields below. | | `beneficiary.name` | string | Yes | Full legal name of the beneficiary. | | `beneficiary.account` | object | Yes | Local account details. Required keys vary by country/method — read them from `GET /v1/payment-methods`. | | `metadata` | object | No | Arbitrary key/value pairs echoed back on the payout. | > `beneficiary.account` is intentionally open: a country may require `account_number` + `institution` + `document_id`, another an IBAN or a CLABE. Query the catalog for the exact required fields per country before you build the form. ### Request ```bash curl https://api.caiboglobal.com/v1/fiat-payouts \ -H "X-API-Key: $CAIBO_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 8f7e6d5c-4b3a-2c1d-0e9f-8a7b6c5d4e3f" \ -d '{ "amount": "250000.00", "currency": "COP", "country": "CO", "beneficiary": { "name": "Maria Gomez", "account": { "type": "bank_account", "institution": "007", "account_number": "1234567890", "document_id": "CC-79000000" } }, "metadata": { "invoice_id": "INV-2043" } }' ``` The example country/currency above is illustrative. Use the values returned by `GET /v1/countries` for corridors enabled on your organization. ### Response — 201 Created Returns the fiat payout. `beneficiary.account.account_number` is masked in responses. If the amount exceeds your auto-approval limit, the status is `pending_approval` and the HTTP status is **202 Accepted** instead of 201. ```json { "id": "fpo_9a8b7c6d5e4f", "object": "fiat_payout", "status": "processing", "mode": "live", "amount": "250000.00", "currency": "COP", "country": "CO", "beneficiary": { "name": "Maria Gomez", "account": { "type": "bank_account", "institution": "007", "account_number": "******7890" } }, "screening_status": "clear", "tracking": { "reference": "CAI-FP-9A8B7C6D", "estimated_settlement": "2026-07-23T18:00:00Z" }, "metadata": { "invoice_id": "INV-2043" }, "created_at": "2026-07-23T14:03:00Z" } ``` ### Behavior - **Funded from your balance.** The payout draws from your organization's USDC balance (the settlement rail). If the balance is below the required amount, it returns `402 insufficient_balance`. - **Screening runs first, in-line.** The beneficiary is screened before anything is sent. A flagged beneficiary yields `403 screening_blocked` and a payout with status `blocked`; no funds move. See [Compliance & screening](/compliance.md). - **Auto-approval.** Amounts within your organization's auto-approval limit send immediately; larger ones return `202` with status `pending_approval` and wait for a manual approval. - **Idempotent.** Reusing the `Idempotency-Key` with the same body returns the original payout; with a different body it returns `409 idempotency_conflict`. - **Webhooks.** A successful create fires `fiat_payout.processing` and finally `fiat_payout.completed` (or `fiat_payout.failed` with a refund). A blocked payout fires `fiat_payout.blocked`. ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 400 | invalid_request_error | bad_request | Missing `Idempotency-Key`, malformed body, or an invalid `amount`. | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `payouts:write`. | | 403 | compliance_error | kyb_required | Live mode with an unverified business. | | 403 | compliance_error | screening_blocked | The beneficiary was blocked by screening. | | 403 | compliance_error | limit_exceeded | The payout would breach a per-payout, daily, or monthly limit. | | 402 | invalid_request_error | insufficient_balance | Balance below the required amount. | | 409 | invalid_request_error | idempotency_conflict | The idempotency key was reused with a different request. | | 422 | invalid_request_error | unsupported_country | The `country`/`currency` corridor is not enabled, or required `beneficiary` fields are missing. | | 429 | rate_limit_error | rate_limited | Too many requests. Back off and retry. | | 503 | api_error | provider_unavailable | The payout corridor is not yet enabled for your organization (rolling out) or is temporarily unavailable. | ## Fiat payout state machine A fiat payout moves through the same shape as a stablecoin [payout](/payout-lifecycle.md): screened, then delivered. ```text pending ──▶ screening ──▶ processing ──▶ completed (beneficiary credited) │ │ ▼ ▼ blocked failed (funds refunded to your balance) (cancel is possible up to — but not during — processing) ──▶ cancelled ``` | Status | Meaning | Terminal? | | --- | --- | --- | | `pending` | Created and accepted; about to be screened. | No | | `screening` | The beneficiary is being screened (sanctions + blacklist). | No | | `processing` | Sent to the payout partner for delivery. | No | | `completed` | The beneficiary's local account was credited. | **Yes** | | `blocked` | Screening blocked the beneficiary. The payout was *not* sent. | **Yes** | | `failed` | Delivery failed or was returned. Funds were **refunded** to your balance. | **Yes** | | `cancelled` | You cancelled it before it started processing. | **Yes** | | `pending_approval` | Cleared screening but exceeds the org's auto-approval limit; held for manual approval. | No | ## Webhooks Register for these on your [webhook endpoints](/webhooks-api.md). Each delivery is signed with `Caibo-Signature`; verify it as described in the [Webhooks guide](/webhooks.md). | Event | Fires when | Status | | --- | --- | --- | | `fiat_payout.processing` | The payout is sent to the payout partner. | `processing` | | `fiat_payout.completed` | The beneficiary's account has been credited. | `completed` | | `fiat_payout.failed` | Delivery failed or was returned (funds refunded). | `failed` | | `fiat_payout.blocked` | Screening blocked the beneficiary (no funds moved). | `blocked` | ```json { "event": "fiat_payout.completed", "object": "fiat_payout", "id": "fpo_9a8b7c6d5e4f", "status": "completed", "amount": "250000.00", "currency": "COP", "country": "CO", "mode": "live", "created_at": "2026-07-23T14:03:00Z" } ``` > The webhook payload is a snapshot. For anything critical, re-fetch the fiat payout with [GET /v1/fiat-payouts/{id}](/fiat-payouts-api.md) to confirm its current state. ## Retrieve a fiat payout `GET /v1/fiat-payouts/{id}` — Scope: `payouts:read` Fetches a single fiat payout by id. Returns `404 resource_missing` if it doesn't exist or isn't yours. ```bash curl https://api.caiboglobal.com/v1/fiat-payouts/fpo_9a8b7c6d5e4f \ -H "X-API-Key: $CAIBO_API_KEY" ``` ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `payouts:read`. | | 404 | invalid_request_error | resource_missing | No such fiat payout for your organization. | ## List fiat payouts `GET /v1/fiat-payouts` — Scope: `payouts:read` Returns your organization's fiat payouts, newest first, cursor-paginated. ### Query parameters | Parameter | Type | Description | | --- | --- | --- | | `limit` | integer | Page size, 1–100. Defaults to 25. | | `starting_after` | string | A fiat payout id; returns the page after it. See [Pagination](/pagination.md). | | `status` | string | Filter by a single status, e.g. `completed`. | | `country` | string | Filter by destination country, ISO-3166 alpha-2. | ### Request ```bash curl "https://api.caiboglobal.com/v1/fiat-payouts?limit=25&status=completed" \ -H "X-API-Key: $CAIBO_API_KEY" ``` ### Response — 200 OK ```json { "object": "list", "data": [ { "id": "fpo_9a8b7c6d5e4f", "object": "fiat_payout", "status": "completed", "mode": "live", "amount": "250000.00", "currency": "COP", "country": "CO", "created_at": "2026-07-23T14:03:00Z" } ], "has_more": true, "next_cursor": "fpo_9a8b7c6d5e4f" } ``` ### Errors | HTTP | type | code | When | | --- | --- | --- | --- | | 401 | authentication_error | api_key_missing / api_key_invalid | No key, or an unknown/revoked key. | | 403 | permission_error | scope_insufficient | The key lacks `payouts:read`. | | 429 | rate_limit_error | rate_limited | Too many requests. |