Introduction
Execution-only, custody-free, multi-rail payouts to humans.
The 8x Payout Gateway moves money to people across global rails. You
integrate it the way you'd integrate Stripe: create a payee → send them to a
hosted onboarding link → get a payee.updated webhook when they're payable →
call POST /payouts.
What the gateway is (and is not)
- Execution-only & custody-free — it owns payout identity, multi-rail
routing, hosted onboarding, execution, and the transaction log. For
direct
POST /payoutsthe gateway holds no balance and tracks no owed ledger — you decide who and how much, exactly as with Stripe. It also offers an optional owed/IOU ledger (POST /disbursements+withdraw) for a pull/wallet model: YOU decide what is earned and push the owed rows; the gateway then holds them asoweduntil an explicit withdraw. It never independently judges or computes what a payee is owed — yourexternalRefstays the source-of-truth ledger id. - Funding is rail/platform funding, never a customer balance. Some rails
require the company operating the gateway to fund a platform account or
rail wallet (Stripe platform balance, Tipalti payer balance) before payouts
settle. Prepaid clients may also fund a wallet: an append-only record of
money received from you, whose available balance is computed — deposits
minus in-flight/paid payouts — and gates release (
heldwithinsufficient_fundswhen short). There is no stored balance, no interest, no customer money-holding product. Underfunding yields a retryable failure or a held payout — never a silent re-route. - The caller decides WHO and HOW MUCH; the gateway decides WHERE. A payout
request carries
payeeId+amountCents— never a bank account or destination. Money can only land on the payee's KYC-locked method, resolved server-side. No single compromised surface can both inflate an amount and redirect it.
Authentication (every request)
Every request is authenticated (which client), signed (tamper- and replay-proof), and authorized (scope + ownership). Send three things:
Authorization: Bearer <API key>— identifies your client. The gateway stores onlysha256(key).x-8x-timestamp— Unix seconds when you signed the request.x-8x-signature—HMAC-SHA256(signingSecret, "{timestamp}.{rawBody}"), hex-encoded. For aGETthe body is empty, so you sign"{timestamp}.".
The timestamp must be within a 5-minute window (300s) of the gateway clock,
or the request is rejected as stale. Mutations (POST) are additionally
replay-protected: a duplicate signature inside the window is rejected. Idempotent
GETs are not replay-checked (so a polling client is never spuriously 401'd).
The official @8x-payout/sdk PayoutClient performs the signing for you — see
the Quickstart.
Authorization (scopes)
Each client carries a set of scopes; every route asserts the scope it needs and
fails closed (403) if it's missing. A read-only integration can be issued
a key whose client holds only the *:read scopes — it cannot move money.
| Scope | Grants |
|---|---|
payouts:write | POST /payouts, POST /payouts/batch †, POST /payouts/{id}/retry, POST /payouts/{id}/release |
payouts:read | GET /payouts, GET /payouts/{id} |
payees:write | POST /payees, POST /payees/{id}/methods |
payees:read | GET /payees, GET /payees/{id}, GET /payees/{id}/readiness, GET /payees/{id}/balance |
onboarding:write | POST /payees/{id}/onboarding-link |
portal:write | POST /payees/{id}/portal-link |
disbursements:write | POST /disbursements, POST /disbursements/recovery-link, POST /payees/{id}/withdraw |
† Batch items that resolve-or-create a payee by email additionally require payees:write.
Tenant isolation
A client can only see and act on its own payees and transactions. A payee or
transaction owned by another tenant is reported as 404 (existence never leaks
across tenants).
Test mode (no real money)
Test mode is a property of your client, not a separate host — the base URL is
the same. A client in test mode routes every payout and onboarding through
the mock provider (settles synchronously, no real KYC, no real money), so you
can run this entire contract end to end before going live. Flip to live when
ready. See the Quickstart for a zero-to-first-payout walkthrough.
Money & values
- Money is always integer cents (
amountCents). The gateway converts to a rail's units at the execution seam, never on the wire. currencyis ISO 4217 uppercase (e.g.USD).- Timestamps are ISO 8601 strings (UTC).
Error & held-reason catalog
For a held or failed payout, statusReason (on GET /payouts/{id}) leads with
a stable snake_case code from the closed set below. This same catalog is the
authoritative source for the statusReason token and the stable error code; the
withdraw, batch, and disbursement per-row error enums reference it.
| code | trigger | kind | self-releases? | remediation |
|---|---|---|---|---|
missing_email | payee has no email | held | no | upsert payee with email, then release |
missing_name | non-test payee has no legal name | held | no | supply payee name, then release |
not_payable | resolved method not payable | held | no | complete onboarding/KYC |
rail_not_allowed | rail outside client allow-list | held | no | contact 8x to widen allow-list |
cap_exceeded_daily | daily cap hit | held | on window reset | wait for the daily window, then release |
cap_exceeded_single | single-payout cap hit | held | no | split the payout or request a higher cap |
insufficient_funds | rail/wallet underfunded | held | on funding | fund the rail/wallet, then release |
below_min_payout | amount under economical floor | held | yes — accumulated-floor sweep | wait for accumulation, or force-release (bypasses floor) |
no_rail | no supported rail for country | held | no | fix country / choose a configured rail |
route_blocked | routing explicitly blocked | held | no | choose an allowed rail |
missing_country | no country on payee | held | no | set payee country |
provider_not_supported | provider can't serve country | held | no | choose a supported rail |
provider_not_configured | provider not wired for deployment | held | no | contact 8x |
country_not_supported_by_provider | country/provider mismatch | held | no | choose a supported rail |
unsupported_country | legacy: routing no longer produces it since Grade became the default rail; may remain on older held payouts | held | no | release |
provider_<name>_not_implemented | routed rail not yet wired | failed (permanent, 400) | no | contact 8x |
authorized_payout_missing_provider | authorized payout lost its provider | failed | no | contact 8x |
below_min_payout is a benign, self-releasing hold (an accumulated-floor sweep
releases it once enough accrues).
Payout status lifecycle
held is caller-remediable — not a dead end. Read statusReason, clear the
blocker (see the catalog above), then POST /payouts/{id}/release.
| status | meaning | terminal? | what you do | endpoint |
|---|---|---|---|---|
requested | accepted, not yet routed | no | wait | — |
authorized | routed, payee payable | no | wait | — |
submitted | sent to the provider, awaiting result | no | wait (do NOT retry) | — |
held | blocked by a statusReason | no | read statusReason, clear the blocker, release | POST /payouts/{id}/release |
failed | attempt failed | no (transient) / yes (permanent) | retry if failureKind=transient | POST /payouts/{id}/retry |
paid | funds released to rail | terminal | key off settledAt for bank arrival | — |
returned | bounced after settlement | terminal | reverse in your ledger; read restoredCents | — |
Legal transitions: requested→authorized→submitted→paid;
held→(release) and failed→(retry) re-enter the flow; paid→returned.
Status versions
Your client's status version picks the words a payout's status uses on the
payout.status webhook and on every payout response. The webhook and REST are
versioned together. Version 1 is the default and uses the lifecycle above. Version 2
is opt-in per client and set by 8x ops. It uses owed, sent, awaiting_withdraw,
paid, failed, returned, held (see the PayoutStatusV2 schema). requested
and authorized become sent. submitted becomes sent on custodial rails
(stripe, tipalti, and mock in test mode) and awaiting_withdraw on custody-free
rails (grade, sideshift); a Grade payout reads sent once the creator has claimed
it, a SideShift payout stays awaiting_withdraw until it settles as paid. The other
words stay the same. owed never
appears on a payout today because it lives on disbursements. The status filter
on GET /payouts takes version 1 words only.
Rate limit
600 requests per minute per client, fixed 60-second window (configurable per
deployment). No Retry-After header is returned — back off to the next minute
boundary.
Versioning
/api/v1 is stable. Within a version, changes are additive-only; anything breaking
ships under a new version path. Deprecations are announced before removal.
Servers
https://8x-payout.com— Production gateway. Your exact base URL is provided when your client is provisioned.http://localhost:3007— Local development (the gateway's default dev port).
Authentication
Every request carries all of the following (bearerAuth & x8xTimestamp & x8xSignature).
bearerAuth http · bearer
Your client API key. Authorization: Bearer <API key>. The gateway stores
only sha256(key); keys are rotatable and revocable.
x8xTimestamp apiKey · header: x-8x-timestamp
Unix time (seconds) at which the request was signed. Must be within 300s of gateway time.
x8xSignature apiKey · header: x-8x-signature
Hex HMAC-SHA256(signingSecret, "{timestamp}.{rawBody}") over the exact raw
request body (empty string for GET). Defeats tampering and replay.