Skip to main content
Work through this before your first live payout. Most of it is about what happens when something goes wrong, because that is the part integrations usually skip.

Prerequisites

1

KYB approved

Your compliance profile reads APPROVED. A payout attempt returns 403 COMPLIANCE_NOT_APPROVED until it does.
2

A refund destination per settlement currency

GET /v1/refund-destinations returns one for every currency you will settle in. Confirm the bank rail shows is_verified: true; for crypto, re-check the address and network by eye — nothing else can.
3

A live key with the right scopes

Separate from your test key, and carrying only the scopes each system needs. See Get access.
4

A registered webhook endpoint

GET /v1/webhooks/config returns your URL and has_secret: true. Without a secret, deliveries arrive unsigned.

Correctness

The key must be stored on your own record before the API call, so a retry after a crash reuses it. A key generated inside the request scope is lost with the process that made it — which is precisely the case it exists to protect.See Idempotency.
Take settlement_amount, total_fee_amount, and exchange_rate from the payout or the quote. Never derive them from a rate and a fee percentage — a figure that disagrees with ours becomes a reconciliation dispute.
Your database columns should mirror ours. A payout spans up to three currencies; storing a bare amount guarantees someone eventually reads it in the wrong denomination.
You call GET /v1/banks rather than shipping a hardcoded list. Codes are set upstream and change without a version bump on our side.
You pass the destination_account_name returned by /v1/banks/resolve, not what your user typed.

Webhook handling

Before any JSON parsing, with a constant-time comparison, and with a timestamp tolerance to block replay. Unverified requests are rejected.
Return 2xx within 10 seconds. Real work goes on a queue. A slow inline handler times out and produces duplicate deliveries.
Not on created_at, not on arrival order. The same event can arrive more than once.
New types ship without a version bump. A handler that throws on the unfamiliar starts burning retries the day one appears.
Treat data.status as the truth and ignore an event that would regress a record you have already advanced.
refund.* payloads have "object": "payout" and data.id is the payout ID. Fetch amounts from GET /v1/payouts/{id}/refunds. See Webhook events.

Failure paths you must handle

These are the ones that cost money when they are missed.
There is no cancel endpoint and no webhook replay endpoint. An unwanted payout lapses to EXPIRED on its own; missed events are recovered by reading state, not by asking us to resend. Build both recovery paths before you need them.

Reconciliation

Do not rely on webhooks alone as your source of truth. Run a periodic sweep that reads state back and repairs anything your handler missed.
The status_timeline on a payout is the authoritative history — every transition, in order, with the reason. Reconcile against it rather than against webhook arrival times.

Alerting

Operational limits

Read expires_at off the instruction and the quote rather than hardcoding the first two — they are configurable per account.

Last checks

1

Run one real payout end to end

Small amount, real beneficiary, on the corridor you will use most. Watch every webhook arrive and confirm your records match GET /v1/payouts/{id}.
2

Force a failure deliberately

Let an instruction expire without paying it, and confirm your system handles payout.expired by releasing the order rather than retrying forever.
3

Confirm no key is in client code

Grep your frontend bundles and repos for sk_live. The key authorises payouts.
4

Verify request IDs reach your logs

Send X-Request-Id and confirm it lands next to your own record. It is the first thing support will ask for.