what payd is, before any code
payd is self-custodial. A merchant gives it an extended PUBLIC key; payd works out one receiving address per order from that key and watches the chain for money arriving there. It cannot sign, hold, move or return money. There is no balance, no payout and no refund it can carry out for anybody. A refund in payd is a RECORD of a transfer a person made from their own wallet. Anything you write that assumes a balance, a payout, an automatic refund or a "charge the customer" call is wrong about the product, not about the API.
the credential
One header:
Authorization: Bearer payd_sk_...
The scheme is matched case-insensitively and a bare key with no scheme also
works. A key is payd_sk_ followed by 43 base64url characters.
Two scopes, and one distinction: taking money versus CHANGING WHERE MONEY GOES.
write orders, instructions, settlement, refunds. The default.
admin that, plus the wallet and the keys themselves.
A shop's checkout holds a write key. It issues invoices and settles them all
day and cannot repoint the wallet, which is the difference between a leaked key
costing a merchant their order history and costing them their income.
A third credential exists and is not a scope: the deployment key, out of
PAYMENTS_API_KEY, which is the only thing that may read across merchants. No
merchant key can be minted with it.
401 never says which credential failed. An unknown key, a revoked key and a
suspended merchant are one answer with no way to tell them apart.
404 never distinguishes gone from not-yours from not-a-route. Do not branch on
it and do not log it as a bug.
SERVER SIDE ONLY. The key is a bearer credential and belongs in an environment
variable, never in a browser and never in a file you commit.
A READY-MADE CLIENT THAT SETS THIS HEADER ships in the checkout, at
src/client.js, exported as ./client. It covers the operations an integration
performs, uses these operation ids as its method names, and answers what each
route answers. It is a convenience over this API and not a second contract: what
is promised is promised about the routes.
asking what this deployment can do
GET /health carries acapabilitiesarray: sorted, opaque tokens naming behaviour this deployment has. It is in front of the key check, so it is the one question you can ask before you have been given a credential — which is exactly when a connector needs to know what it is talking to. BRANCH ON MEMBERSHIP AND NOTHING ELSE. Do not parse a token apart, do not order your own copy differently, and do not readversionor the database generation for this purpose: one is a build number you would have to keep a table for, and the other is a database fact VERSIONS.md deliberately leaves outside the promise. api.strict-json-body A non-empty body that is not one JSON object is a 400 on every /api route that reads a body. invoice.price-restrike A lapsed price is re-struck on invoice load if the ledgers read empty. orders.currency-refused An order in a currency this build cannot price is refused, not stored. orders.list-paged GET /api/orders pages the orders this key may read, newest first, with a cursor in next. orders.payments-bounded The order read bounds payments[] and publishes paymentsOmitted. orders.settlement-verdict GET /api/orders/:reference answers state, and settlement, outstanding and the required depth on the live instruction. payments.config-policy GET /api/payments/config answers policy: the depth per chain, tolerance, precision and EVM chain id this deployment uses. payments.outstanding-paged GET /api/payments/outstanding pages, with a cursor in next. webhooks.settlement-figures payment.settle-refused is delivered, and every body names the instruction, what arrived on chain, what is still outstanding and how far over a settlement ran. THE RULE THESE ARE PUBLISHED UNDER, in four sentences. An adapter may assume that every field named in a shape test is present and means what these documents say it means. A field may be ADDED to a response in a minor release; nothing is removed, renamed or re-typed except in a major one. A token published here is not removed for two major releases, and an adapter that finds one absent must degrade rather than guess what the deployment does. Anything no shape test holds is not promised, however plainly it is written down.
| token | what it means |
|---|---|
| api.strict-json-body | A non-empty body that is not one JSON object is a 400 on every /api route that reads a body. |
| invoice.price-restrike | A lapsed price is re-struck on invoice load if the ledgers read empty. |
| orders.currency-refused | An order in a currency this build cannot price is refused, not stored. |
| orders.list-paged | GET /api/orders pages the orders this key may read, newest first, with a cursor in next. |
| orders.payments-bounded | The order read bounds payments[] and publishes paymentsOmitted. |
| orders.settlement-verdict | GET /api/orders/:reference answers state, and settlement, outstanding and the required depth on the live instruction. |
| payments.config-policy | GET /api/payments/config answers policy: the depth per chain, tolerance, precision and EVM chain id this deployment uses. |
| payments.outstanding-paged | GET /api/payments/outstanding pages, with a cursor in next. |
| webhooks.settlement-figures | payment.settle-refused is delivered, and every body names the instruction, what arrived on chain, what is still outstanding and how far over a settlement ran. |
the check that costs nothing
Four calls, on a real deployment, that cost nothing and prove your integration
is wired up.
PAYD=https://your-deployment
KEY=payd_sk_...
# 0. May I, and can this shop be billed at all?
curl -sS "$PAYD/api/merchant" -H "Authorization: Bearer $KEY"
# 1. Register an order that costs nothing. A zero total is FREE, and free
# orders are registered already paid.
curl -sS -X POST "$PAYD/api/orders" -H "Authorization: Bearer $KEY" \
-H 'content-type: application/json' \
-d '{"reference":"PAYD-CHECK-1","goodsCents":0,"shippingCents":0}'
# 201 {"order":{"reference":"PAYD-CHECK-1","status":"paid","totalCents":0,
# "publicToken":"QrtKmvXbdLnpWfzJaHcEuSy", ...},
# "invoiceUrl":"$PAYD/i/QrtKmvXbdLnpWfzJaHcEuSy"}
# invoiceUrl is the whole of what you send a buyer. It is the token above,
# composed against this deployment's own origin, so it is a credential.
# 2. Ask to bill it. A free order settles instead of issuing an address, so
# no address is worked out and nothing can be sent anywhere.
curl -sS -X POST "$PAYD/api/orders/PAYD-CHECK-1/payment" \
-H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
-d '{"rail":"BTC"}'
# 200 {"order":{"status":"paid",...},"payment":null,"free":true}
# 3. Read it back, and read the customer's own view of it.
curl -sS "$PAYD/api/orders/PAYD-CHECK-1" -H "Authorization: Bearer $KEY"
curl -sS "$PAYD/i/QrtKmvXbdLnpWfzJaHcEuSy/state"
# 4. Prove the retry rule. This must answer 400, not 409, and it means
# ALREADY REGISTERED.
curl -sS -X POST "$PAYD/api/orders" -H "Authorization: Bearer $KEY" \
-H 'content-type: application/json' \
-d '{"reference":"PAYD-CHECK-1","goodsCents":0,"shippingCents":0}'
# 400 {"errors":{"reference":"Reference PAYD-CHECK-1 is already in use."}}
WHAT THIS PROVES: your key works and its scope is enough, your JSON reaches the
server, the order lifecycle is wired, and your retry handling reads a taken
reference correctly.
WHAT IT DOES NOT PROVE: address derivation, the rate feed, or settlement from
the chain — none of which happen on a free order. For those, set a wallet and
register an order for five cents on LTC: the instruction is real and carries a
real address, and if you never send anything it lapses when the payment window
ends — two hours by default — and costs nothing.
THIS IS NOT A TEST MODE AND THERE IS NO TEST MODE. PAYD-CHECK-1 is a real order
and will sit in that shop's invoices book for ever. Use a reference the merchant
will recognise later.
money
Fiat is INTEGER MINOR UNITS. goodsCents, shippingCents, totalCents, amountCents, receivedCents, owedCents — all whole numbers of cents. Crypto is an EXACT DECIMAL STRING. quotedAmount, chainAmount, assetAmount and shortfallUnits are TEXT in the database and strings on the wire, and they are compared as strings. NEVER A FLOAT, in either direction. One ether is 10^18 wei, which is two orders of magnitude past Number.MAX_SAFE_INTEGER, so a float quietly hands the customer a number that is not the number they owe. Cents lose the same way, more slowly. goodsCents: null is NOT 0. null means nobody has priced it yet and the order sits in quote_requested with a null total. 0 means free, and a free order is registered already paid — asking to bill it settles it instead of issuing an address, so nothing is derived and nothing can be sent anywhere. Amounts are quoted at the asset's ASKING precision rather than its full one, and always rounded UP. Being over by one satoshi costs nothing; being short by one never settles itself.
retries, and what stands in for an idempotency key
THERE IS NO IDEMPOTENCY-KEY HEADER AND NO REQUEST DE-DUPLICATION.
Your own order reference is the de-duplication. Send one on POST /api/orders,
and a repeat answers:
400 {"errors":{"reference":"Reference PP-4821903 is already in use."}}
A retry after a timeout must read that as ALREADY REGISTERED and go and fetch
the order, not as a bad request. It is deliberately not a 409: a 409 would
invite a client to treat it as a conflict to resolve, when the correct handling
is to carry on.
Billing the same order twice on the same coin returns the SAME address, because
the derivation index is held per order per keyring. So an interrupted
POST /api/orders/:reference/payment is safe to repeat: it voids the outstanding
instruction and issues a new one at the same address — unless the chain has
already seen money against it, in which case it is refused.
Nothing else de-duplicates. Minting a key twice leaves two keys; adding a
webhook twice delivers everything twice; recording a refund twice records two
refunds.
every response object, field by field
Order
An order, staff side. What GET /api/orders/:reference returns under order.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| id | integer | no | camel | Sequential, deployment-wide. Never shown to a customer. |
| merchantId | integer | no | camel | Whose order this is. Always your own. |
| reference | string | no | camel | The handle a person says out loud. Unique within a shop. |
| publicToken | string | yes | camel | The customer's own handle for this order. Send them to /i/<publicToken>. 131 bits, never logged, never shown where the reference belongs. |
| status | enum orderStatus | no | camel | Where the order is. |
| currency | string | no | camel | ISO 4217, upper case. What the cents are cents of. |
| customerRef | string | yes | camel | Your own handle for the person. Never reaches /i. |
| customerEmail | string | yes | camel | Never reaches /i. |
| locale | string | no | camel | Two letters. Defaults to en. |
| channel | enum channel | no | camel | Decides whether an outstanding instruction holds goods. |
| needsQuote | boolean | no | camel | True while nobody has priced it. The totals are null and null is NOT zero. |
| goodsCents | integer | yes | camel | Integer minor units. Null means not priced yet. |
| shippingCents | integer | yes | camel | Integer minor units. Null means not priced yet. |
| railDiscountCents | integer | no | camel | Zero on every order registered now. Kept because orders priced while the card lane existed carry a real figure and goods - discount + shipping must still reconstruct what was asked. |
| totalCents | integer | yes | camel | goodsCents - railDiscountCents + shippingCents, or null while either is. Zero means free and the order is registered already paid. |
| paymentMethod | string | yes | camel | What they SAID they would pay with. |
| paymentAsset | string | yes | camel | A stated preference. The instruction is the truth. |
| paymentOnramp | string | yes | camel | Residue of the withdrawn card lane. Always empty. |
| offeredRails | string[] | no | camel | Coins this order says it will take. [] means it does not say, so the shop's own default decides. NOT the menu the customer is shown: that is this narrowed to the coins the shop holds a key for today. |
| offeredAssets | string[] | no | camel | Ledgers — a coin on one network, USDT_TRC20 — this order says it will take, in menu order. [] means it names no networks, and its coins (or the shop's default) decide them. When it names some, offeredRails is the coins they belong to, and the invoice offers and bills these ledgers alone. |
| refundDestination | string | yes | camel | Where the customer would like money sent back. |
| metadata | object | yes | camel | Your own blob, replaced wholesale. Nothing here reads it. |
| createdAt | string | no | camel | SQL UTC, YYYY-MM-DD HH:MM:SS. Not ISO 8601, and no Z. |
| updatedAt | string | no | camel | SQL UTC text: YYYY-MM-DD HH:MM:SS, no timezone and no Z. |
CustomerOrder
The same order as a stranger holding the link may see it. An ALLOW list — a new column does not appear here until somebody decides it should.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| reference | string | no | camel | The handle a person says out loud. |
| status | enum orderStatus | no | camel | Where the order is. |
| currency | string | no | camel | ISO 4217, upper case. |
| locale | string | no | camel | Two letters, as the host application supplied them. |
| needsQuote | boolean | no | camel | True while nobody has priced it. |
| goodsCents | integer | yes | camel | Integer minor units. |
| shippingCents | integer | yes | camel | Integer minor units. |
| railDiscountCents | integer | no | camel | Zero on everything registered now. |
| totalCents | integer | yes | camel | What is owed, in integer minor units. |
| createdAt | string | no | camel | SQL UTC text: YYYY-MM-DD HH:MM:SS, no timezone and no Z. |
Payment
A payment instruction, staff side. An instruction to a customer, never a movement of money — payd holds public keys and cannot send anything.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| id | integer | no | camel | Small and sequential. What /api/payments/:id takes. |
| merchantId | integer | no | camel | Copied from the order at issue. |
| orderId | integer | no | camel | The order this bills. |
| method | enum method | no | camel | crypto. There is no other. |
| status | enum paymentStatus | no | camel | awaiting, confirmed, expired or void. |
| amountCents | integer | no | camel | What was owed, snapshotted at issue. |
| currency | string | no | camel | ISO 4217, upper case. What the cents are cents of. |
| reference | string | yes | camel | Written empty. The handle lives on the order. |
| rail | enum rail | yes | camel | The TICKER the customer chose and the amount is quoted in. |
| asset | enum asset | yes | camel | The LEDGER being watched. A rail narrowed to one network. |
| address | string | yes | camel | Where to send it. Derived from the shop's extended PUBLIC key. |
| derivationIndex | integer | no | camel | Staff side only. A running count of the shop's orders. |
| pickedAsset | enum asset | yes | camel | Which ledger this settles from. Null on instructions issued before networks were picked one at a time; otherwise always equal to asset. |
| legs | PaymentLeg[] | no | camel | Every ledger an address has been handed out on, including networks the customer visited and left while the invoice still offered that. An address that was displayed stays resolvable. |
| quotedAmount | decimal string | yes | camel | What to send, as an exact decimal string. NEVER a float: one ether is 1e18 wei. |
| quotedRateCents | integer | no | camel | Fiat cents per whole unit at the moment of the lock. |
| quotedAt | string | no | camel | SQL UTC text: YYYY-MM-DD HH:MM:SS, no timezone and no Z. |
| rateExpiresAt | string | yes | camel | When the quoted amount stops being honoured. The invoice page re-strikes it on the next load after this, if a live chain read finds nothing arriving. |
| expiresAt | string | yes | camel | When the HOLD lapses. Not when the address stops working. |
| declaredAt | string | yes | camel | When somebody pressed 'I've sent it'. Never evidence. |
| chainSeenAt | string | yes | camel | When the chain was last read for this instruction. |
| chainAmount | decimal string | yes | camel | What the chain has seen, as an exact string. |
| chainConfirmations | integer | yes | camel | How deep it is buried. |
| shortfallUnits | decimal string | no | camel | Staff side only. How far under the quoted amount this one instruction may land and still settle. Empty on everything issued now, which is an allowance of zero. |
| slippageBps | integer | no | camel | Staff side only. The shop's slippage when this instruction was issued, in basis points of its quote (150 is 1.50%). Fixed at issue: changing the setting later moves only invoices issued after. A shortfall inside it plus the asset's dust is exact on the order read and settles itself. 0 where the shop set none. |
| confirmedAt | string | yes | camel | When it settled. |
| confirmedBy | string | yes | camel | Staff side only. |
| externalRef | string | yes | camel | A transaction id somebody recorded by hand. |
| receivedCents | integer | yes | camel | What actually arrived, in fiat cents. |
| note | string | yes | camel | Staff side only. The operator's own note. |
| createdAt | string | no | camel | SQL UTC text: YYYY-MM-DD HH:MM:SS, no timezone and no Z. |
PaymentLeg
One ledger of one instruction, staff side. Two legs can share an address: ERC-20 and BEP-20 are two balances at one 0x….
| field | type | null? | case | what it is |
|---|---|---|---|---|
| asset | enum asset | no | camel | Which ledger. |
| address | string | no | camel | Derived from the shop's extended public key. |
| network | string | no | camel | What to tell the customer. 'Tron (TRC-20)', not 'TRX'. |
| derivationIndex | integer | no | camel | Staff side only. Counted per keyring. |
| chainSeenAt | string | yes | camel | When this leg was last read. |
| chainAmount | decimal string | yes | camel | What this leg has seen. |
| chainConfirmations | integer | yes | camel | How deep, on this ledger. |
CustomerPayment
What customerPayment() hands back, which is what every /api route returns under payment. A DENY list over Payment: note, derivationIndex, confirmedBy, shortfallUnits, slippageBps and merchantId are removed, and every leg loses its derivationIndex.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| id | integer | no | camel | Kept here, stripped again on /i — see InvoicePayment. |
| orderId | integer | no | camel | Kept here, stripped again on /i. |
| method | enum method | no | camel | crypto. There is no other method. |
| status | enum paymentStatus | no | camel | awaiting, confirmed, expired or void. |
| amountCents | integer | no | camel | What was owed, at issue. |
| currency | string | no | camel | ISO 4217, upper case. What the cents are cents of. |
| reference | string | yes | camel | Written empty. |
| rail | enum rail | yes | camel | The ticker the amount is quoted in. |
| asset | enum asset | yes | camel | The ledger the address belongs to. |
| address | string | yes | camel | Where to send it. |
| pickedAsset | enum asset | yes | camel | Which ledger this settles from. |
| legs | CustomerPaymentLeg[] | no | camel | Every ledger an address was handed out on. |
| quotedAmount | decimal string | yes | camel | Exact decimal string. Never a float. |
| quotedRateCents | integer | no | camel | Fiat cents per whole unit at the lock. |
| quotedAt | string | no | camel | SQL UTC text: YYYY-MM-DD HH:MM:SS, no timezone and no Z. |
| rateExpiresAt | string | yes | camel | When the lock lapses. The invoice page re-strikes the price on the next load after this, if a live chain read finds nothing arriving. |
| expiresAt | string | yes | camel | When the hold lapses. |
| declaredAt | string | yes | camel | When somebody said they had sent it. |
| chainSeenAt | string | yes | camel | When the chain was last read. |
| chainAmount | decimal string | yes | camel | What the chain has seen. |
| chainConfirmations | integer | yes | camel | How deep it is buried, in confirmations. |
| confirmedAt | string | yes | camel | When it settled. |
| externalRef | string | yes | camel | A transaction id recorded by hand. |
| receivedCents | integer | yes | camel | What arrived, in fiat cents. |
| createdAt | string | no | camel | SQL UTC text: YYYY-MM-DD HH:MM:SS, no timezone and no Z. |
CustomerPaymentLeg
A leg with its derivationIndex removed. The index is a running count of the shop's orders, so two invoices a week apart would disclose their volume.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| asset | enum asset | no | camel | Which ledger. |
| address | string | no | camel | Where to send it. |
| network | string | no | camel | What to tell the customer. |
| chainSeenAt | string | yes | camel | When this leg was last read. |
| chainAmount | decimal string | yes | camel | What this leg has seen. |
| chainConfirmations | integer | yes | camel | How deep, on this ledger. |
OrderPayment
What GET /api/orders/:reference returns under payment: CustomerPayment plus this deployment's verdict — the settlement shape, what is still owed, and how deep it has to be buried. The other four routes that answer payment still answer a plain CustomerPayment.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| id | integer | no | camel | Kept here, stripped again on /i — see InvoicePayment. |
| orderId | integer | no | camel | Kept here, stripped again on /i. |
| method | enum method | no | camel | crypto. There is no other method. |
| status | enum paymentStatus | no | camel | awaiting, confirmed, expired or void. |
| amountCents | integer | no | camel | What was owed, at issue. |
| currency | string | no | camel | ISO 4217, upper case. What the cents are cents of. |
| reference | string | yes | camel | Written empty. |
| rail | enum rail | yes | camel | The ticker the amount is quoted in. |
| asset | enum asset | yes | camel | The ledger the address belongs to. |
| address | string | yes | camel | Where to send it. |
| pickedAsset | enum asset | yes | camel | Which ledger this settles from. |
| legs | OrderPaymentLeg[] | no | camel | Every ledger an address was handed out on. |
| quotedAmount | decimal string | yes | camel | Exact decimal string. Never a float. |
| quotedRateCents | integer | no | camel | Fiat cents per whole unit at the lock. |
| quotedAt | string | no | camel | SQL UTC text: YYYY-MM-DD HH:MM:SS, no timezone and no Z. |
| rateExpiresAt | string | yes | camel | When the lock lapses. The invoice page re-strikes the price on the next load after this, if a live chain read finds nothing arriving. |
| expiresAt | string | yes | camel | When the hold lapses. |
| declaredAt | string | yes | camel | When somebody said they had sent it. |
| chainSeenAt | string | yes | camel | When the chain was last read. |
| chainAmount | decimal string | yes | camel | What the chain has seen. |
| chainConfirmations | integer | yes | camel | How deep it is buried, in confirmations. |
| confirmedAt | string | yes | camel | When it settled. |
| externalRef | string | yes | camel | A transaction id recorded by hand. |
| receivedCents | integer | yes | camel | What arrived, in fiat cents. |
| createdAt | string | no | camel | SQL UTC text: YYYY-MM-DD HH:MM:SS, no timezone and no Z. |
| requiredConfirmations | integer | yes | camel | How deep the picked ledger has to be buried ON THIS DEPLOYMENT. Read it rather than assuming a default: it is CHAIN_CONFIRMATIONS_<CHAIN> and an operator may have moved it. Null when no ledger is picked. |
| settlement | enum settlement | no | camel | How the money compares with the ask, on THIS instruction. short is the sweep's own boundary, so an underpayment inside this deployment's dust allowance is exact and settles; over is descriptive and decides nothing. Read state on the envelope for whether the order is finished — an instruction a person accepted short answers state settled and settlement short, which is the truth on both counts, and an order paid some other way answers state settled and settlement none because nothing arrived on this instruction. |
| outstanding | decimal string | no | camel | What is still owed on the ORDER, in the asset's own units, as an exact decimal string — compare it, never parse it. "0" whenever nothing more is owed: settled by hand, exact, within the allowance, over, or the order itself already paid or cancelled. The whole quoted figure while nothing has arrived and the order is still open. |
OrderPaymentLeg
A leg on the merchant's order read: CustomerPaymentLeg with the depth this deployment requires for that ledger. Per leg because the depths genuinely differ — Bitcoin settles at 1 and Tron at 19.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| asset | enum asset | no | camel | Which ledger. |
| address | string | no | camel | Where to send it. |
| network | string | no | camel | What to tell the customer. |
| chainSeenAt | string | yes | camel | When this leg was last read. |
| chainAmount | decimal string | yes | camel | What this leg has seen. |
| chainConfirmations | integer | yes | camel | How deep, on this ledger. |
| requiredConfirmations | integer | no | camel | How deep this ledger has to be buried on this deployment. |
InvoicePayment
What a stranger holding the link sees under payment. CustomerPayment with id and orderId removed — both are AUTOINCREMENT counters shared by every merchant on the deployment — and the confirmation depth added.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| method | enum method | no | camel | crypto. There is no other method. |
| status | enum paymentStatus | no | camel | awaiting, confirmed, expired or void. |
| amountCents | integer | no | camel | What was owed. |
| currency | string | no | camel | ISO 4217, upper case. What the cents are cents of. |
| reference | string | yes | camel | Written empty. |
| rail | enum rail | yes | camel | The ticker the amount is quoted in. |
| asset | enum asset | yes | camel | The ledger being watched. |
| address | string | yes | camel | Where to send it. |
| pickedAsset | enum asset | yes | camel | Which ledger this settles from. |
| legs | InvoiceLeg[] | no | camel | Every ledger an address was handed out on. |
| quotedAmount | decimal string | yes | camel | Exact decimal string. |
| quotedRateCents | integer | no | camel | Fiat cents per whole unit at the lock. |
| quotedAt | string | no | camel | SQL UTC text: YYYY-MM-DD HH:MM:SS, no timezone and no Z. |
| rateExpiresAt | string | yes | camel | When the lock lapses. The invoice page re-strikes the price on the next load after this, if a live chain read finds nothing arriving. |
| expiresAt | string | yes | camel | When the hold lapses. |
| declaredAt | string | yes | camel | When somebody said they had sent it. |
| chainSeenAt | string | yes | camel | When the chain was last read. |
| chainAmount | decimal string | yes | camel | What the chain has seen. |
| chainConfirmations | integer | yes | camel | How deep it is buried, in confirmations. |
| confirmedAt | string | yes | camel | When it settled. |
| externalRef | string | yes | camel | A transaction id recorded by hand. |
| receivedCents | integer | yes | camel | What arrived, in fiat cents. |
| createdAt | string | no | camel | SQL UTC text: YYYY-MM-DD HH:MM:SS, no timezone and no Z. |
| requiredConfirmations | integer | yes | camel | How deep the picked ledger has to be buried. Null when no ledger is picked. |
InvoiceLeg
A customer-facing leg with its own confirmation depth. Per leg, not per instruction: Bitcoin settles at 1 and Tron at 19, and one number for both would be quoting the wrong chain at somebody.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| asset | enum asset | no | camel | Which ledger. |
| address | string | no | camel | Where to send it. |
| network | string | no | camel | What to tell the customer. |
| chainSeenAt | string | yes | camel | When this leg was last read. |
| chainAmount | decimal string | yes | camel | What this leg has seen. |
| chainConfirmations | integer | yes | camel | How deep, on this ledger. |
| requiredConfirmations | integer | no | camel | How deep this ledger has to be buried. |
InvoiceState
The whole of what GET /i/:token/state returns. No key. The token in the path is the entire authorisation.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| order | CustomerOrder | no | camel | The order, projected through the allow list. |
| payment | InvoicePayment | yes | camel | The outstanding instruction, or null. |
| state | enum invoiceState | no | camel | What the page is showing. Struck through the same verdict function the chain sweep uses, so the page cannot reach a conclusion the watcher would not. |
| timeline | TimelineEvent[] | no | camel | The newest 200 moments on the order, oldest first among those returned. A longer story loses its OLDEST rows and nothing counts them, so do not read this as a complete history. |
TimelineEvent
One moment in an instruction's life. Append only, never revised, customer-safe by construction — there is nothing on it to strip.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| kind | enum timelineKind | no | camel | What happened. |
| asset | enum asset | yes | camel | Which ledger, where one is responsible. |
| amount | decimal string | yes | camel | The figure, as an exact string. |
| confirmations | integer | yes | camel | How deep it was. |
| required | integer | yes | camel | How deep it needed to be. |
| blockHeight | integer | yes | camel | Null until something reads blocks rather than balances. |
| detail | object | yes | camel | Kind-specific. Arrivals, a remaining figure, a pick. |
| at | string | no | camel | SQL UTC text: YYYY-MM-DD HH:MM:SS, no timezone and no Z. |
Refund
A record that money was sent back. RECORDS ONLY — payd sends nothing and cannot: it holds extended public keys. A person made the transfer.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| id | integer | no | camel | Sequential, and shared by every merchant on the deployment. |
| orderId | integer | no | camel | Which order this belongs to. |
| paymentId | integer | no | camel | Which settled instruction it is refunded against. |
| amountCents | integer | no | camel | Integer minor units. |
| currency | string | no | camel | ISO 4217, upper case. What the cents are cents of. |
| method | enum method | no | camel | Copied from the payment. |
| destination | string | yes | camel | Where the person sent it. |
| asset | enum asset | yes | camel | Which ledger it went out on. |
| assetAmount | decimal string | yes | camel | The crypto figure, as an exact string. |
| externalRef | string | yes | camel | The transaction id. |
| note | string | yes | camel | Why the money was sent back. |
| refundedBy | string | yes | camel | Who recorded it. |
| createdAt | string | no | camel | SQL UTC text: YYYY-MM-DD HH:MM:SS, no timezone and no Z. |
RefundPosition
What an order still owes. MIXED CASE, and the one shape in this API where that is true of a single response: the three figures are camelCase and payment is a raw payments row.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| payment | PaymentRow | yes | camel | The settled instruction, as it comes out of SQL. snake_case throughout — see PaymentRow. |
| receivedCents | integer | no | camel | What arrived. |
| refundedCents | integer | no | camel | What has been recorded as sent back. |
| owedCents | integer | no | camel | receivedCents - refundedCents, never below zero. |
PaymentRow
A payments row exactly as SQLite returns it. Reached only through RefundPosition.payment. Listed because it is the one place an integration meets a payment in snake_case, and reading amountCents off it silently gives undefined.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| id | integer | no | snake | Sequential, and shared by every merchant on the deployment. |
| merchant_id | integer | no | snake | Whose this is. Always your own. |
| order_id | integer | no | snake | Which order this belongs to. |
| method | enum method | no | snake | crypto. There is no other method. |
| status | enum paymentStatus | no | snake | confirmed, by construction. |
| amount_cents | integer | no | snake | What was owed. |
| currency | string | no | snake | ISO 4217, upper case. What the cents are cents of. |
| reference | string | yes | snake | Written empty. |
| rail | enum rail | yes | snake | The ticker the amount is quoted in. |
| asset | enum asset | yes | snake | The ledger the address belongs to. |
| address | string | yes | snake | Where money was asked for. |
| derivation_index | integer | no | snake | The index this address came from. |
| picked_asset | enum asset | yes | snake | Which ledger it settled from. |
| issued_plan | string | yes | snake | The payd.sh plan the instruction was issued under. Nothing records it yet, so it is null. |
| quoted_amount | decimal string | yes | snake | TEXT in SQLite, compared as a string. |
| quoted_rate_cents | integer | no | snake | The locked rate. |
| quoted_at | string | yes | snake | SQL UTC text: YYYY-MM-DD HH:MM:SS, no timezone and no Z. |
| expires_at | string | yes | snake | When the hold lapsed. |
| declared_at | string | yes | snake | When somebody said they had sent it. |
| chain_seen_at | string | yes | snake | When the chain was last read. |
| chain_amount | decimal string | yes | snake | TEXT in SQLite, and compared as a string. |
| chain_confirmations | integer | yes | snake | How deep it is buried, in confirmations. |
| shortfall_units | decimal string | yes | snake | TEXT. The shortfall allowance. |
| slippage_bps | integer | no | snake | The shop's slippage when it was issued, in basis points of the quote. 0 for none. |
| onramp_started_at | string | yes | snake | Residue of the withdrawn card lane. Never set. |
| onramp_status | string | no | snake | Residue of the withdrawn card lane. Always empty. |
| onramp_ref | string | no | snake | Residue of the withdrawn card lane. Always empty. |
| onramp_updated_at | string | yes | snake | Residue of the withdrawn card lane. Never set. |
| onramp_tx | string | no | snake | Residue of the withdrawn card lane. Always empty. |
| confirmed_at | string | yes | snake | When it settled. |
| confirmed_by | string | yes | snake | Who settled it. |
| external_ref | string | yes | snake | A transaction id. |
| received_cents | integer | yes | snake | What arrived, in fiat cents. |
| note | string | yes | snake | The operator's note. |
| created_at | string | no | snake | SQL UTC text: YYYY-MM-DD HH:MM:SS, no timezone and no Z. |
Check
What POST /api/orders/:reference/paid found when it went and looked. A reading, never a decision — verdict is struck by the same function the sweep uses.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| verdict | string | no | camel | none, unavailable, pending, settle or short. unavailable means an explorer could not be reached and is deliberately NOT none: telling somebody who has paid that nothing arrived is the worst answer available. |
| seen | decimal string | yes | camel | What the chain showed. |
| expected | decimal string | no | camel | What was asked for. |
| remaining | decimal string | yes | camel | Still owed, on a shortfall only. |
| confirmations | integer | yes | camel | How deep it is buried, in confirmations. |
| required | integer | yes | camel | How deep it has to be. |
Policy
What THIS DEPLOYMENT decides about money, on GET /api/payments/config. The three lists beside it on that route are the CALLER's and depend on the keys the shop holds; this one is the same for everybody here and covers every asset the build knows.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| chains | ChainPolicy[] | no | camel | One row per chain this build can watch. |
| assets | AssetPolicy[] | no | camel | One row per ledger this build knows — NOT the assets list beside it, which is what this shop can be billed on. |
ChainPolicy
One chain's settlement policy. Depth is per chain and not per asset: a token is buried by the same blocks the chain's own coin is.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| chain | enum chain | no | camel | Which ledger family. |
| confirmations | integer | no | camel | How deep a transaction has to be buried before this deployment settles on it. CHAIN_CONFIRMATIONS_<CHAIN>, read per request, so this is the live figure and not the default table. |
| chainId | integer | yes | camel | EIP-155's network number, for building a payment URI. Null on Bitcoin, Litecoin and Tron, where there is none to give — and null is not unknown: a reader that invented one would point a wallet at a different ledger. |
AssetPolicy
One ledger's precision and dust allowance. The dust is absolute and never a percentage. A shop that sets a slippage adds its own percentage on top, per invoice — that is on the instruction (slippageBps), not here, because it is the shop's and not the deployment's.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| asset | enum asset | no | camel | Which ledger. |
| chain | enum chain | no | camel | Which chain carries it, and so which depth applies. |
| tolerance | decimal string | no | camel | How far under the asking price still settles, in this asset's own units. CHAIN_TOLERANCE_<ASSET>, read per request. A shortfall at or inside this is exact on the order read and settles itself; anything larger stays short for a person, unless the invoice's slippageBps covers the rest. |
| decimals | integer | no | camel | The ledger's true precision, which is how many places an outstanding figure can carry. Eighteen on Binance-Peg USDT and six on Tether's own chains — a property of the contract, not of the ticker. |
| quoteDecimals | integer | no | camel | How precisely a customer is ASKED, which is a different question: ether divides into 10^18 wei and nobody is handed nineteen characters to transcribe. |
MerchantWhoami
Everything a shop needs to know about its own setup, in one call. Step zero of any integration.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| merchant | object | no | camel | { id, name }. Your own. |
| key | object | no | camel | { id, label, scope } — what the key being used RIGHT NOW can do, so a client can grey out what it would only be refused for. |
| canBeBilled | boolean | no | camel | The honest answer to 'can I take money yet'. False for a shop with no wallet, which is the state every new shop starts in. |
| rails | object[] | no | camel | { rail, label, ticker, networks[] } — what to offer a customer. |
| wallets | object[] | no | camel | { keyring, label, path, configured, assets[] } — what to export from a wallet and what each export switches on. |
| slippageBps | integer | no | camel | How far short, in basis points of the quote, a payment on an invoice issued NOW may land and still settle (150 is 1.50%, at most 500). Set in the portal. 0 for none. Each instruction carries its own copy, and that copy decides. |
Key
A merchant key as POST /api/merchant/keys returns it under record. snake_case: it is a SQL RETURNING row and never passes through a shape function.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| id | integer | no | snake | What DELETE /api/merchant/keys/:id takes. |
| merchant_id | integer | no | snake | Whose this is. Always your own. |
| label | string | no | snake | Whatever you called it. |
| prefix | string | no | snake | The first characters, kept in the clear so a list can name it. |
| scope | enum scope | no | snake | write or admin. |
| created_at | string | no | snake | SQL UTC text: YYYY-MM-DD HH:MM:SS, no timezone and no Z. |
KeyListing
A key as GET /api/merchant/keys lists it. Also snake_case, and NOT the same field set as Key — there is no merchant_id and there are two timestamps more.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| id | integer | no | snake | What revoke takes. |
| label | string | no | snake | Whatever you called it. |
| prefix | string | no | snake | The first characters. |
| scope | enum scope | no | snake | write or admin. |
| created_at | string | no | snake | SQL UTC text: YYYY-MM-DD HH:MM:SS, no timezone and no Z. |
| last_used_at | string | yes | snake | Coarse: stamped at most once a minute. |
| revoked_at | string | yes | snake | Null while it still works. |
Wallet
One keyring this shop is billed on. The extended key is PUBLIC and is returned in full, on purpose: it derives addresses and cannot sign.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| keyring | enum keyring | no | camel | BTC, LTC, ETH or TRX. |
| xpub | string | no | camel | The extended PUBLIC key. Any of xpub, ypub, zpub, Ltub or Mtub. |
| verifiedAddress | string | yes | camel | The address at index 0 the merchant checked. Null for an environment key, which nobody confirmed through payd. |
| verifiedAt | string | yes | camel | SQL UTC text: YYYY-MM-DD HH:MM:SS, no timezone and no Z. Null for an environment key. |
| source | string | no | camel | database for a key stored through this API, the portal or the CLI; environment for one answered by a WALLET_XPUB_* variable. An environment key changes by redeploying. Posting a key for its keyring stores a row that takes precedence while nothing has been issued on that keyring, and is refused 409 once anything has. |
Address
One address this shop has been ISSUED, with everything billed against it. Issued, not derived: an index is allocated before an address is committed, so the sequence has holes and nextIndex is an upper bound rather than a count.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| index | integer | no | camel | The derivation index under the keyring. |
| address | string | no | camel | The address itself. |
| instructions | object[] | no | camel | { paymentId, orderReference, rail, status, confirmedAt, issuedAt, assets[] }. Several, routinely: one address can carry a void instruction and a live one. |
Webhook
A delivery endpoint. snake_case — the row is returned as SQLite hands it over.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| id | integer | no | snake | What DELETE and enable take. |
| url | string | no | snake | https only, and not as a formality — or telegram:<chatId> for a Telegram chat, which is a URI and not a place to send a request. |
| created_at | string | no | snake | SQL UTC text: YYYY-MM-DD HH:MM:SS, no timezone and no Z. |
| disabled_at | string | yes | snake | Set by the breaker. Null while it is live. |
Delivery
One attempt to tell you something. snake_case, for the same reason.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| id | integer | no | snake | Sequential, and shared by every merchant on the deployment. |
| webhook_id | integer | no | snake | Which endpoint. |
| event | enum webhookEvent | no | snake | What happened. |
| attempts | integer | no | snake | How many tries so far, up to six. |
| delivered_at | string | yes | snake | When it was accepted. Null while it has not been. |
| gave_up_at | string | yes | snake | When the ladder ran out. |
| next_try_at | string | yes | snake | When it will be tried again. |
| last_error | string | yes | snake | What went wrong last time. |
| created_at | string | no | snake | SQL UTC text: YYYY-MM-DD HH:MM:SS, no timezone and no Z. |
WebhookBody
What arrives at your endpoint. Signed with payd-signature: t=<unix>,v1=<hex>, HMAC-SHA256 over ${t}.${rawBody}. A doorbell, not the truth — reconcile against GET /api/orders/<reference>.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| event | enum webhookEvent | no | camel | Which one happened. Fewer events are delivered than the library emits, and an event you do not recognise is to be answered 2xx and ignored. |
| at | string | no | camel | ISO 8601, with a Z — unlike every timestamp inside the payload. When the EVENT happened, composed once; the t in the signature is when THIS attempt was sent, so two attempts of one delivery carry identical bytes under different signatures. |
| order | WebhookBodyOrder | no | camel | Which order this is about, and where it now stands. |
| payment | WebhookBodyPayment | yes | camel | The instruction this is about, or null where the event has none — an expiry sweep and a free order both carry no instruction. |
WebhookBodyOrder
The order on a webhook body: the reference you registered, where the order stands now, and what it was for. Enough to find it again and nothing more — a body travels to a URL that ends up in logs.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| reference | string | no | camel | Yours, the one you sent on POST /api/orders. |
| status | enum orderStatus | no | camel | Where the order stands as this was composed. |
| totalCents | integer | yes | camel | What the order came to, in fiat minor units. |
| currency | string | no | camel | ISO 4217, upper case. What the cents are cents of. |
WebhookBodyPayment
The payment on a webhook body: which instruction, what was asked, and what the reading that caused this event actually held. Every figure is copied from the event, never recomputed, and a figure nobody measured is null rather than zero.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| id | integer | yes | camel | Which instruction, for GET /api/payments/:id. |
| rail | enum rail | yes | camel | The ticker the amount was quoted in. |
| asset | enum asset | yes | camel | The ledger the address belongs to. |
| amountCents | integer | yes | camel | What was owed, at issue. |
| receivedCents | integer | yes | camel | What arrived, in fiat cents at the quoted rate. |
| status | enum paymentStatus | no | camel | awaiting, confirmed, expired or void. |
| chainAmount | decimal string | yes | camel | What the reading behind this event held, in the asset's own units, as an exact decimal string — compare it, never parse it. Null where nothing read the chain, as on a confirmation made by hand. |
| outstanding | decimal string | yes | camel | What was still missing when this event was struck, in the asset's own units, as an exact decimal string. Only a short payment carries one; it answers the reading, so a late payment onto a cancelled order can name a figure while GET /api/orders/:reference answers "0" about the ORDER. Reconcile there. |
| overCents | integer | yes | camel | How far a settlement ran past the ask, in fiat cents: receivedCents minus amountCents as the settlement struck it, and 0 where it landed on the nose. Null wherever nothing measured an over figure — on a confirmation made by hand, an accepted short included, and on every event that is not a settlement. It is null independently of chainAmount, which a hand confirmation can still carry. payd records it and takes no view — what happens to the excess is between a shop and its customer. |
Health
Whether this deployment is alive and watching the chain. No key. Nothing here counts anything, names anybody, or moves when a merchant's customers do.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| ok | boolean | no | camel | This process is up and answering. Always true if you got a body. |
| version | string | no | camel | Which build is running. Matches deno.json. |
| schema | integer | no | camel | The database generation this build reads. |
| chain | object | no | camel | { watching, sweptSecondsAgo, stale }. stale is the one to alarm on, and it is false when the watcher is off — nothing is supposed to be sweeping, so nothing is late. |
| rates | object | no | camel | { fresh, ageSeconds }. Both null when nothing has ever been priced. |
| capabilities | string[] | no | camel | Sorted, opaque tokens naming behaviour you may branch on. Test for membership rather than reading version or schema, neither of which tells a connector what this deployment does. See the capabilities guide for what each token means and how long it is held. |
FieldErrors
The field-validation 400 body. The ONLY error shape with a plural: everything else, including the 400 for a body that is not one JSON object, is { "error": "<sentence>" }.
| field | type | null? | case | what it is |
|---|---|---|---|---|
| errors | object | no | camel | Field name to sentence. One entry per field that was refused. |
every operation
service
Is this deployment alive, and is it watching the chain.
Deliberately in front of the key check: a load balancer holds no secret. The status is 200 even when the fields below say the deployment is sick, because a stale chain sweep is not a reason to stop serving invoice pages to customers who are mid-payment. Read chain.stale and decide for yourself.
then usuallygetMerchant
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | Health |
The deployment's audit trail — every merchant's.
THE DEPLOYMENT KEY ONLY, which is neither write nor admin and is not a scope a merchant key can be minted with. The entries carry a merchant_id and could be filtered one day; they are not filtered today because audit() writes whatever merchant the request carried, so the column is only as trustworthy as the oldest row in the table, and a per-merchant view built on rows written before there was a merchant to write would show one shop another's actions.
what you send · query string
| field | type | required | what it is |
|---|---|---|---|
| action | string | no | In the query. Filter by action. |
| limit | integer | no | In the query. Newest first; defaults to 100, up to 500. Clamped, never refused: a fraction is truncated toward zero, a negative number reads as 1, and 0 or anything that is not a number is the default. |
| offset | integer | no | In the query. Entries to skip. Clamped, never refused: below 0 reads as 0, and a fraction is truncated. |
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | entries: object[], total: integer |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 404 | Not found. | A merchant key asked. 404 and not 403, for the same reason every other 404 here is one: it says nothing about what exists. | Nothing. A merchant key cannot read this, and no scope makes it able to. |
merchant
Who am I, what may this key do, and can this shop be paid yet.
Step zero of every integration, and one request rather than four on purpose: this is what a settings screen is built from and what somebody checks when an integration is not working, and a screen that assembles itself from four round trips shows four different half-answers while it does. canBeBilled is false for a shop with no wallet, which is the state every new shop starts in.
then usuallypaymentsConfig, createOrder
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | MerchantWhoami |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 401 | Unauthorized. | An unknown key, a revoked key and a suspended merchant, which are deliberately indistinguishable. | Check the key. There is nothing here to branch on. |
Every key this shop holds, and when each was last used.
Never the key strings — only a hash is kept, so nothing here can hand one back. last_used_at is coarse by design: an exact answer would turn every read request into a write.
then usuallymintKey, revokeKey
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | keys: KeyListing[] |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 403 | That key may take payments but not change where they go. Use an admin key for this. | A write key asked an admin question. | Use the admin key. 403 and not 404 here on purpose — you already know your own account exists, so the only thing left to tell you is which key to reach for. |
Mint a key, and see it once.
key in the response is the only time that string exists anywhere. Only a one-way hash is stored, so a client that does not keep it now cannot ask again — a database that could hand back working credentials is a database whose backups are working credentials.
then usuallylistKeys, revokeKey
what you send · JSON body
| field | type | required | what it is |
|---|---|---|---|
| label | string | no | Whatever you want to call it. |
| scope | enum scope | no | Defaults to write, which is the key that cannot repoint the wallet. |
what comes back
| status | media type | body | note |
|---|---|---|---|
| 201 | application/json | key: string, record: Key |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 400 | Unknown scope {scope}. One of: {scopes}. | The scope is not one of the two. | Send write or admin. The response echoes scopes beside the error. |
| 403 | That key may take payments but not change where they go. Use an admin key for this. | A write key asked an admin question. | Use the admin key. |
| 400 | The request body is not valid JSON. | A body was sent and it does not parse as JSON, whatever Content-Type said. Nothing was done: no row, no audit entry, no event. | Send one JSON object, or no body at all. A form-encoded body is never read here. |
| 400 | The request body must be a JSON object. | The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done. | Send the fields as one JSON object, or no body at all. |
Revoke one, including the one making the request.
Including your own, deliberately. Somebody who believes a key is compromised may well be holding that key, and a service that refuses to let them cut it off until they have minted a replacement has invented a reason to hesitate. thisKey in the response says plainly whether you have just cut your own line.
then usuallylistKeys
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | revoked: integer, thisKey: boolean |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 404 | No such key, or it was already revoked. | Gone, already revoked, or not yours. Deliberately one answer. | Read the list. Do not branch on it. |
| 403 | That key may take payments but not change where they go. Use an admin key for this. | A write key asked an admin question. | Use the admin key. |
wallet
The extended PUBLIC keys this shop is billed on, and where each lives.
Returned in full, and that is not a leak. An extended public key derives public addresses and cannot sign anything; it is the only half this server ever holds, and the whole design rests on that.
then usuallysetWallet, listAddresses
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | wallets: Wallet[] |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 403 | That key may take payments but not change where they go. Use an admin key for this. | A write key asked an admin question. | Use the admin key. |
Point a keyring at an extended public key, in two steps.
Post without firstAddress and the answer is 202 with the address that key derives at index 0, and NOTHING IS STORED. Post again with that address and it is stored. The pause is the feature: every wrong key parses — a typo, the wrong account, somebody else's key out of a support thread — and all of them make perfectly valid addresses nobody on earth can spend. There is nothing this server can check for you. The only instrument that can tell a right key from a wrong one is the merchant's own wallet showing them the same string.
unchanged: true.then usuallylistWallets, paymentsConfig
what you send · JSON body
| field | type | required | what it is |
|---|---|---|---|
| keyring | enum keyring | yes | BTC, LTC, ETH or TRX. |
| xpub | string | yes | The PUBLIC half. xpub, ypub, zpub, Ltub or Mtub. A private key is refused before anything reads it and is never named in the response or the audit trail. |
| firstAddress | string | no | The address at index 0, checked against the wallet itself. Omit it to be shown what to check. |
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | wallets: Wallet[], unchanged: boolean | |
| 202 | application/json | confirm: object | Understood, and deliberately not done yet. Nothing has been stored. |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 400 | That's a PRIVATE key. | The version bytes say the key can spend. The server must never hold one and refuses before setWallet, before the audit row, before anything that could write the value down. | Export the public half. The refused value is not echoed, not logged and not audited, deliberately: a trail entry saying somebody sent a private key at 14:03 would turn a caught mistake into a stored secret. |
| 400 | That isn't a valid extended key | The base58 checksum does not match. | Re-copy it. firstAddress: null rides beside every 400 from this route so both refusals have one shape. |
| 409 | already been issued | An address has been derived on that keyring, so the key can no longer be replaced. Replacing it would redirect invoices already in customers' hands. | Do not retry. Use a new keyring, or a new shop. |
| 403 | That key may take payments but not change where they go. Use an admin key for this. | A write key tried to decide where money goes. This is the boundary the two scopes exist for. | Use the admin key. |
| 400 | The request body is not valid JSON. | A body was sent and it does not parse as JSON, whatever Content-Type said. Nothing was done: no row, no audit entry, no event. | Send one JSON object, or no body at all. A form-encoded body is never read here. |
| 400 | The request body must be a JSON object. | The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done. | Send the fields as one JSON object, or no body at all. |
Every address this shop has been issued on one keyring.
What the spending half asks when it wants to know which addresses are its own. admin and not write, and this is the most privileged read in the API: one call enumerates a shop's ENTIRE receiving history. Void instructions are included deliberately — a superseded instruction keeps its address, that address was printed on an invoice somebody may still hold, and unless a later instruction on the same order was issued onto that same address and network, payd itself will never look at it again.
then usuallylistWallets
what you send · query string
| field | type | required | what it is |
|---|---|---|---|
| keyring | enum keyring | yes | In the query string. |
| since | integer | no | Resume from this index. |
| limit | integer | no | Indices, not rows. Up to 1000. |
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | keyring: enum keyring, nextIndex: integer, next: integer, addresses: Address[] | next is where to resume and is NOT nextIndex unless the page ran to the end. nextIndex is an upper bound on the counter, never a count. |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 400 | Unknown keyring {keyring}. One of: {keyrings}. | Not one of the four. | The response echoes keyrings. An unconfigured keyring is a 200 with an empty list, not this. |
| 403 | That key may take payments but not change where they go. Use an admin key for this. | A write key asked an admin question. | Use the admin key. |
webhooks
Where settlement notices are sent.
Behind admin for a reason related to the wallet's: a webhook body says what somebody paid and for which order, so a till key that could add an endpoint could quietly copy every settlement somewhere else.
then usuallyaddWebhook, listDeliveries
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | webhooks: Webhook[] |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 403 | That key may take payments but not change where they go. Use an admin key for this. | A write key asked an admin question. | Use the admin key. |
Add an endpoint — an https URL or a Telegram chat — and see a URL's signing secret once.
https only, and not as a formality: the body carries what somebody paid and for which order, and the signature proves who sent it but hides nothing. The secret is returned once and kept in the clear here — signing needs the secret itself, so hashing it would make the feature impossible rather than safer. Send telegram instead of url to have the same events posted to a Telegram chat by the merchant's own bot, as plain text for people to read: the endpoint's url is then telegram:<chatId>, nothing is signed, and the bot token is kept to send with and never returned. Both kinds share the cap of ten, the queue, the ladder and the breaker.
then usuallylistWebhooks, listDeliveries
what you send · JSON body
| field | type | required | what it is |
|---|---|---|---|
| url | string | no | https, and reachable. Send this or telegram, not both. |
| telegram | object | no | {botToken, chatId} for a Telegram chat instead of a URL: the token @BotFather gave the merchant's bot, and the chat's numeric id (negative for a group or a channel) or a public channel's @username. Send this or url, not both. |
what comes back
| status | media type | body | note |
|---|---|---|---|
| 201 | application/json | webhook: Webhook, secret: string, verify: string | An https endpoint answers all three keys. A Telegram chat answers webhook alone: nothing sent to a chat is signed, so there is no secret, and the bot token is never echoed. |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 400 | That is not a URL. | It did not parse. | Send an absolute https URL. |
| 400 | That is Telegram's Bot API, and a URL there carries your bot token where it would be listed and logged. Add the chat as a Telegram endpoint instead, with its bot token and chat id. | url points at api.telegram.org, which would put a bot token in a field that is listed, audited and logged. | Send telegram: {botToken, chatId} instead of url. |
| 400 | Send url for an https endpoint or telegram for a Telegram chat, not both. | The body carried both kinds of endpoint. | Send one of the two. |
| 400 | That is not a bot token. @BotFather gives you one when you create a bot: digits, a colon, then about 35 letters, digits, dashes and underscores. | telegram.botToken is missing or is not shaped like a Bot API token. | Paste the token exactly as @BotFather printed it. |
| 400 | That is not a chat id. Use the chat's numeric id (a group's starts with a minus sign) or a public channel's @username. | telegram.chatId is missing or is neither a numeric id nor an @username. | Send the chat's id as a string. |
| 400 | That chat is already registered. | This shop already has an endpoint for that chat. | Nothing to do; remove the existing one first to change its bot. |
| 403 | That key may take payments but not change where they go. Use an admin key for this. | A write key asked an admin question. | Use the admin key. |
| 400 | The request body is not valid JSON. | A body was sent and it does not parse as JSON, whatever Content-Type said. Nothing was done: no row, no audit entry, no event. | Send one JSON object, or no body at all. A form-encoded body is never read here. |
| 400 | The request body must be a JSON object. | The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done. | Send the fields as one JSON object, or no body at all. |
Stop delivering to an endpoint.
Cascades the delivery history away with it. If the endpoint is merely broken, enable it rather than removing and re-adding — that route keeps the history and the signing secret.
then usuallylistWebhooks
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | removed: integer |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 404 | No such webhook. | Gone, or not yours. | Do not branch on it. |
| 403 | That key may take payments but not change where they go. Use an admin key for this. | A write key asked an admin question. | Use the admin key. |
Turn an endpoint the breaker disabled back on.
The breaker exists so an endpoint that has failed five deliveries with nothing accepted in between stops being retried. This is the way back, and it is not a convenience: without it the only route from disabled to working was to remove and re-add, which cascades the whole delivery history away and issues a new secret every receiver would have to be reconfigured for. It does not promise the endpoint works — one accepted body clears the count.
then usuallylistDeliveries
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | enabled: integer |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 404 | No such webhook. | Gone, or not yours. | Do not branch on it. |
| 403 | That key may take payments but not change where they go. Use an admin key for this. | A write key asked an admin question. | Use the admin key. |
What was actually sent, and what happened to it.
The question somebody has when an integration is not working is 'did you try', and this answers it without them having to ask a person. Registered BEFORE /webhooks/:id would match, because Hono matches in registration order.
then usuallyenableWebhook
what you send · query string
| field | type | required | what it is |
|---|---|---|---|
| limit | integer | no | In the query. Newest first; defaults to 50, up to 200. Clamped, never refused: a fraction is truncated toward zero, a negative number reads as 1, and 0 or anything that is not a number is the default. |
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | deliveries: Delivery[] |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 403 | That key may take payments but not change where they go. Use an admin key for this. | A write key asked an admin question. | Use the admin key. |
orders
Register something to bill.
The order this service keeps is a shadow of yours: a reference, a currency, a total and a status. Nothing here knows what was bought. goodsCents: null is NOT zero — null means nobody has priced it yet and the order lands in quote_requested; zero means free, and a free order is registered already paid. Whose order this is comes from the key and never from the body: merchantId is spread LAST, so a body carrying one cannot choose.
then usuallyissuePayment, quoteOrder, getOrder
what you send · JSON body
| field | type | required | what it is |
|---|---|---|---|
| reference | string | no | Yours if you have one; minted if not. Unique within your shop only — asking globally would tell a stranger that another shop had issued that number. |
| currency | enum currency | no | A code this deployment can price a quote in, from the currency enum, sent upper case as listed. Omit it for USD. Anything else — another code, null, an empty or padded string, a number, an object — is refused with 400 and nothing is registered. |
| customerRef | string | no | Your handle for the person. |
| customerEmail | string | no | Never reaches /i. |
| locale | string | no | Defaults to en. |
| channel | enum channel | no | web holds goods from the moment it is placed; telegram holds nothing until money lands. |
| goodsCents | integer | no | Whole minor units, or null for not-priced-yet. Never a float. |
| shippingCents | integer | no | Whole minor units. |
| rails | string[] | no | Which coins the buyer may choose between on the invoice — BTC, LTC, ETH, USDT, USDC. Omit it and the shop's default decides; [] is refused, since an order offering nothing cannot be paid. A coin's networks come from the shop's default where it names that coin, and are every network the shop holds a key for where it does not. |
| assets | string[] | no | Which LEDGERS — a coin on one network, USDT_TRC20 — the buyer may choose between, the same codes as asset on the invoice's coin menu. Says the coins as well, so it is refused beside rails. The invoice then offers and bills these alone: an order offering Tether on Tron is never billed on ERC-20. |
| metadata | object | no | Your blob. Nothing reads it. |
what comes back
| status | media type | body | note |
|---|---|---|---|
| 201 | application/json | order: Order, invoiceUrl: string | invoiceUrl is the page to send the buyer to: absolute, built from APP_URL or, with that unset, from the origin this request arrived at, and never a relative path. It is order.publicToken composed against this deployment, so it is a CREDENTIAL rather than an order number — anybody holding the link can see the invoice. Send it to the customer; keep it out of your logs. |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 400 | Reference {reference} is already in use. | THIS IS THE DUPLICATE ANSWER, and a retry after a timeout must read it as ALREADY REGISTERED rather than as a bad request. | Read the order back with GET /api/orders/{reference}. It is deliberately not a 409 — there is no request de-duplication anywhere in this API and no Idempotency-Key header, so your own reference is the de-duplication. |
| 400 | {field} must be a whole number of cents. | A money figure was fractional, negative, or not an integer. | Integer minor units. A float that looks right in your language is a float that has already lost a cent somewhere. |
| 400 | currency must be one of the currencies this service can price: {codes}. | A code no rate can be struck in, or a value that is not a code at all: null, an empty or padded string, a number, an object. Every rate is struck in the codes listed, so an order in anything else would be quoted the wrong amount. Nothing was registered. | Send one of the codes in the currency enum, or omit the field for USD. Until more currencies can be priced, convert to US cents on your side before registering. |
| 400 | rails must name only coins this service settles: {codes}. | A value in rails that is not a coin code, on any day. Nothing was registered. | Send codes from the list, or omit rails to let the shop's default decide. |
| 400 | assets must name only ledgers this service settles: {codes}. | A value in assets that is not a ledger code — USDT is a coin, USDT_TRC20 is a ledger. Nothing was registered. | Send ledger codes from the list, or send rails instead to name coins without networks. |
| 400 | Send rails or assets, not both: assets already says which coins. | Both menus in one body. A ledger names its coin, so the two could disagree, and this door does not choose which half you meant. | Send assets alone when you are naming networks, rails alone when you are not. |
| 400 | The request body is not valid JSON. | A body was sent and it does not parse as JSON, whatever Content-Type said. Nothing was done: no row, no audit entry, no event. | Send one JSON object, or no body at all. A form-encoded body is never read here. |
| 400 | The request body must be a JSON object. | The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done. | Send the fields as one JSON object, or no body at all. |
Every order this key may read, newest first, a page at a time.
The index to reconcile against, and the one a connector polls for a list rather than for one reference. A PAGE, NEWEST REGISTERED FIRST: a shop with more than 100 orders gets the first 100 and follows next until it is null. There is no merchant in the query string because there is nothing to put one in — the key already said whose orders these are, and the read is scoped in SQL, so another shop's cursor is not refused and not honoured: it is a number, and the page it names is your own.
Pages are keyed on the order id, which only ever increases, so one walk serves each order exactly once. NEWEST FIRST DECIDES WHAT A WALK SEES, and it is the opposite of GET /api/payments/outstanding: an order registered DURING a walk is above the cursor and that walk never reaches it. Start each poll with no cursor — following next for ever is how a poller would wait on a page that by construction cannot arrive. An order whose status changes mid-walk keeps its position, since the position is the id.
This is NOT GET /api/payments/outstanding under another name. That route answers open crypto INSTRUCTIONS and leaves out every order that is settled, cancelled or was never billed; this one answers orders, whatever state they are in.
then usuallygetOrder, issuePayment
what you send · query string
| field | type | required | what it is |
|---|---|---|---|
| limit | integer | no | In the query. Orders per page: defaults to 100, up to 500. Clamped, never refused: a fraction is truncated toward zero, a negative number reads as 1, and 0 or anything that is not a number is the default. |
| cursor | string | no | In the query. The next of the page before, sent back unchanged; leave it out or empty to start from the newest. A position, never a row: it is not looked up, so any id answers this shop's orders below it. |
| status | enum orderStatus | no | In the query. One status from the enum, exactly as listed; omit it for every status. Anything else is a 400 rather than an empty page — a typo that filtered silently would read as a shop with no orders in that state. |
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | orders: Order[], next: string or null | next is the cursor for the following page, or null on the last one — which can be full. Treat it as opaque: it is a string so its format can change without re-typing the field. Each row is THE SAME Order GET /api/orders/:reference answers, and there is no second, narrower order shape to learn. That includes publicToken, which is a buyer's whole authorisation for that invoice, so ONE PAGE CARRIES ONE CREDENTIAL PER ORDER: keep the response out of your logs. There is no invoiceUrl on a row — it is composed per request for the one order in hand, and a page of composed links would put a credential URL per order on the wire; build one from publicToken, or read the order back. Nothing else about a row is bounded, and payments is not on it: an order's instruction history is on the single read. |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 400 | That is not a cursor this route hands out. Send next back exactly as it came, or leave cursor out to start from the beginning. | Under errors.cursor. The cursor is not at most sixteen digits naming a safe integer, so this route never handed it out. Whether an id exists, and whose it is, is never asked, so the answer is the same for every shop — and it is the same sentence GET /api/payments/outstanding refuses with. | Send next back exactly as it came, or leave cursor out to start again from the newest. |
| 400 | status must be one of the statuses an order can be in: {statuses}. | Under errors.status. A status outside the orderStatus enum, or one differing by case or padding. Nothing was filtered and no page was read. | Send one value from the orderStatus enum, spelled as listed, or leave status out for every status. |
| 401 | Unauthorized. | Unknown key, revoked key or suspended shop. | Check the key. |
Read one order back, with its outstanding instruction.
The reconciliation call. A webhook is a doorbell; this is the truth. The lookup is scoped by the calling merchant in SQL, so an order belonging to somebody else is not refused — it does not exist.
then usuallyissuePayment, listRefunds
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | order: Order, payment: OrderPayment or null, payments: Payment[], paymentsOmitted: integer, invoiceUrl: string, state: enum invoiceState | invoiceUrl is the same absolute link POST /api/orders hands back, composed again from this request: APP_URL when the deployment names one, otherwise the origin the call arrived at, and never a relative path. It carries the order's publicToken, so treat it as a credential and not as a reference. payment is the outstanding one, or null, and is never bounded. payments is the newest 50 instructions issued against the order, newest first, and paymentsOmitted counts the older ones left out — 0 when there are none, and always present. It stops counting at 1000, so 1000 means at least that many. An older instruction stays readable through GET /api/payments/:id, and every one still being watched is on GET /api/payments/outstanding, including a lapsed one kept watched beside its replacement. POST /api/payments/:id/confirm and settle-short still answer with every instruction on the order.
THIS IS THE CALL THAT ANSWERS "MAY I SHIP". state is the verdict, one of the 6 values GET /i/:token/state uses, struck through the same function the chain sweep uses — so this route cannot reach a conclusion the watcher would not. It is on the ENVELOPE and not on the instruction, because an order nobody billed and a free order that settled both have a state and neither has a payment. On the live instruction, settlement is one of none, short, exact, over and outstanding is what is still owed as an exact decimal string in the asset's own units — "0" whenever nothing more is owed, and the whole quoted figure while nothing has arrived. Compare outstanding as a string and never parse it: an eighteen-decimal remainder does not survive a float. requiredConfirmations, on the instruction and on every leg, is how deep THIS deployment needs it. Do not derive any of this yourself — two of the inputs are this deployment's own environment, and GET /api/payments/config publishes them.
AND A PAID ORDER OUTRANKS ITS INSTRUCTION, which matters because payment is the newest STANDING instruction and is not always the row that settled. An order recorded paid some other way — a bank transfer through POST /api/orders/:reference/status, or late money settling a lapsed instruction beside its replacement — answers state settled and outstanding "0" with an instruction still awaiting beside it, and settlement none, because nothing arrived on THAT instruction. A CANCELLED order keeps its instruction's own state, since a withdrawn request can still hold a lapsed instruction that took late money and short is the true thing to say about it; what cancelling decides is that nothing is owed, so outstanding is "0" there too. state is enough to decide whether to ship — nothing but a paid order reaches settled — and order.status is what tells a cancelled order from a live one.
payments[] is unchanged and carries none of the four: those are raw staff rows, shared with GET /api/payments/outstanding, GET /api/payments/:id and the CLI, none of which is answering "may I ship" about a live instruction. |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 404 | No such order. | Gone, not yours, or never existed. Byte-identical in all three cases. | Do not branch on it and do not log it as a bug. |
Fill in a price that was outstanding.
For the quote lane only. Refused once an instruction could have been issued against the old figure: an order in awaiting_payment has a payment quoting a specific amount, and moving the total underneath it would leave the customer paying a number that no longer matches what they owe.
then usuallyissuePayment
what you send · JSON body
| field | type | required | what it is |
|---|---|---|---|
| goodsCents | integer | no | Whole minor units. |
| shippingCents | integer | no | Whole minor units. |
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | order: Order |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 409 | An order that's {status} can't be re-priced. | It has left the quote lane. | Issue a new instruction instead, which supersedes the old one properly. |
| 400 | {field} must be a whole number of cents. | A money figure was not an integer. | Integer minor units. |
| 400 | The request body is not valid JSON. | A body was sent and it does not parse as JSON, whatever Content-Type said. Nothing was done: no row, no audit entry, no event. | Send one JSON object, or no body at all. A form-encoded body is never read here. |
| 400 | The request body must be a JSON object. | The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done. | Send the fields as one JSON object, or no body at all. |
Move an order along by hand.
Bounded by the transition table, and by one guard the table cannot express: placed and quoted may reach paid directly ONLY for an order whose final total is zero. That opening exists because a free order has no instruction to issue and nothing to match; it is not a way to mark a billable order paid without money.
then usuallygetOrder
what you send · JSON body
| field | type | required | what it is |
|---|---|---|---|
| status | enum orderStatus | yes | Where to move it. |
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | order: Order |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 409 | An order that's {from} can't become {to}. | Not a legal transition. See the table. | Read the order first. |
| 409 | That order still has to be paid for. | A billable order was asked to become paid without an instruction ever having been matched. | Issue an instruction and let the chain settle it, or confirm the payment by hand and say so. |
| 409 | That order needs a price before it can be confirmed. | Committing to an order nobody has priced would mean charging an unknown total. | Quote it first. |
| 400 | The request body is not valid JSON. | A body was sent and it does not parse as JSON, whatever Content-Type said. Nothing was done: no row, no audit entry, no event. | Send one JSON object, or no body at all. A form-encoded body is never read here. |
| 400 | The request body must be a JSON object. | The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done. | Send the fields as one JSON object, or no body at all. |
Replace your own blob on an order.
A wholesale replace and not a merge: an empty body blanks it and answers 200. The audit row records how many keys were lost rather than what they were — this trail is per-tenant and APPEND ONLY, so copying arbitrary merchant data into it would write it somewhere no later call can delete it from.
then usuallygetOrder
what you send · JSON body
| field | type | required | what it is |
|---|---|---|---|
| metadata | object | no | Omit it to blank. |
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | order: Order |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 404 | No such order. | Gone, or not yours. | Do not branch on it. |
| 400 | The request body is not valid JSON. | A body was sent and it does not parse as JSON, whatever Content-Type said. Nothing was done: no row, no audit entry, no event. | Send one JSON object, or no body at all. A form-encoded body is never read here. |
| 400 | The request body must be a JSON object. | The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done. | Send the fields as one JSON object, or no body at all. |
billing
Issue a payment instruction against an order.
Works out a fresh receiving address from this shop's extended PUBLIC key, locks a rate, and returns what the customer must send. Nothing here moves money and nothing here can: the server has no private key. The derivation index is held per order per keyring, so billing the same order twice on the same coin returns the same address. An order that costs nothing settles instead, with payment: null and free: true, so no address is worked out and nothing can be sent anywhere — unless it is stored in a currency this service cannot price and is still waiting to be billed, which is refused with 409 rather than settled.
then usuallygetOrder, requotePayment, declarePaid, invoiceState
what you send · JSON body
| field | type | required | what it is |
|---|---|---|---|
| rail | enum rail | no | A TICKER. Given alone, the instruction is born on the first network this order offers for it, and the invoice does not let the customer move it: name the network with asset, or leave the order unbilled so the customer picks the coin and network on the invoice before an address exists. |
| asset | enum asset | no | A LEDGER. Narrows to one network and sets the rail from it. |
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | order: Order, payment: CustomerPayment or null, free: boolean | payment is the instruction issued, or null on an order that costs nothing, which settles instead and answers free: true. |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 400 | No {rail} wallet is configured. | This shop holds no extended public key for that coin. | Call GET /api/payments/config and offer only the rails it returns. |
| 400 | Unknown payment rail. | Not one of the five tickers. | Read rails off GET /api/payments/config. |
| 409 | An order that's {status} isn't waiting to be billed. | The order's status forbids a new instruction. | Read the order first. Do not retry. |
| 409 | Price every line and the shipping before asking for payment. | The total is still null, and null is not zero. | Quote it. |
| 409 | That order is in a currency this service cannot price, so no rate was struck and nothing was issued. | The order was registered, before POST /api/orders began refusing it, in a currency no rate can be struck in, and it is still waiting to be billed. Billing one that costs something would have asked for its number of dollars; one that costs nothing is refused too, rather than settled paid. A paid or cancelled order gets the answer it always did instead (its status refusal, or free: true for a paid one that costs nothing), and so does one with money already seen on its newest instruction (the 400 about a payment on its way). Nothing was written, and the order is left as it is. | Do not retry. Read the order first. If it is paid, or money has been seen on any of its payments, do not cancel it: that money is for this order. If it costs nothing, mark it paid with POST /api/orders/:reference/status. Otherwise cancel it and register the order again, under a new reference, in a currency from the currency enum. |
| 409 | That merchant is suspended, so nothing can be billed to them. | The shop is switched off. Money already in flight still arrives and still settles. | Nothing you can do from here. |
| 409 | This shop has {n} invoices open, and its {plan} plan allows {ceiling} at once, so a new one cannot be issued. Invoices already issued are not affected: they are still watched and still settle. Change plan at /app/billing, or wait until an open invoice is paid, expires or is cancelled. | This deployment sells plans, and this shop already has as many invoices open as its plan allows at once: orders awaiting payment, and any order holding an instruction still awaiting payment, whatever its status, so moving an order to expired or paid through the status route frees no slot while its instruction is still awaiting. Only a NEW invoice is refused: billing again an order that is still awaiting payment on an instruction already issued is not, while an expired order (one moved back to awaiting payment included), or one created straight into pending_payment with nothing issued, counts as new. Nothing already issued stops being watched or settling. Never answered where PAYD_HOUSE_MERCHANT is unset, for the house merchant, or for an order that costs nothing. | The sentence is written for the merchant and names their plan, so do not show it to a customer; tell them the shop cannot take this payment right now. Tell this refusal apart from every other 409 here by its code, which is plan_ceiling on this one and absent on the rest, and never by matching the sentence. Try again once an open invoice is paid, expires or is cancelled, or once the plan is changed. |
| 429 | Too many attempts. Try again in {n}s. | Twenty instructions in sixty seconds for this shop. | Honour Retry-After. The bucket is per merchant, not per order, so using more references does not buy more budget. |
| 400 | The request body is not valid JSON. | A body was sent and it does not parse as JSON, whatever Content-Type said. Nothing was done: no row, no audit entry, no event. | Send one JSON object, or no body at all. A form-encoded body is never read here. |
| 400 | The request body must be a JSON object. | The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done. | Send the fields as one JSON object, or no body at all. |
Refresh the crypto amount against the current rate.
The rate lock is short precisely so that it can be redone, and a customer who takes an hour to get to their wallet should not have to contact anybody. The rate limit is only there to stop this being used as a free proxy to the price feed. The old figure is recorded in the audit trail before it is overwritten, because the payment row is the only surface that carried it. The invoice page does this by itself on the first load after a lock lapses, when nothing has been seen on the instruction AND a live chain read finds nothing arriving at its addresses; this route is how a merchant re-quotes anything else, a live lock or an instruction with money against it included, and it never reads the chain because the merchant's application is deciding on purpose.
then usuallyinvoiceState, getOrder
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | payment: CustomerPayment |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 404 | There's nothing to pay on that order. | No outstanding instruction. | Issue one. |
| 409 | That payment is already settled. | It confirmed. | Nothing to do. |
| 409 | That payment was superseded. | A newer instruction replaced it. | Read the order and use the current one. |
| 409 | That payment is in a currency this service cannot price, so it was not re-quoted. | The instruction or its order is stored in a currency no rate can be struck in, from before POST /api/orders began refusing one. Its quote is left exactly as it was. | Do not retry. The instruction still settles at the quote it has. Read the order first: if it is paid, or money has been seen on any of its payments, do not cancel it, and let that money settle. Its payments are what to read, not only the instruction this answers for: an older one lapsed and still watched can hold that money. Otherwise, to ask for a different amount, cancel the order and register it again, under a new reference, in a currency from the currency enum. |
| 429 | Too many attempts. Try again in {n}s. | Fifteen in sixty seconds for this shop. | Honour Retry-After. |
'I've sent it' — which the server goes and checks.
Two things it deliberately does NOT do. It does not settle unless the chain watcher is switched on, because confirming from here is still an unattended release of goods. And it does not decide anything itself: the verdict is struck by the same function the sweep uses, so a customer pressing a button cannot reach a conclusion the sweep would not have reached about the same address. The declaration is recorded in every branch, including the ones that resolve.
then usuallygetOrder
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | order: Order, payment: CustomerPayment, check: Check |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 404 | There's nothing to pay on that order. | No outstanding instruction. | Issue one. |
| 409 | That instruction was superseded. | A newer instruction replaced it. | Read the order and use the current one. |
| 429 | Too many attempts. Try again in {n}s. | Six in sixty seconds for this shop — tighter than its neighbours because each call is an outbound request to a public explorer. | Honour Retry-After. Poll GET /api/orders/<reference> instead. |
refunds
What has been sent back, and what is still owed.
position.owedCents is zero unless money actually arrived — an unpaid order that gets cancelled owes nothing, however dramatic the cancellation looked. position.payment is a raw SQL row and is snake_case; the refunds beside it are not.
then usuallyrecordRefund
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | refunds: Refund[], position: RefundPosition |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 404 | No such order. | Gone, or not yours. | Do not branch on it. |
Write down that money was sent back.
SENDS NOTHING. payd cannot move money — it holds public keys only. The transfer happens by hand, from the merchant's own wallet; this is where it is written down, so what a customer is owed stays answerable months later. Both of the things that decide whose money this is are positional arguments and never body fields, because a body that could overwrite them was the worst defect this project has had.
then usuallylistRefunds
what you send · JSON body
| field | type | required | what it is |
|---|---|---|---|
| amountCents | integer | yes | Whole minor units. |
| destination | string | yes | Where the money went. |
| asset | enum asset | no | Which ledger it went out on. |
| assetAmount | decimal string | no | Exact decimal string. Never a float. |
| externalRef | string | no | The transaction id. |
| note | string | no | Why. |
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | refund: Refund, position: RefundPosition |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 409 | Nothing was ever paid on that order, so there's nothing to send back. | No confirmed instruction on the order. | Nothing to do. |
| 400 | Enter an amount to refund. | Missing, zero, negative or fractional. | Whole minor units, above zero. |
| 400 | is left to refund. | More than is outstanding. The sentence names what is left. | Read position.owedCents first. |
| 400 | Say where the money went. | No destination. | An address, an account, a sentence — whatever a person would need in six months. |
| 400 | The request body is not valid JSON. | A body was sent and it does not parse as JSON, whatever Content-Type said. Nothing was done: no row, no audit entry, no event. | Send one JSON object, or no body at all. A form-encoded body is never read here. |
| 400 | The request body must be a JSON object. | The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done. | Send the fields as one JSON object, or no body at all. |
Where the customer would like it sent.
Theirs to set, and only useful before the money goes out. Audited with its previous value, because a leaked write key can repoint this on an order awaiting a manual refund and the operator would then record the refund against the substituted address. The pair of values is what makes a substitution visible after the fact instead of invisible. Never shown on /i — it is an address the customer gave in confidence.
then usuallyrecordRefund
what you send · JSON body
| field | type | required | what it is |
|---|---|---|---|
| destination | string | no | Omit or blank it to clear. |
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | order: Order |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 404 | No such order. | Gone, or not yours. | Do not branch on it. |
| 400 | The request body is not valid JSON. | A body was sent and it does not parse as JSON, whatever Content-Type said. Nothing was done: no row, no audit entry, no event. | Send one JSON object, or no body at all. A form-encoded body is never read here. |
| 400 | The request body must be a JSON object. | The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done. | Send the fields as one JSON object, or no body at all. |
payments
What this shop can actually be billed on right now.
Draw your payment picker from here rather than hardcoding rails: a rail with no wallet behind it is not on offer. The answer is the CALLER's and not the deployment's — answering with the operator's configuration would offer a shop rails it cannot be paid on.
then usuallyissuePayment
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | methods: string[], rails: object[], assets: string[], wallets: object[], policy: Policy | THE FIRST FOUR ARE THE CALLER'S AND policy IS THE DEPLOYMENT'S, and the pair to read carefully is policy.assets beside assets. The top-level assets is what THIS SHOP can be billed on, which depends on the keys the merchant holds; policy.assets is every ledger this build knows, with figures that are the same for everybody here. Read policy once at start-up instead of assuming the defaults: CHAIN_CONFIRMATIONS_<CHAIN> and CHAIN_TOLERANCE_<ASSET> are this deployment's own environment and are read on every request, so an adapter that hardcoded a depth or a dust allowance is wrong wherever an operator moved one — and cannot tell. decimals, quoteDecimals and chainId are build constants rather than settings, and are published here because an adapter needs all of them in the same breath: precision decides how a remainder is written down, and the EIP-155 chain id is what makes a payment URI safe rather than dangerous to build. |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 401 | Unauthorized. | Unknown key, revoked key or suspended shop. One answer for all three. | Check the key. |
Every crypto instruction still being watched, for this shop, a page at a time.
The sweep looks at every shop's, because one left out of it never settles; a request only ever gets its own. A PAGE, oldest issued first: a shop with more than 100 open instructions gets the first 100 and follows next until it is null. Pages are keyed on the instruction id, so one walk serves each row exactly once — a row that settles or is withdrawn before its page is read is simply absent, and a row issued during the walk appears on a later page. Start each poll with no cursor. Registered before /api/payments/:id because Hono matches in registration order and a bare :id would swallow it.
then usuallygetPayment, confirmPayment
what you send · query string
| field | type | required | what it is |
|---|---|---|---|
| limit | integer | no | In the query. Rows per page: defaults to 100, up to 500. Clamped, never refused: a fraction is truncated toward zero, a negative number reads as 1, and 0 or anything that is not a number is the default. |
| cursor | string | no | In the query. The next of the page before, sent back unchanged; leave it out or empty to start from the beginning. A position, never a row: it is not looked up, so any id answers this shop's rows after it. |
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | payments: Payment[], next: string or null | next is the cursor for the following page, or null on the last one — which can be full. Treat it as opaque: it is a string so its format can change without re-typing the field. |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 400 | That is not a cursor this route hands out. Send next back exactly as it came, or leave cursor out to start from the beginning. | Under errors.cursor. The cursor is not at most sixteen digits naming a safe integer, so this route never handed it out. Whether an id exists, and whose it is, is never asked, so the answer is the same for every shop. | Send next back exactly as it came, or leave cursor out to start again. |
| 401 | Unauthorized. | Unknown key, revoked key or suspended shop. | Check the key. |
One instruction, with the order it bills.
A payment id is a small sequential integer, so the lookup is scoped by the calling merchant in the SQL rather than compared afterwards — anything that would hand a handler a payment it did not scope is something an attacker reaches by counting from one.
then usuallyconfirmPayment, settleShort
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | payment: Payment, order: Order |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 404 | No such payment. | Gone, or not yours. | Do not branch on it. |
Record by hand that money arrived in full.
A person deciding, which is the only thing that can decide this when the chain has not. The status codes travel in the action's result rather than being chosen by the transport, so this route and the merchant portal cannot drift.
then usuallygetOrder
what you send · JSON body
| field | type | required | what it is |
|---|---|---|---|
| receivedCents | integer | no | Whole cents, or leave it out. |
| externalRef | string | no | A transaction id. |
| note | string | no | Why. |
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | order: Order, payment: Payment, payments: Payment[] |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 400 | Whole cents, or leave it out. | receivedCents was not an integer. | Integer minor units, or omit the field. |
| 409 | That payment is already confirmed. | It settled. | Nothing to do. |
| 409 | That instruction was superseded | A newer instruction replaced it. | Read the order and confirm the current one. |
| 404 | No such payment. | Gone, or not yours. | Do not branch on it. |
| 400 | The request body is not valid JSON. | A body was sent and it does not parse as JSON, whatever Content-Type said. Nothing was done: no row, no audit entry, no event. | Send one JSON object, or no body at all. A form-encoded body is never read here. |
| 400 | The request body must be a JSON object. | The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done. | Send the fields as one JSON object, or no body at all. |
Accept a payment that landed short.
THE ONLY WAY A SHORTFALL EVER SETTLES. Nothing automated promotes one, however deeply confirmed, however long it waits — see GUIDES.settlement. The note is demanded before anything else is even looked at, because the note is the whole record of why a shop accepted less than it asked for. The shortfall settles at the rate the customer was quoted and never at what was asked.
then usuallygetOrder
what you send · JSON body
| field | type | required | what it is |
|---|---|---|---|
| note | string | yes | Say why this is being accepted short. Refused without it. |
| externalRef | string | no | A transaction id. |
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | order: Order, payment: Payment, payments: Payment[] |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 400 | Say why this is being accepted short. | No note. | Write one. It is the record. |
| 409 | Only a crypto payment settles from the chain. | There is no ledger to read. | Confirm it by hand instead. |
| 409 | Nothing has been seen at that address yet. Use confirm if you're recording it by hand. | There is no shortfall to accept, because there is no arrival. | Use confirm. |
| 409 | That payment is already settled. | It confirmed. | Nothing to do. |
| 400 | The request body is not valid JSON. | A body was sent and it does not parse as JSON, whatever Content-Type said. Nothing was done: no row, no audit entry, no event. | Send one JSON object, or no body at all. A form-encoded body is never read here. |
| 400 | The request body must be a JSON object. | The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done. | Send the fields as one JSON object, or no body at all. |
invoice
What is happening to one invoice, as JSON.
No key, and there never will be one: the customer holds none. The 131-bit token in the path is the entire authorisation. Deliberately flat and complete rather than something a page must assemble from several calls — a payment page that renders half a state while a second request is in flight is a payment page that shows the wrong amount for a moment. Inside VERSIONS.md's compatibility promise, unlike the invoice PAGE. The first read after the rate lock lapses, on an awaiting or expired instruction with nothing seen on chain on any leg, re-strikes the price at the current rate before answering: the same re-quote a merchant's key makes, once per lock, filed as payment.requote. The address and amountCents do not move. A lapsed price is re-struck ONLY when a live read, taken for that load, of every address that instruction has shown says nothing is arriving — any amount anywhere, confirmed or not, no chain reader configured for the asset, an explorer that fails or takes longer than 1.5 seconds, a leg that could not be read, and a reading that had already settled when the load arrived all leave the quote exactly as it is. That read records nothing. An instruction money has arrived against is never re-struck, nor one the chain sweep or a payment check is reading at that moment, and a re-strike that cannot get a price answers the stored figures; the page then leaves that ticker alone for 30 seconds. A HEAD never re-strikes and never reads a chain.
then usuallyinvoiceEvents
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | application/json | InvoiceState |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 404 | Not found. | A wrong-length token, a well-formed unknown one, and a deleted order all produce the same four words. | Nothing. An endpoint that distinguished them would help somebody narrow a search. |
| 429 | Too many attempts. Try again in {n}s. | Sixty a minute per TOKEN once it resolves, or twenty a minute per IP on MISSES. Keyed differently because they defend against different things. | Poll less often, or hold the event stream open instead. |
The WebSocket the invoice page patches itself from.
DO NOT BUILD AGAINST THIS. It is the private wire between the invoice page and the server, and what it carries is that page's own rendered MARKUP — a frame of {rev, title, accent, live, regions} where every region is a fragment of HTML the server struck for the four elements it names. The markup is outside VERSIONS.md's promise exactly as the page is, and a consumer parsing these frames is scraping a page over a socket. Build against /state, which is the shape that holds still, and hold this open only if you are the page. A frame arrives when the invoice moves and one arrives the moment the socket opens, which closes the race between the page being rendered and its socket connecting; {beat: true} is the idle keep-alive and means nothing. Nothing a client sends is read — the connection is one-way by design. Not rate limited by token, for the same reason the event stream is not: it is meant to be held open for the length of a payment.
then usuallyinvoiceState
what comes back
| status | media type | body | note |
|---|---|---|---|
| 101 | application/json | not JSON — see the note | The handshake. What follows on the connection is JSON frames of rendered markup — no schema, by design. |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 404 | Not found. | Unknown token. | Do not branch on it. |
| 426 | This route takes a WebSocket. | An ordinary GET, or a HEAD, which Hono runs through the same handler. The route says what it is and nothing about the invoice. | Open a WebSocket, or read /state instead. |
Server-sent events, for a deployment the socket cannot get through.
NOT JSON. text/event-stream, held open for the length of a payment. It says only that something moved and what kind of thing it was, and the reader then asks /state what the invoice is now — so exactly one thing decides what a state looks like. A stream carrying its own rendering data would be a second one, and the two would drift. THE PAGE FALLS BACK TO THIS, and the fallback is not theoretical: a WebSocket upgrade needs proxy_set_header Upgrade in front of it and that is not nginx's default, while an SSE response passes a stock reverse proxy. Unlike the socket, this one is safe to build against — it carries no markup, only the fact that something changed. Not rate limited by token, because this connection is meant to be held open and counting it against a per-minute budget would disconnect precisely the customer who is behaving correctly.
then usuallyinvoiceState
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | text/event-stream | not JSON — see the note | A stream of named events. No JSON schema, by design — read /state for the state. |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 404 | Not found. | Unknown token. | Do not branch on it. |
The invoice itself, as a page for a person.
DO NOT BUILD THIS PAGE. It already shows the amount, the address, a code to scan, the coin and network choice before anything is issued, and the live state, and it is the only surface with the information to do it. Server-rendered with one script whose hash is named in the policy; form-action 'self' means the one form on it — the coin menu, see invoicePickCoin — can submit here and nowhere else. It updates itself over the WebSocket at /socket, which pushes server-rendered markup for the four regions that can change — the page decides nothing — and falls back to the event stream at /events and then to a ninety-second meta refresh, so it stays correct with no script at all. Once an instruction is issued the page offers no other network or coin: ?network=<ASSET>, which used to move the instruction and answer 303, is no longer read, and a link carrying it answers 200 with the page as it stands. ?coin=<RAIL> moves nothing either: it is the coin menu's second step, drawn for a coin the order offers on two or more networks, with one button per network and a way back to the bare page; it writes nothing, reads no chain and answers 200, and any other value renders the page as though it were absent. The first load after the rate lock lapses, on an instruction a live chain read finds nothing arriving at, re-strikes the price at the current rate before rendering, exactly as /state does, so the figure, the code and the wallet link are never older than the lock; when it could not be re-struck the page says the price has lapsed and will be refreshed rather than printing a hold that has ended. A HEAD never re-strikes and never reads a chain. The MARKUP is outside VERSIONS.md's promise — do not scrape it.
then usuallyinvoiceState
what you send · query string
| field | type | required | what it is |
|---|---|---|---|
| coin | enum rail | no | In the query. The coin menu's second step: shows that coin's networks as the buttons that bill it. Changes nothing. Silently ignored unless it names a coin this order offers on two or more networks, on an order still choosing its coin: none is issued, and it is not cancelled, free, unpriced or a withdrawn period's. |
what comes back
| status | media type | body | note |
|---|---|---|---|
| 200 | text/html | not JSON — see the note |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 404 | Not found. | Unknown token. | Do not branch on it. |
| 429 | Too many attempts. Try again in {n}s. | Sixty a minute per token, or twenty a minute per IP on misses. | Honour Retry-After. |
The buyer picks which coin to be billed in.
DO NOT BUILD THIS EITHER — it is the invoice page's own form, and the page draws it. An order that offers more than one coin shows a menu instead of an address, and this is what pressing a coin does: it issues the instruction, which strikes a rate, derives an address and starts the hold. A POST and not a link, deliberately: a coin pick strikes a PRICE, and a price must not be struck by a link unfurler following a URL out of a chat client. It is the one reason this page's policy carries form-action 'self' rather than 'none'. Send rail for a coin with one network behind it, or asset to name the ledger on a coin that settles on several — which the page makes the buyer do, on a second step at ?coin=<RAIL>, because BNB Smart Chain reuses Ethereum's addresses and money sent on the wrong one arrives somewhere nothing is watching. A coin the order does not offer, a body naming neither, and an order that already has an instruction are all answered by redrawing the page, with the menu still on it.
then usuallyinvoicePage
what you send · JSON body
| field | type | required | what it is |
|---|---|---|---|
| rail | enum rail | no | Form-encoded. The coin to be billed in. Must be one this order offers. |
| asset | enum asset | no | Form-encoded, and takes precedence over rail. The ledger to be billed on, for a coin that settles on more than one; its rail must be one this order offers. |
what comes back
| status | media type | body | note |
|---|---|---|---|
| 303 | text/html | not JSON — see the note | Always. The Location is the invoice, whether the pick was taken or refused — the page is the answer. |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 404 | Not found. | Unknown token. | Do not branch on it. |
| 429 | Too many attempts. Try again in {n}s. | Sixty a minute per token. | Honour Retry-After. |
The buyer opens a SideShift.ai swap to pay in a coin the invoice does not take.
DO NOT BUILD THIS EITHER — it is the invoice page's own form, drawn only on a deployment that has configured SideShift.ai (SIDESHIFT_SECRET_KEY and SIDESHIFT_AFFILIATE_ID). It asks SideShift.ai for a FIXED-RATE swap whose output is this instruction exactly as it stands — its own ledger, its own address, its own quoted figure — and records the deposit the buyer must make on the timeline as a shifted line, which the page then draws in place of the address. Nothing in payd moves: no price is struck, no address derived, no instruction issued; the chain watcher settles the order when the swapped coins arrive, as it settles any other payment. Once a swap is open the page no longer re-strikes the instruction's price, so the swap's figure is the one the arrival is judged against. The buyer names a refund address, which is fixed on the swap when it opens so a wrong or late deposit can only go back to them. The buyer's IP address is sent to SideShift.ai, which requires it to refuse the regions it does not serve, and is recorded nowhere. Every outcome is a 303 to the invoice: a refusal adds ?swap=region, small, large, refund (no refund address SideShift accepts) or unavailable, which the page turns into a sentence; one this page decided (no swap on offer, a coin not on its list, a swap already open) adds nothing.
then usuallyinvoicePage
what you send · JSON body
| field | type | required | what it is |
|---|---|---|---|
| deposit | string | yes | Form-encoded. COIN:network as SideShift.ai names them, e.g. DOGE:doge. Must be one the page's own list offered for this instruction. |
| refund | string | yes | Form-encoded. The buyer's own address on the deposit coin's network, where SideShift.ai sends a wrong or late deposit back. Fixed when the swap opens, sent to SideShift.ai and kept nowhere in payd. Missing, or not printable text, answers ?swap=refund. |
| refundMemo | string | no | Form-encoded. The memo or tag the refund address needs, on networks that use one. |
what comes back
| status | media type | body | note |
|---|---|---|---|
| 303 | text/html | not JSON — see the note | Always. The Location is the invoice, with ?swap=<reason> when SideShift.ai refused. |
what it refuses
| status | the sentence | what it means | what to do |
|---|---|---|---|
| 404 | Not found. | Unknown token. | Do not branch on it. |
| 429 | Too many attempts. Try again in {n}s. | Sixty a minute per token. The per-address limit (ten in ten minutes, an IPv6 /64 counted as one address), and the budgets spent only by a press about to ask SideShift.ai (fifteen a minute per merchant, thirty across the deployment), answer a 303 with ?swap=unavailable instead, because they refuse a person at a form. | Honour Retry-After. |
the closed sets
| set | members |
|---|---|
| orderStatus | quote_requested placed quoted awaiting_payment pending_payment paid expired cancelled |
| paymentStatus | awaiting confirmed expired void |
| invoiceState | settled none short confirming expired awaiting |
| settlement | none short exact over |
| scope | write admin |
| event | payment.sighted payment.short payment.settled payment.settle-refused payment.expired order.status |
| webhookEvent | payment.settled payment.settle-refused payment.short payment.expired order.status |
| timelineKind | issued sighted picked short settled expired shifted |
| rail | BTC LTC ETH USDT USDC |
| asset | BTC LTC ETH USDT_ERC20 USDC_ERC20 USDT_BEP20 USDC_BEP20 USDT_TRC20 USDC_TRC20 USDC_BASE |
| keyring | BTC LTC ETH TRX |
| chain | BTC LTC ETH BSC TRX BASE |
| currency | USD |
| method | crypto |
| channel | web telegram |
which status may become which
Being listed here is not permission on its own. placed and quoted reach paid directly only for an order whose final total is zero, and that guard is enforced separately — a table saying a move is legal beside a service refusing it would read as a bug rather than as the rule it is.
| from | may become |
|---|---|
| quote_requested | quoted cancelled |
| placed | awaiting_payment pending_payment paid cancelled |
| quoted | awaiting_payment pending_payment paid cancelled |
| awaiting_payment | paid expired cancelled |
| pending_payment | paid expired cancelled |
| expired | awaiting_payment pending_payment paid cancelled |
| paid | cancelled |
| cancelled | nothing — terminal |
rails — what a customer picks
| rail | label | ticker | ledgers it can settle from |
|---|---|---|---|
| BTC | Bitcoin | BTC | BTC |
| LTC | Litecoin | LTC | LTC |
| ETH | Ethereum | ETH | ETH |
| USDT | Tether | USDT | USDT_ERC20 USDT_BEP20 USDT_TRC20 |
| USDC | USD Coin | USDC | USDC_BASE USDC_ERC20 USDC_BEP20 USDC_TRC20 |
assets — what is actually watched
A rail is a ticker and an asset is a ledger. Sending asset narrows an instruction to one network; sending rail alone bills the first network the order offers for that coin, and the invoice does not let the customer move it. To let the customer pick, leave the order unbilled: the invoice offers its coins and networks before any address exists. The network column is the one to show them — "USDT" on its own is four different transfers.
| asset | network | ticker | decimals | quoted to | contract |
|---|---|---|---|---|---|
| BTC | Bitcoin | BTC | 8 | 6 | — |
| LTC | Litecoin | LTC | 8 | 3 | — |
| ETH | Ethereum (ERC-20) | ETH | 18 | 5 | — |
| USDT_ERC20 | Ethereum (ERC-20) | USDT | 6 | 2 | 0xdAC17F958D2ee523a2206206994597C13D831ec7 |
| USDC_ERC20 | Ethereum (ERC-20) | USDC | 6 | 2 | 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 |
| USDT_BEP20 | BNB Smart Chain (BEP-20) | USDT | 18 | 2 | 0x55d398326f99059fF775485246999027B3197955 |
| USDC_BEP20 | BNB Smart Chain (BEP-20) | USDC | 18 | 2 | 0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d |
| USDT_TRC20 | Tron (TRC-20) | USDT | 6 | 2 | TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t |
| USDC_TRC20 | Tron (TRC-20) | USDC | 6 | 2 | TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8 |
| USDC_BASE | Base (ERC-20) | USDC | 6 | 2 | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 |
keyrings — what a merchant exports
| keyring | label | derivation path | what setting it switches on |
|---|---|---|---|
| BTC | Bitcoin | m/84'/0'/0' | BTC |
| LTC | Litecoin | m/84'/2'/0' | LTC |
| ETH | Ethereum / EVM | m/44'/60'/0' | ETH USDT_ERC20 USDC_ERC20 USDT_BEP20 USDC_BEP20 USDC_BASE |
| TRX | Tron | m/44'/195'/0' | USDT_TRC20 USDC_TRC20 |
settlement, and what never settles itself
Automated promotion is EXACT-OR-OVER AND BURIED. An instruction settles by
itself only when the full quoted amount is visible on the ledger it was issued
for and it is confirmed deeply enough for that chain. Bitcoin wants 1
confirmation and Tron wants 19, which is why the required depth is published per
leg rather than per instruction.
NOTHING SETTLES A PAYMENT THAT ARRIVED SHORT. Not eventually, not after any
number of confirmations, not after any amount of waiting. It goes to the state
short and stays there until a person calls
POST /api/payments/:id/settle-short with a note saying why. That is the single
most important sentence in this document for anybody building a checkout.
The sweep only ever PROMOTES. It never marks a payment short, never cancels,
never reverses and never refunds.
OVER-PAYMENT settles. The order is paid; what to do about the excess is a
conversation between a shop and its customer, and payd records the figures for
it and takes no view.
LATE MONEY STILL LANDS. Expiry releases the hold on the goods; it does not
refuse money. The address stays ours, an expired order can be reinstated or paid
outright, and payd will not turn away a transfer because a clock ran out.
A SUSPENDED MERCHANT is a door being closed, not a payment being refused. Money
already on its way to an address they were billed on still arrives, is still
matched, and still settles.
webhooks
THE WEBHOOK IS A DOORBELL, NOT THE TRUTH. Five of the six events are delivered: payment.settled, payment.settle-refused, payment.short, payment.expired, order.status.payment.sightedis deliberately not among them — it fires every time a figure moves on chain, which is useful to staff watching a screen and is noise to a server that only cares whether it may ship. AN EVENT YOU DO NOT RECOGNISE MUST BE ANSWERED 2xx AND IGNORED: a handler that refuses an unfamiliar name fails its ladder, and enough of that trips the breaker and stops your shop being told about settlements at all. Each delivery carries: payd-signature: t=<unix>,v1=<hex> payd-delivery: <id> payd-event: <name> The hex is HMAC-SHA256 over${t}.${rawBody}with the secret you were shown once when you added the endpoint. Verify it over the RAW bytes, before parsing, and refuse anything with a timestamp older than a few minutes. IF YOUR HANDLER RUNS OFF DENO, DO NOT WRITE THAT CHECK YOURSELF.verifyWebhookin src/client.js, exported as ./client, is the same check built on WebCrypto rather than node:crypto, so it runs on Node, on Bun and on an edge runtime where node:crypto is not there. It is async because crypto.subtle is, it takes the raw body, and it shares its header parse, its age guard and its signed material with the function that signs here — so the two cannot drift apart. It exports no signer, deliberately: something that mints one of these inside your own process is a forgery tool aimed at your own handler. ONE BODY SHAPE FOR EVERY EVENT, so you parse it once and branch onevent: { event, at, order: { reference, status, totalCents, currency }, payment: { id, rail, asset, amountCents, receivedCents, status, chainAmount, outstanding, overCents } | null } A field the event has nothing to say about is NULL rather than absent. Which of them are populated, per event: payment.settled chainAmount is what the chain held; overCents is how far past the ask it ran, 0 on the nose, and null on a confirmation made by hand — an accepted short included, which carries a chainAmount all the same. outstanding is null: nothing is owed. payment.settle-refused chainAmount is what arrived. MONEY EXISTS ON CHAIN THAT THIS ORDER WILL NOT ACCEPT. Do not treat it as informational: page a person, reconcile against GET /api/orders/<reference>, and decide whether to confirm it by hand or return it. Neither the address nor the reason travels — the reason is in the deployment's own log. Delivered ONCE per instruction per endpoint FOR EACH FIGURE it refuses, because the sweep refuses the same money again on every pass until somebody acts: one page for that reading, and another if chainAmount MOVES because more money landed on the same address. payment.short chainAmount is what arrived and outstanding is what is still missing, both exact decimal strings in the asset's own units. Delivered again whenever the figure MOVES, so a customer topping up is visible. Nothing settles until a person decides. payment.expired the hold lapsed; payment is null and the instruction is still live, so money in flight still lands. order.status a settlement, or a status change a caller asked for; payment is null. COMPARE THE DECIMAL STRINGS, NEVER PARSE THEM.chainAmountandoutstandingare exact decimals in the asset's own units — one ether is 10^18 wei, well past what a float can hold — andamountCents,receivedCentsandoverCentsare whole fiat minor units. DELIVERY IS AT LEAST ONCE AND CAN FAIL ENTIRELY. The ladder is 10, 30, 90, 300, 900, 2700 seconds — 6 attempts over about 67 minutes — and then it is given up on. An endpoint that fails five deliveries with nothing accepted in between is disabled by a breaker until somebody enables it again. ORDERING IS NOT GUARANTEED. The queue is drained oldest first in bounded parallel, and a retry lands up to twenty-odd minutes behind whatever was queued after it, so apayment.settledcan arrive before thepayment.shortthat preceded it. Decide from the body and from the order read, never from arrival order. A 3xx IS A FAILURE AND IS NEVER FOLLOWED. Register the final URL. A redirect goes through the same retry ladder as a 500 and counts towards the breaker like any other failure — following one would mean the host checked at registration is not the host that receives the body. WHERE YOUR ENDPOINT POINTS IS CHECKED AGAIN BEFORE EVERY DELIVERY. Its host is resolved here, for A and AAAA, immediately before the request, and every answer must be a public address: one private record among several is a refusal rather than a preference, and a name that answers nothing at all is refused too. Such a delivery is never sent — it fails the attempt, takes the same ladder as a 500 and counts towards the breaker. Two things follow for you. The lookup is DNS proper, with no /etc/hosts and no search domains, so an endpoint that resolves only inside your network is not deliverable to. And this NARROWS rebinding rather than closing it: fetch resolves the name a second time and cannot be pinned to what was checked, so the window is milliseconds rather than the hours between registering an endpoint and being delivered to.payd-deliveryIS NOT THE DE-DUPLICATION KEY, whatever an older copy of this paragraph told you. One event produces ONE payload, and that same payload is queued once per live endpoint — so a shop with two endpoints pointed at one handler receives two deliveries carrying identical bytes,atincluded, under two different delivery ids. That header tells the 6 attempts of ONE delivery apart, and nothing else. THE SIGNATURE IS NOT A DE-DUPLICATION KEY EITHER.tis when that attempt was sent, so the six attempts of one delivery carry six differentpayd-signaturevalues over identical bytes. De-duplicate on what the body says: the order'sreference, theeventandorder.status, beside your own record of whether you have already shipped — which is the fact that actually decides it. THAT KEY IS THE SHIP DECISION, NOT AN INGRESS FILTER.payment.shortandpayment.settle-refusedare sent AGAIN precisely when the figure moved, and both sends carry the same reference, the same event and the sameorder.status— so a receiver that drops the second one throws away the top-up, or the second arrival on money nobody can settle, which is the whole reason those two are re-sent. Key those two onpayment.idandchainAmountas well. And RECONCILE against GET /api/orders/<reference> rather than trusting that anything arrived. A checkout whose only source of truth is a webhook is a checkout that loses an order the first time your host restarts during a deploy.
errors
Two shapes, and only two.
400 {"errors": {"<field>": "<sentence>"}} field validation
any {"error": "<sentence>"} everything else
One refusal also carries a code beside its sentence, for a host to branch on
without matching words: {"error": "<sentence>", "code": "plan_ceiling"} is the
409 POST /api/orders/:reference/payment answers when the shop's plan ceiling
refuses a new invoice. No other refusal carries a code, so do not wait for one.
A BODY THAT IS NOT ONE JSON OBJECT is refused on every route that reads a body,
after the key, the scope and the resource are checked and before anything is
done. It is the second shape, at 400:
400 {"error": "The request body is not valid JSON."}
400 {"error": "The request body must be a JSON object."}
So at 400, read error before errors. An empty body is not refused: zero bytes, or
nothing but whitespace, is read as no body, as it always was.
The sentences are stable. VERSIONS.md makes them a promise because host
applications read them back, and this document generates them from the modules
that emit them, so a sentence that changed would change here too.
Status codes that mean something specific here:
400 a field was wrong — INCLUDING a reference already in use, which is
success for a retry after a timeout and is deliberately not a 409
401 the credential did not resolve. Which one, and why, is not said
403 the key is real and is the wrong scope. Only on the admin routes,
where you already know your own account exists
404 gone, not yours, or not a route. Never distinguished
409 the request was well formed and the state forbids it
429 a rate limit. Honour Retry-After
500 {"error": "Something went wrong."} and nothing else, ever
Nothing in an error body names another merchant, an address you were not given,
a key, a token or a secret. A refusal says what was NOT done and does not
explain how to get closer.
rate limits
Three routes are limited, all keyed on the MERCHANT and not on the path:
POST /api/orders/:reference/payment 20 per 60s
POST /api/orders/:reference/payment/requote 15 per 60s
POST /api/orders/:reference/paid 6 per 60s
The last is tightest because each call is an outbound request to a public
explorer.
Keying on the merchant rather than on the path is load-bearing: the path on all
three carries the order reference, so a per-path key would have given every
reference its own budget and let one caller multiply their own limit by using
more references.
The invoice routes are limited by TOKEN (60/minute, once the token resolves) and
by IP on MISSES only (20/minute), so a shop's own customers never meet the
second however many of them share an office address.
Every 429 sets Retry-After. Honour it.
make me an mcp server
THERE IS NO MCP SERVER HERE, and no tool manifest. What there is, is enough to
generate one: /openapi.json carries four extensions per operation that plain
OpenAPI has no vocabulary for, and they are exactly what a bridge needs.
x-payd-scope which credential the operation wants
x-payd-effect what it does in one clause, in the product's terms
x-payd-safe-to-retry whether repeating it after a timeout is safe
x-payd-idempotent whether repeating it changes anything
Generate read-only tools from every GET. Mark these DESTRUCTIVE and require
confirmation: revokeKey, removeWebhook, setWallet, recordRefund, settleShort,
confirmPayment, setOrderStatus. Each either destroys a credential, decides where
future money lands, or settles an order on a person's word.
Mark issuePayment NEITHER read-only NOR destructive: it moves no money and
cannot, but it does void an outstanding instruction, so a confirmation step is
cheap insurance and an automatic retry is not.
And give the agent GET /api/merchant first. Every other decision — which rails
to offer, whether this shop can be billed at all, whether the key can change the
wallet — is answered there in one request.
what is not documented, and not to be scripted
/app is the merchant's own browser portal: form-encoded HTML behind a session cookie, with CSRF on every POST and a per-route field allow-list that answers 400 for anything unexpected. It is explicitly OUTSIDE the compatibility promise in VERSIONS.md and its markup may change in a patch release. Do not script it. Everything it does that a program should do is on /api, and the credential for that is a key rather than a person's password.
what this page cannot tell you
Four things this file cannot tell you, because they are facts about a running
deployment rather than about payd:
Whether the chain is being watched at all. startChainWatch is opt-in at
boot, and with it off nothing settles by itself. Ask GET /health —
chain.watching, and chain.stale, which is the one to alarm on.
Whether the rate feed is alive. Ask GET /health — rates.fresh and
rates.ageSeconds, both null when nothing has ever been priced.
Which coins THIS shop can be billed on. That is a property of the extended
keys this merchant has configured, not of payd. Ask
GET /api/payments/config, and offer only the rails it returns.
What this deployment is called or where it lives. Every link in these
documents is relative on purpose. The base URL for /api and /i
is https://app.payd.sh; everything else resolves against where you fetched this.