Payouts
A payout goes through a state machine with a broadcast reservation in front of it. The reservation is a primary key, which is what makes signing and broadcasting the same payout twice impossible rather than unlikely.
Submitting a payout
const result = await payments.payouts.submit({ payoutId: 'pyo_wd8812a1', // yours; stable across retries asset: 'USDT', amountMinor: '250000000', // minor units, decimal string destination: '0x8Ba1f109551bD432803012645Ac136ddd64DBA72' }, 'withdrawal-8812'); // the idempotency key — required result.status; // 'REQUESTED' result.outcome; // 'ACCEPTED' | 'REPLAYED'
Submission is acceptance, not payment. The engine has recorded a durable instruction; signing, broadcasting and settlement follow asynchronously, and you learn about them through webhooks or by polling the payout.
The state machine
Every transition is written to a transition log with its timestamp, which is what GET /v1/payouts/:id returns as transitions. A payout that ends in REFUSED carries the policy decision that refused it.
The broadcast reservation
Before anything is sent to a chain, the engine inserts a reservation row keyed by the payout ID. The insert either succeeds — and this process owns the broadcast — or violates the primary key, and this process does not broadcast. There is no window between checking and acting, because the check is the act.
-- One statement. The database decides, not the application. INSERT INTO payouts.broadcast_reservations (payout_id, signed_payload_digest, reserved_at) VALUES ($1, $2, now()) ON CONFLICT DO NOTHING RETURNING payout_id; -- No row returned → somebody else already owns this broadcast.
Signing policy
Custody applies policy before signing, and a refusal is a recorded decision rather than an exception:
| Control | Behaviour |
|---|---|
| Per-payout ceiling | An amount above the configured maximum is refused, with the policy version that refused it recorded. |
| 24-hour velocity cap | An exact sliding window over individual signing amounts, not a tumbling bucket. A bucket that resets at midnight allows nearly double the cap across a boundary. |
| One cap across instances | Every check-and-increment holds pg_advisory_xact_lock, so ten API processes share one limit rather than having ten. |
| Destination rules | Allow and deny lists evaluated before signing. |
| Emergency halt | Signing can be stopped globally. Requests are refused and recorded, not queued — a queue that drains on resume is not a halt. |
Checking status
const payout = await payments.payouts.get('pyo_wd8812a1'); payout.status; // current state payout.broadcast; // { signedPayloadDigest } once reserved, else null payout.attempts; // [{ occurredAt, outcome, txHash }] payout.transitions; // [{ from, to, occurredAt }] — the full history
GET /v1/payouts returns your payouts newest first, filtered by status if you ask, a page at a time with a cursor. Reconcile against it rather than against what you remember submitting.Payouts that get stuck
A payout broadcast but not settling is the situation that costs operators money and sleep. What the engine gives you:
- The attempt log — every broadcast attempt with its outcome and transaction hash, so you can see whether the network ever accepted it.
- The payload digest — proof of exactly what was signed, so a re-broadcast sends the same transaction rather than a competing one.
- A reconciliation finding — a payout the engine believes settled and the chain does not becomes an open finding with a severity, and it stays open until a person closes it with a reason.
Fee bumping (RBF or a replacement transaction) is deliberately not automatic. It is a decision with a cost, and it is yours.