Payments Engine

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

Base pathAll operator endpoints are under /v1. Health endpoints are not.
AuthenticationHMAC-SHA256 over a five-line canonical string. Sixty-second window, one use per signature.
AmountsDecimal strings of minor units, in and out. A JSON number is a 400.
ErrorsOne envelope: { type, detail, status, requestId }.
ScopingSupported tenant reads are scoped to the calling operator inside the query. Deployment-global treasury and reconciliation reads answer 501 until they have owner scope; they are not filtered after reading.

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.

FieldPatternExample
payoutIdpyo_[a-z0-9]{8,48}pyo_wd8812a1
addressIdwaddr_[a-z0-9]{8,48}waddr_p4471eth
operatorIdop_[a-z0-9]{8,48}op_abcdefgh
Idempotency-Key[A-Za-z0-9:_-]{8,128}withdrawal-8812
asset[A-Z0-9]{2,16}USDT
chainId3–32 alphanumerics and underscoresethereum
destinationup to 256 non-space characters0x8Ba1…DBA72
amountMinor[1-9][0-9]{0,38}, as a string"250000000"
derivationIndex[0-9]{1,20}, as a string"4471"
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.