API reference
Every endpoint the engine serves. All of them except the health checks require a signature — see Authentication — and every amount crosses the wire as a decimal string in both directions.
Conventions
Identifiers
Every identifier you supply is validated against a pattern before it reaches a store, and a malformed one is a 400 rather than a lookup that finds nothing. The prefixes are not decoration — they are what makes a mis-wired identifier fail immediately instead of silently addressing the wrong table.
Lowercase only, and at least eight characters after the prefix. pyo_8812 is refused — it has four. Deriving the identifier from your own primary key with a fixed prefix and padding is the simplest way to satisfy this and stay stable across retries.
Payouts
POST
/v1/payouts
Submit a payout instruction. Idempotent on the Idempotency-Key header.
Requires Idempotency-Key. It is part of the signed canonical string, and a submission without one is a 400 rather than an unguarded payment.
Request
request
{ "payoutId": "pyo_wd8812a1",
"asset": "USDT",
"amountMinor": "250000000",
"destination": "0x8Ba1f109551bD432803012645Ac136ddd64DBA72" }
Response
response
201 — accepted, a new payout
{ "payoutId": "pyo_wd8812a1", "status": "REQUESTED", "outcome": "ACCEPTED" }
200 — the key was recognised, this is the original payout
{ "payoutId": "pyo_wd8812a1", "status": "REQUESTED", "outcome": "REPLAYED" }
409 — the key was reused with a different instruction
{ "type": "about:blank#conflict", "status": 409, "requestId": "…" }
A
409 is never something to route around with a fresh key. See
Idempotency.
GET
/v1/payouts/:payoutId
Full state of one payout: status, broadcast reservation, every broadcast attempt and the complete transition history.
Response
response
{ "payoutId": "pyo_wd8812a1",
"status": "BROADCAST",
"asset": "USDT",
"amountMinor": "250000000",
"destination": "0x8Ba1…DBA72",
"createdAt": "2026-08-29T20:41:07.412Z",
"broadcast": { "signedPayloadDigest": "3f9a…" },
"attempts": [ { "occurredAt": "…", "outcome": "SENT", "txHash": "0x…" } ],
"transitions": [ { "from": "SIGNED", "to": "BROADCAST", "occurredAt": "…" } ] }
A payout belonging to another operator answers 404, identically to one that does not exist. Anything else would enumerate other operators' payouts.
GET
/v1/payouts
Your payouts, newest first, a page at a time. Optional status, limit (1–100, default 50) and cursor.
Response
response
200
{ "payouts": [ { "payoutId": "pyo_…", "status": "COMPLETED", "asset": "USDT", "amountMinor": "25000000",
"destination": "0x…", "chainId": "eth_mainnet", "txHash": "0x…", "reason": null, "createdAt": "…", "updatedAt": "…" } ],
"nextCursor": "…" }
Pass nextCursor back as cursor for the next page; it is null on the last one. The cursor names the last payout returned, so payouts created meanwhile never shift a page. Only your own payouts are ever listed.
Deposits
POST
/v1/deposit-addresses
The deposit address for one of your accounts on one network. The engine derives it from your seed and starts watching it.
Request
request
{ "accountRef": "account_4471",
"chainId": "eth_devnet" }
Response
response
201 new · 200 the account already had it
{ "addressId": "waddr_1d4861e8…",
"address": "0x870EE4FeF8294de5a647e7988ed62E5e4D845060",
"chainId": "eth_devnet",
"accountRef": "account_4471",
"assets": [ { "assetCode": "USDT", "contract": "0x5fbd…0aa3",
"decimals": 6, "confirmations": 3, … } ] }
You do not supply the address and cannot: an address the engine was only told about is one it may not be able to move money out of. Asking again for the same account returns the same address. 503 means the deployment was started without a custody master key.
GET
/v1/assets
The networks and assets this deployment accepts, each with its contract, precision and confirmation depth.
Response
response
{ "assets": [ { "assetId": "eth_devnet:USDT", "kind": "ERC20",
"contract": "0x5fbd…0aa3", "decimals": 6, "networkId": 31337,
"confirmations": 3, "environment": "local" }, … ] }
Read amounts with the precision given here, never a table of your own. The same ticker on two networks is two entries.
GET
/v1/deposits?accountRef=&chainId=
Transfers credited to one account. accountRef is required; chainId narrows the result.
Response
response
{ "operatorId": "op_abcdefgh",
"accountRef": "account_4471",
"transfers": [ {
"transferId": "trf_01H…", "chainId": "ethereum",
"txHash": "0x…", "outputIndex": 0,
"assetCode": "USDT", "amountMinor": "250000000",
"blockHeight": "19482013", "status": "CONFIRMED",
"detectedAt": "…" } ] }
An account with no registered address answers 404, not an empty list — those mean different things and confusing them hides an onboarding bug.
Treasury
GET
/v1/treasury/positions?asset=
Unavailable to operator callers while custody positions are deployment-global.
Response
response
{ "type": "about:blank#tenant-scope-unavailable", "status": 501 }
A future operator-scoped accounting migration must carry owner identity at write time. Filtering a global position after it was read is not a tenant boundary.
Reconciliation
GET
/v1/reconciliation/findings?asset=
Unavailable to operator callers while reconciliation findings have no owner scope.
Response
response
{ "type": "about:blank#tenant-scope-unavailable", "status": 501 }
A deployment-wide incident belongs on a separately authorised fleet surface. An empty tenant response would be a false safety claim.
Reports and exports
GET
/v1/reports?type=&asset=
Projection rows. type is one of DEPOSITS, PAYOUTS, TREASURY, FEES, SUMMARY.
Response
response
{ "operatorId": "op_abcdefgh",
"reportType": "PAYOUTS",
"builtThrough": "2026-08-29T20:41:00Z",
"rows": [ { "periodBucket": "2026-08-29", "assetCode": "USDT",
"count": "184", "totalAmountMinor": "46000000000",
"totalFeeMinor": "1840000", "asOfTime": "…" } ] }
builtThrough is the cursor the projection was built to. A null means it has never been built — which is not the same as "nothing to report".
POST
/v1/exports
Start an export. Idempotent on jobId.
Request
request
{ "jobId": "exp_2026_08", "reportType": "PAYOUTS",
"assetCode": "USDT",
"fromTime": "2026-08-01T00:00:00Z",
"toTime": "2026-09-01T00:00:00Z" }
Response
response
202
{ "jobId": "exp_2026_08", "status": "PENDING", "createdAt": "…" }
GET
/v1/exports/:jobId
Export job status and row count.
Response
response
{ "jobId": "exp_2026_08", "status": "COMPLETE",
"rowCount": "184203", "downloadUrl": "/v1/exports/exp_2026_08/download",
"completedAt": "…" }
GET
/v1/exports/:jobId/download
Streamed CSV. Written to the socket row by row and never buffered, whatever the size.
Response
response
200 · content-type: text/csv
period,asset,count,total_amount_minor,total_fee_minor,as_of
2026-08-01,USDT,184,46000000000,1840000,2026-08-01T23:59:59Z
…
A stream that fails mid-transfer destroys the connection instead of ending cleanly — a truncated CSV that looks complete is worse than a failed download.
Webhooks
GET
/v1/webhooks/subscriptions
Active subscriptions for the calling operator.
Response
response
{ "operatorId": "op_abcdefgh",
"subscriptions": [ { "subscriptionId": "sub_01H…",
"eventType": "deposit.confirmed",
"endpointUrl": "https://application.example/webhooks/payments",
"createdAt": "…" } ] }
Health
GET
/health/live
Unauthenticated. The process is running and its event loop responds.
Response
response
{ "status": "pass", "service": "payments-operator-api" }
Wire this to your restart policy. A failure means kill and restart.
GET
/health/ready
Unauthenticated. Dependencies answer — the database in particular.
Response
response
{ "status": "pass", "service": "payments-operator-api",
"version": "0.0.0", "checks": { "database": "pass" } }
Wire this to your load balancer. A failure means take out of rotation — not restart. Wiring liveness to a dependency turns a database blip into a full restart of every instance at once.