reference

Payouts — Engineering Reference

docs/reference/payouts.mdtype: referenceupdated: 2026-08-09

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 on master as 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 against api_keys (lib/payouts/auth.ts). Keys carry both revoked_at and expires_at.
  • Per-API-key REST rate limiting (lib/api/rest-limits.ts) backed by the rate_limits table.
  • Requires the payouts_enabled feature 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_keys row, 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.

  1. 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.
  2. Amount parse to BigInt minor units. Bad → invalid_amount.
  3. AML lookup in batch (lookupAmlBatch). Score above maxRiskScoreblocked.
  4. Whitelist resolve — every remaining address must map to an active payee_wallets entry, whose Fireblocks identifiers are snapshotted onto the row. Unmatched → blocked.
  5. Funds preflightlockAccountAsset(user, asset), then trim from the tail (highest row_num) to insufficient_balance until the batch fits the balance, covering amount + network fee + platform fee.
  6. 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 — swapping MockElliptic for live Elliptic doesn't change caching behaviour.
  • POST /api/v1/payouts/risk-score exposes 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:

  • lockAccountAsset on (user, asset)
  • balance re-check
  • ledger debit with idempotency key payout:row:<rowId>
  • row status flip
  • transactions mirror row (type = 'payout')
  • audit_log event

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
pendingbroadcastconfirmed 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_pct via resolveMerchantOpRate (the fee_tiers model is retired — see ../03-Domains#Fees).
  • Batch total accumulates into payout_batches.total_platform_fees_minor; per-row fee denomination is recorded in payout_rows.fee_asset.
  • No per-recipient flat network-fee charge is levied on merchants — PAYOUT_FEE_MINOR_BY_ASSET is 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_lock per (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.