Payments Engine

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

node
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

1
REQUESTED
Recorded durably under your idempotency key. Nothing has been signed.
2
ASSEMBLED
Inputs pinned and fee estimated. Pinning is what makes the signed payload deterministic.
3
SIGNED
Custody signed it under policy. The signed payload digest is recorded.
4
BROADCAST
Sent to the chain, once, under a reservation that cannot be taken twice.
5
SETTLED
Confirmed to the configured depth. Terminal.

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.

what the engine does
-- 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.
This is why a mid-broadcast crash is survivable. A process that dies after reserving but before sending leaves the reservation in place; the retry finds it, sees the recorded payload digest, and re-sends that exact payload rather than signing a new one. Re-signing would produce a second valid transaction, and both could confirm.

Signing policy

Custody applies policy before signing, and a refusal is a recorded decision rather than an exception:

ControlBehaviour
Per-payout ceilingAn amount above the configured maximum is refused, with the policy version that refused it recorded.
24-hour velocity capAn 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 instancesEvery check-and-increment holds pg_advisory_xact_lock, so ten API processes share one limit rather than having ten.
Destination rulesAllow and deny lists evaluated before signing.
Emergency haltSigning can be stopped globally. Requests are refused and recorded, not queued — a queue that drains on resume is not a halt.

Checking status

node
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
Listing. 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.