reference
Payouts — Engineering Reference
Payouts
Canonical engineering reference for the bulk-payout surface. Product-level status lives at ../03-Domains#Payouts; risks at ../08-Risks and Drawbacks#Payouts.
This page was referenced from six documents (and
CLAUDE.md) before it existed. It is reconstructed from the implementation onmasteras of 2026-08-09 — treat the code as authoritative where they disagree.
Surfaces
| Surface | Path |
|---|---|
| Public REST | POST /api/v1/payouts, GET /api/v1/payouts, GET /api/v1/payouts/{batchId}, POST /api/v1/payouts/{batchId}/execute, POST /api/v1/payouts/risk-score |
| Merchant UI | /my/payouts, /my/payouts/new, /my/payouts/[id] |
| Merchant API console | /api-payouts |
| Admin | /admin/payouts, /admin/payouts/[id] |
| Implementation | lib/payouts/{engine,execute,execute-rows,hold,limits,network,address-format,csv,auth}.ts |
Authentication & gating
- Bearer token or
x-api-key, hashed sha256 againstapi_keys(lib/payouts/auth.ts). Keys carry bothrevoked_atandexpires_at. - Per-API-key REST rate limiting (
lib/api/rest-limits.ts) backed by therate_limitstable. - Requires the
payouts_enabledfeature flag on the user (403 feature-disabled). - Requires a passing KYC status (
403 kyc-required). - Blocked by an active per-asset outbound halt (
503 halted) — see ../03-Domains#Reconciliation & Outbound Halts.
The bearer token is an
api_keysrow, not a short-lived minted token. See ../08-Risks and Drawbacks#Payouts.
Submit — POST /api/v1/payouts
Body (Zod-validated):
| Field | Rule |
|---|---|
merchantBatchId |
1–128 chars; idempotency key |
network, currency |
1–16 chars; resolved to an Asset by resolveAsset |
maxRiskScore |
0 ≤ n ≤ 6 |
items[] |
{ address (≤128), amount (≤64) }; at least 1, capped by the hard limit |
Recipient cap is two-layered: the hard ceiling PAYOUT_MAX_RECIPIENTS_HARD_LIMIT is enforced at parse time, and the admin-tunable platform_fees.payout_max_recipients (default 100) is enforced after parse — so ops can lower the effective limit without a deploy.
Idempotency: a partial unique index on payout_batches (user_id, merchant_batch_id) makes re-submission of the same batch id a no-op returning the original batch.
Validation pipeline (submitPayoutBatch)
Runs inside a locked transaction to close the TOCTOU window between balance check and persistence.
- Format-validate each address per network (
validateAddressFormat). Malformed →invalid_address, and the address is never sent to Elliptic — we don't pay per-call for garbage. - Amount parse to BigInt minor units. Bad →
invalid_amount. - AML lookup in batch (
lookupAmlBatch). Score abovemaxRiskScore→blocked. - Whitelist resolve — every remaining address must map to an
activepayee_walletsentry, whose Fireblocks identifiers are snapshotted onto the row. Unmatched →blocked. - Funds preflight —
lockAccountAsset(user, asset), then trim from the tail (highestrow_num) toinsufficient_balanceuntil the batch fits the balance, coveringamount + network fee + platform fee. - Reserve a hold for the surviving total (
reserveBatchHold). Insufficient →InsufficientBatchHoldError.
AML cache
- Table
aml_records, composite PK(wallet_address, network, currency). - Freshness window
AML_FRESHNESS_MS = 24 h(lib/aml/index.ts); a hit within the window skips the provider call. - Cache-aside lives in
lib/aml, not in the provider mock — swappingMockEllipticfor live Elliptic doesn't change caching behaviour. POST /api/v1/payouts/risk-scoreexposes the same lookup for pre-flight scoring.
Execution — POST /api/v1/payouts/{batchId}/execute
The REST handler is a thin auth wrapper around executePayoutBatch; the merchant UI server action calls the same helper directly.
Execution is queued, not synchronous. executePayoutBatch dispatches to the payout.execute pg-boss queue, so the request acknowledges immediately and the worker drains the batch. This closes the old load-balancer-timeout risk on 1000-row batches.
Per row, inside one DB transaction:
lockAccountAsseton(user, asset)- balance re-check
- ledger debit with idempotency key
payout:row:<rowId> - row status flip
transactionsmirror row (type = 'payout')audit_logevent
Each row is its own atomic unit, so a mid-batch failure leaves earlier successes durable. Row failures refund against the batch hold (refundRowAgainstHold); fee deltas apply via applyHoldFeeAdjustment; the remainder is released with releaseHoldRemainder when the batch settles.
Provider calls are idempotent on providerIdempotencyKey, and reservations (reservePayoutRow / findPayoutReservation) survive a worker restart mid-broadcast.
Lifecycle
Batch (payout_status): queued → validated → executing → completed | partial_success | failed
Row (payout_row_status):
| Status | Meaning |
|---|---|
ready |
Passed validation, funded, awaiting execution |
invalid_address |
Failed per-network format regex |
invalid_amount |
Unparseable / non-positive amount |
blocked |
AML score above threshold, or no active whitelist entry |
insufficient_balance |
Trimmed by funds preflight |
pending → broadcast → confirmed |
Normal execution path |
failed |
Provider rejected or reported failure |
MockFireblocks.getTransaction returns FAILED ~1% of the time, which exercises the partial_success path — and makes tests for it probabilistic.
Fees
- Platform fee per row uses the merchant's
users.payout_pctviaresolveMerchantOpRate(thefee_tiersmodel is retired — see ../03-Domains#Fees). - Batch total accumulates into
payout_batches.total_platform_fees_minor; per-row fee denomination is recorded inpayout_rows.fee_asset. - No per-recipient flat network-fee charge is levied on merchants —
PAYOUT_FEE_MINOR_BY_ASSETis zeroed across the board. Who funds payout gas is an open treasury question at ../09-Open Questions#Outbound network gas economics.
Concurrency invariants
- All balance mutations hold
pg_advisory_xact_lockper(account, asset). - Validation trimming and persistence share one transaction — no window between "checked" and "written".
- Rows execute sequentially within a batch. Parallelism (4–8 at a time) is open at ../09-Open Questions#Payout perf.
CSV
lib/payouts/csv.ts (parseCsvDetailed) parses in-browser at /my/payouts/new, with a risk-threshold slider, live totals and a "show filtered out" toggle. Fixtures live at tests/data/payout_*.csv — ~1000 rows for USDC_ETH, smaller for other assets, each with one deliberately-invalid address.
Related
- ../03-Domains#Payouts — product status
- ../08-Risks and Drawbacks#Payouts — known weaknesses
- ../implementation/fireblocks-whitelisted-wallets — whitelist lifecycle
- ../PRD/reference/reconciliation — halts and drift detection