{"openapi":"3.1.0","info":{"title":"payd","version":"4.2.0","summary":"Self-custodial crypto payments. This server holds extended PUBLIC keys only and cannot sign, hold, move or return money.","description":"payd is self-custodial. A merchant gives it an extended PUBLIC key; payd works\nout one receiving address per order from that key and watches the chain for\nmoney arriving there.\n\nIt cannot sign, hold, move or return money. There is no balance, no payout and\nno refund it can carry out for anybody. A refund in payd is a RECORD of a\ntransfer a person made from their own wallet.\n\nAnything you write that assumes a balance, a payout, an automatic refund or a\n\"charge the customer\" call is wrong about the product, not about the API.","x-payd-schema-generation":20,"x-payd-read-first":"/agent.md"},"servers":[{"url":"https://app.payd.sh","description":"where this deployment serves /api and /i"}],"security":[{"merchantKey":[]}],"tags":[{"name":"service"},{"name":"merchant"},{"name":"wallet"},{"name":"webhooks"},{"name":"orders"},{"name":"billing"},{"name":"refunds"},{"name":"payments"},{"name":"invoice"}],"x-payd-conventions":{"money":"Fiat is integer minor units. Crypto is an exact decimal string. Never a float, in either direction.","cors":"None on /api, anywhere. Server to server only — the key is a bearer credential and belongs in an environment variable.","idempotency":"No Idempotency-Key header and no request de-duplication. Send your own order reference; a duplicate answers 400 with errors.reference, and that is success for a retry after a timeout.","notFound":"404 does not distinguish gone, not-yours and not-a-route. Do not branch on it and do not log it as a bug.","errorBody":"{\"error\":\"<sentence>\"}, except field validation which is {\"errors\":{\"<field>\":\"<sentence>\"}} at 400. A body that is not one JSON object is also refused at 400, as {\"error\":\"<sentence>\"}, and nothing is done. One refusal adds a code beside the sentence, \"plan_ceiling\", to branch on instead of the words."},"x-payd-guides":{"custody":"payd is self-custodial. A merchant gives it an extended PUBLIC key; payd works\nout one receiving address per order from that key and watches the chain for\nmoney arriving there.\n\nIt cannot sign, hold, move or return money. There is no balance, no payout and\nno refund it can carry out for anybody. A refund in payd is a RECORD of a\ntransfer a person made from their own wallet.\n\nAnything you write that assumes a balance, a payout, an automatic refund or a\n\"charge the customer\" call is wrong about the product, not about the API.","auth":"One header:\n\n    Authorization: Bearer payd_sk_...\n\nThe scheme is matched case-insensitively and a bare key with no scheme also\nworks. A key is payd_sk_ followed by 43 base64url characters.\n\nTwo scopes, and one distinction: taking money versus CHANGING WHERE MONEY GOES.\n\n    write   orders, instructions, settlement, refunds. The default.\n    admin   that, plus the wallet and the keys themselves.\n\nA shop's checkout holds a write key. It issues invoices and settles them all\nday and cannot repoint the wallet, which is the difference between a leaked key\ncosting a merchant their order history and costing them their income.\n\nA third credential exists and is not a scope: the deployment key, out of\nPAYMENTS_API_KEY, which is the only thing that may read across merchants. No\nmerchant key can be minted with it.\n\n401 never says which credential failed. An unknown key, a revoked key and a\nsuspended merchant are one answer with no way to tell them apart.\n\n404 never distinguishes gone from not-yours from not-a-route. Do not branch on\nit and do not log it as a bug.\n\nSERVER SIDE ONLY. The key is a bearer credential and belongs in an environment\nvariable, never in a browser and never in a file you commit.\n\nA READY-MADE CLIENT THAT SETS THIS HEADER ships in the checkout, at\nsrc/client.js, exported as ./client. It covers the operations an integration\nperforms, uses these operation ids as its method names, and answers what each\nroute answers. It is a convenience over this API and not a second contract: what\nis promised is promised about the routes.","money":"Fiat is INTEGER MINOR UNITS. goodsCents, shippingCents, totalCents,\namountCents, receivedCents, owedCents — all whole numbers of cents.\n\nCrypto is an EXACT DECIMAL STRING. quotedAmount, chainAmount, assetAmount and\nshortfallUnits are TEXT in the database and strings on the wire, and they are\ncompared as strings.\n\nNEVER A FLOAT, in either direction. One ether is 10^18 wei, which is two orders\nof magnitude past Number.MAX_SAFE_INTEGER, so a float quietly hands the customer\na number that is not the number they owe. Cents lose the same way, more slowly.\n\ngoodsCents: null is NOT 0. null means nobody has priced it yet and the order\nsits in quote_requested with a null total. 0 means free, and a free order is\nregistered already paid — asking to bill it settles it instead of issuing an\naddress, so nothing is derived and nothing can be sent anywhere.\n\nAmounts are quoted at the asset's ASKING precision rather than its full one, and\nalways rounded UP. Being over by one satoshi costs nothing; being short by one\nnever settles itself.","settlement":"Automated promotion is EXACT-OR-OVER AND BURIED. An instruction settles by\nitself only when the full quoted amount is visible on the ledger it was issued\nfor and it is confirmed deeply enough for that chain. Bitcoin wants 1\nconfirmation and Tron wants 19, which is why the required depth is published per\nleg rather than per instruction.\n\nNOTHING SETTLES A PAYMENT THAT ARRIVED SHORT. Not eventually, not after any\nnumber of confirmations, not after any amount of waiting. It goes to the state\n`short` and stays there until a person calls\nPOST /api/payments/:id/settle-short with a note saying why. That is the single\nmost important sentence in this document for anybody building a checkout.\n\nThe sweep only ever PROMOTES. It never marks a payment short, never cancels,\nnever reverses and never refunds.\n\nOVER-PAYMENT settles. The order is paid; what to do about the excess is a\nconversation between a shop and its customer, and payd records the figures for\nit and takes no view.\n\nLATE MONEY STILL LANDS. Expiry releases the hold on the goods; it does not\nrefuse money. The address stays ours, an expired order can be reinstated or paid\noutright, and payd will not turn away a transfer because a clock ran out.\n\nA SUSPENDED MERCHANT is a door being closed, not a payment being refused. Money\nalready on its way to an address they were billed on still arrives, is still\nmatched, and still settles.","webhooks":"THE WEBHOOK IS A DOORBELL, NOT THE TRUTH.\n\nFive of the six events are delivered: payment.settled, payment.settle-refused, payment.short, payment.expired, order.status.\n`payment.sighted` is deliberately not among them — it fires every time a figure\nmoves on chain, which is useful to staff watching a screen and is noise to a\nserver that only cares whether it may ship. AN EVENT YOU DO NOT RECOGNISE MUST BE\nANSWERED 2xx AND IGNORED: a handler that refuses an unfamiliar name fails its\nladder, and enough of that trips the breaker and stops your shop being told about\nsettlements at all.\n\nEach delivery carries:\n\n    payd-signature: t=<unix>,v1=<hex>\n    payd-delivery:  <id>\n    payd-event:     <name>\n\nThe hex is HMAC-SHA256 over `${t}.${rawBody}` with the secret you were shown\nonce when you added the endpoint. Verify it over the RAW bytes, before parsing,\nand refuse anything with a timestamp older than a few minutes.\n\nIF YOUR HANDLER RUNS OFF DENO, DO NOT WRITE THAT CHECK YOURSELF. `verifyWebhook`\nin src/client.js, exported as ./client, is the same check built on WebCrypto\nrather than node:crypto, so it runs on Node, on Bun and on an edge runtime where\nnode:crypto is not there. It is async because crypto.subtle is, it takes the raw\nbody, and it shares its header parse, its age guard and its signed material with\nthe function that signs here — so the two cannot drift apart. It exports no\nsigner, deliberately: something that mints one of these inside your own process\nis a forgery tool aimed at your own handler.\n\nONE BODY SHAPE FOR EVERY EVENT, so you parse it once and branch on `event`:\n\n    { event, at, order: { reference, status, totalCents, currency },\n      payment: { id, rail, asset, amountCents, receivedCents, status,\n                 chainAmount, outstanding, overCents } | null }\n\nA field the event has nothing to say about is NULL rather than absent. Which of\nthem are populated, per event:\n\n    payment.settled         chainAmount is what the chain held; overCents is how\n                            far past the ask it ran, 0 on the nose, and null on a\n                            confirmation made by hand — an accepted short\n                            included, which carries a chainAmount all the same.\n                            outstanding is null: nothing is owed.\n    payment.settle-refused  chainAmount is what arrived. MONEY EXISTS ON CHAIN\n                            THAT THIS ORDER WILL NOT ACCEPT. Do not treat it as\n                            informational: page a person, reconcile against\n                            GET /api/orders/<reference>, and decide whether to\n                            confirm it by hand or return it. Neither the address\n                            nor the reason travels — the reason is in the\n                            deployment's own log. Delivered ONCE per instruction\n                            per endpoint FOR EACH FIGURE it refuses, because the\n                            sweep refuses the same money again on every pass until\n                            somebody acts: one page for that reading, and another\n                            if chainAmount MOVES because more money landed on the\n                            same address.\n    payment.short           chainAmount is what arrived and outstanding is what is\n                            still missing, both exact decimal strings in the\n                            asset's own units. Delivered again whenever the figure\n                            MOVES, so a customer topping up is visible. Nothing\n                            settles until a person decides.\n    payment.expired         the hold lapsed; payment is null and the instruction\n                            is still live, so money in flight still lands.\n    order.status            a settlement, or a status change a caller asked for;\n                            payment is null.\n\nCOMPARE THE DECIMAL STRINGS, NEVER PARSE THEM. `chainAmount` and\n`outstanding` are exact decimals in the asset's own units — one ether is 10^18\nwei, well past what a float can hold — and `amountCents`, `receivedCents` and\n`overCents` are whole fiat minor units.\n\nDELIVERY IS AT LEAST ONCE AND CAN FAIL ENTIRELY. The ladder is\n10, 30, 90, 300, 900, 2700 seconds — 6 attempts over\nabout 67 minutes —\nand then it is given up on. An endpoint that fails five deliveries with nothing\naccepted in between is disabled by a breaker until somebody enables it again.\n\nORDERING IS NOT GUARANTEED. The queue is drained oldest first in bounded\nparallel, and a retry lands up to twenty-odd minutes behind whatever was queued\nafter it, so a `payment.settled` can arrive before the `payment.short` that\npreceded it. Decide from the body and from the order read, never from arrival\norder.\n\nA 3xx IS A FAILURE AND IS NEVER FOLLOWED. Register the final URL. A redirect goes\nthrough the same retry ladder as a 500 and counts towards the breaker like any\nother failure — following one would mean the host checked at registration is not\nthe host that receives the body.\n\nWHERE YOUR ENDPOINT POINTS IS CHECKED AGAIN BEFORE EVERY DELIVERY. Its host is\nresolved here, for A and AAAA, immediately before the request, and every answer\nmust be a public address: one private record among several is a refusal rather\nthan a preference, and a name that answers nothing at all is refused too. Such a\ndelivery is never sent — it fails the attempt, takes the same ladder as a 500 and\ncounts towards the breaker. Two things follow for you. The lookup is DNS proper,\nwith no /etc/hosts and no search domains, so an endpoint that resolves only\ninside your network is not deliverable to. And this NARROWS rebinding rather than\nclosing it: fetch resolves the name a second time and cannot be pinned to what\nwas checked, so the window is milliseconds rather than the hours between\nregistering an endpoint and being delivered to.\n\n`payd-delivery` IS NOT THE DE-DUPLICATION KEY, whatever an older copy of this\nparagraph told you. One event produces ONE payload, and that same payload is\nqueued once per live endpoint — so a shop with two endpoints pointed at one\nhandler receives two deliveries carrying identical bytes, `at` included, under\ntwo different delivery ids. That header tells the 6\nattempts of ONE delivery apart, and nothing else.\n\nTHE SIGNATURE IS NOT A DE-DUPLICATION KEY EITHER. `t` is when that attempt was\nsent, so the six attempts of one delivery carry six different\n`payd-signature` values over identical bytes.\n\nDe-duplicate on what the\nbody says: the order's `reference`, the `event` and `order.status`, beside\nyour own record of whether you have already shipped — which is the fact that\nactually decides it.\n\nTHAT KEY IS THE SHIP DECISION, NOT AN INGRESS FILTER. `payment.short` and\n`payment.settle-refused` are sent AGAIN precisely when the figure moved, and\nboth sends carry the same reference, the same event and the same\n`order.status` — so a receiver that drops the second one throws away the\ntop-up, or the second arrival on money nobody can settle, which is the whole\nreason those two are re-sent. Key those two on `payment.id` and\n`chainAmount` as well.\n\nAnd RECONCILE against GET /api/orders/<reference> rather than trusting that\nanything arrived. A checkout whose only source of truth is a webhook is a\ncheckout that loses an order the first time your host restarts during a deploy.","errors":"Two shapes, and only two.\n\n    400  {\"errors\": {\"<field>\": \"<sentence>\"}}     field validation\n    any  {\"error\": \"<sentence>\"}                   everything else\n\nOne refusal also carries a code beside its sentence, for a host to branch on\nwithout matching words: {\"error\": \"<sentence>\", \"code\": \"plan_ceiling\"} is the\n409 POST /api/orders/:reference/payment answers when the shop's plan ceiling\nrefuses a new invoice. No other refusal carries a code, so do not wait for one.\n\nA BODY THAT IS NOT ONE JSON OBJECT is refused on every route that reads a body,\nafter the key, the scope and the resource are checked and before anything is\ndone. It is the second shape, at 400:\n\n    400  {\"error\": \"The request body is not valid JSON.\"}\n    400  {\"error\": \"The request body must be a JSON object.\"}\n\nSo at 400, read error before errors. An empty body is not refused: zero bytes, or\nnothing but whitespace, is read as no body, as it always was.\n\nThe sentences are stable. VERSIONS.md makes them a promise because host\napplications read them back, and this document generates them from the modules\nthat emit them, so a sentence that changed would change here too.\n\nStatus codes that mean something specific here:\n\n    400  a field was wrong — INCLUDING a reference already in use, which is\n         success for a retry after a timeout and is deliberately not a 409\n    401  the credential did not resolve. Which one, and why, is not said\n    403  the key is real and is the wrong scope. Only on the admin routes,\n         where you already know your own account exists\n    404  gone, not yours, or not a route. Never distinguished\n    409  the request was well formed and the state forbids it\n    429  a rate limit. Honour Retry-After\n    500  {\"error\": \"Something went wrong.\"} and nothing else, ever\n\nNothing in an error body names another merchant, an address you were not given,\na key, a token or a secret. A refusal says what was NOT done and does not\nexplain how to get closer.","rateLimits":"Three routes are limited, all keyed on the MERCHANT and not on the path:\n\n    POST /api/orders/:reference/payment          20 per 60s\n    POST /api/orders/:reference/payment/requote  15 per 60s\n    POST /api/orders/:reference/paid             6 per 60s\n\nThe last is tightest because each call is an outbound request to a public\nexplorer.\n\nKeying on the merchant rather than on the path is load-bearing: the path on all\nthree carries the order reference, so a per-path key would have given every\nreference its own budget and let one caller multiply their own limit by using\nmore references.\n\nThe invoice routes are limited by TOKEN (60/minute, once the token resolves) and\nby IP on MISSES only (20/minute), so a shop's own customers never meet the\nsecond however many of them share an office address.\n\nEvery 429 sets Retry-After. Honour it.","idempotency":"THERE IS NO IDEMPOTENCY-KEY HEADER AND NO REQUEST DE-DUPLICATION.\n\nYour own order reference is the de-duplication. Send one on POST /api/orders,\nand a repeat answers:\n\n    400 {\"errors\":{\"reference\":\"Reference PP-4821903 is already in use.\"}}\n\nA retry after a timeout must read that as ALREADY REGISTERED and go and fetch\nthe order, not as a bad request. It is deliberately not a 409: a 409 would\ninvite a client to treat it as a conflict to resolve, when the correct handling\nis to carry on.\n\nBilling the same order twice on the same coin returns the SAME address, because\nthe derivation index is held per order per keyring. So an interrupted\nPOST /api/orders/:reference/payment is safe to repeat: it voids the outstanding\ninstruction and issues a new one at the same address — unless the chain has\nalready seen money against it, in which case it is refused.\n\nNothing else de-duplicates. Minting a key twice leaves two keys; adding a\nwebhook twice delivers everything twice; recording a refund twice records two\nrefunds.","capabilities":"GET /health carries a `capabilities` array: sorted, opaque tokens naming\nbehaviour this deployment has. It is in front of the key check, so it is the one\nquestion you can ask before you have been given a credential — which is exactly\nwhen a connector needs to know what it is talking to.\n\nBRANCH ON MEMBERSHIP AND NOTHING ELSE. Do not parse a token apart, do not order\nyour own copy differently, and do not read `version` or the database generation\nfor this purpose: one is a build number you would have to keep a table for, and\nthe other is a database fact VERSIONS.md deliberately leaves outside the promise.\n\n    api.strict-json-body\n        A non-empty body that is not one JSON object is a 400 on every /api route that reads a body.\n\n    invoice.price-restrike\n        A lapsed price is re-struck on invoice load if the ledgers read empty.\n\n    orders.currency-refused\n        An order in a currency this build cannot price is refused, not stored.\n\n    orders.list-paged\n        GET /api/orders pages the orders this key may read, newest first, with a cursor in next.\n\n    orders.payments-bounded\n        The order read bounds payments[] and publishes paymentsOmitted.\n\n    orders.settlement-verdict\n        GET /api/orders/:reference answers state, and settlement, outstanding and the required depth on the live instruction.\n\n    payments.config-policy\n        GET /api/payments/config answers policy: the depth per chain, tolerance, precision and EVM chain id this deployment uses.\n\n    payments.outstanding-paged\n        GET /api/payments/outstanding pages, with a cursor in next.\n\n    webhooks.settlement-figures\n        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.\n\nTHE RULE THESE ARE PUBLISHED UNDER, in four sentences. An adapter may assume that\nevery field named in a shape test is present and means what these documents say\nit means. A field may be ADDED to a response in a minor release; nothing is\nremoved, renamed or re-typed except in a major one. A token published here is not\nremoved for two major releases, and an adapter that finds one absent must degrade\nrather than guess what the deployment does. Anything no shape test holds is not\npromised, however plainly it is written down.","zeroCostCheck":"Four calls, on a real deployment, that cost nothing and prove your integration\nis wired up.\n\n    PAYD=https://your-deployment\n    KEY=payd_sk_...\n\n    # 0. May I, and can this shop be billed at all?\n    curl -sS \"$PAYD/api/merchant\" -H \"Authorization: Bearer $KEY\"\n\n    # 1. Register an order that costs nothing. A zero total is FREE, and free\n    #    orders are registered already paid.\n    curl -sS -X POST \"$PAYD/api/orders\" -H \"Authorization: Bearer $KEY\" \\\n      -H 'content-type: application/json' \\\n      -d '{\"reference\":\"PAYD-CHECK-1\",\"goodsCents\":0,\"shippingCents\":0}'\n    # 201 {\"order\":{\"reference\":\"PAYD-CHECK-1\",\"status\":\"paid\",\"totalCents\":0,\n    #      \"publicToken\":\"QrtKmvXbdLnpWfzJaHcEuSy\", ...},\n    #      \"invoiceUrl\":\"$PAYD/i/QrtKmvXbdLnpWfzJaHcEuSy\"}\n    # invoiceUrl is the whole of what you send a buyer. It is the token above,\n    # composed against this deployment's own origin, so it is a credential.\n\n    # 2. Ask to bill it. A free order settles instead of issuing an address, so\n    #    no address is worked out and nothing can be sent anywhere.\n    curl -sS -X POST \"$PAYD/api/orders/PAYD-CHECK-1/payment\" \\\n      -H \"Authorization: Bearer $KEY\" -H 'content-type: application/json' \\\n      -d '{\"rail\":\"BTC\"}'\n    # 200 {\"order\":{\"status\":\"paid\",...},\"payment\":null,\"free\":true}\n\n    # 3. Read it back, and read the customer's own view of it.\n    curl -sS \"$PAYD/api/orders/PAYD-CHECK-1\" -H \"Authorization: Bearer $KEY\"\n    curl -sS \"$PAYD/i/QrtKmvXbdLnpWfzJaHcEuSy/state\"\n\n    # 4. Prove the retry rule. This must answer 400, not 409, and it means\n    #    ALREADY REGISTERED.\n    curl -sS -X POST \"$PAYD/api/orders\" -H \"Authorization: Bearer $KEY\" \\\n      -H 'content-type: application/json' \\\n      -d '{\"reference\":\"PAYD-CHECK-1\",\"goodsCents\":0,\"shippingCents\":0}'\n    # 400 {\"errors\":{\"reference\":\"Reference PAYD-CHECK-1 is already in use.\"}}\n\nWHAT THIS PROVES: your key works and its scope is enough, your JSON reaches the\nserver, the order lifecycle is wired, and your retry handling reads a taken\nreference correctly.\n\nWHAT IT DOES NOT PROVE: address derivation, the rate feed, or settlement from\nthe chain — none of which happen on a free order. For those, set a wallet and\nregister an order for five cents on LTC: the instruction is real and carries a\nreal address, and if you never send anything it lapses when the payment window\nends — two hours by default — and costs nothing.\n\nTHIS IS NOT A TEST MODE AND THERE IS NO TEST MODE. PAYD-CHECK-1 is a real order\nand will sit in that shop's invoices book for ever. Use a reference the merchant\nwill recognise later.","mcp":"THERE IS NO MCP SERVER HERE, and no tool manifest. What there is, is enough to\ngenerate one: /openapi.json carries four extensions per operation that plain\nOpenAPI has no vocabulary for, and they are exactly what a bridge needs.\n\n    x-payd-scope          which credential the operation wants\n    x-payd-effect         what it does in one clause, in the product's terms\n    x-payd-safe-to-retry  whether repeating it after a timeout is safe\n    x-payd-idempotent     whether repeating it changes anything\n\nGenerate read-only tools from every GET. Mark these DESTRUCTIVE and require\nconfirmation: revokeKey, removeWebhook, setWallet, recordRefund, settleShort,\nconfirmPayment, setOrderStatus. Each either destroys a credential, decides where\nfuture money lands, or settles an order on a person's word.\n\nMark issuePayment NEITHER read-only NOR destructive: it moves no money and\ncannot, but it does void an outstanding instruction, so a confirmation step is\ncheap insurance and an automatic retry is not.\n\nAnd give the agent GET /api/merchant first. Every other decision — which rails\nto offer, whether this shop can be billed at all, whether the key can change the\nwallet — is answered there in one request.","notDocumented":"/app is the merchant's own browser portal: form-encoded HTML behind a session\ncookie, with CSRF on every POST and a per-route field allow-list that answers 400\nfor anything unexpected. It is explicitly OUTSIDE the compatibility promise in\nVERSIONS.md and its markup may change in a patch release.\n\nDo not script it. Everything it does that a program should do is on /api, and\nthe credential for that is a key rather than a person's password.","cannotTellYou":"Four things this file cannot tell you, because they are facts about a running\ndeployment rather than about payd:\n\n    Whether the chain is being watched at all. startChainWatch is opt-in at\n    boot, and with it off nothing settles by itself. Ask GET /health —\n    chain.watching, and chain.stale, which is the one to alarm on.\n\n    Whether the rate feed is alive. Ask GET /health — rates.fresh and\n    rates.ageSeconds, both null when nothing has ever been priced.\n\n    Which coins THIS shop can be billed on. That is a property of the extended\n    keys this merchant has configured, not of payd. Ask\n    GET /api/payments/config, and offer only the rails it returns.\n\n    What this deployment is called or where it lives. Every link in these\n    documents is relative on purpose. The base URL for /api and /i\n    is https://app.payd.sh; everything else resolves against where you fetched this."},"components":{"securitySchemes":{"merchantKey":{"type":"http","scheme":"bearer","description":"A merchant key (payd_sk_ followed by 43 base64url characters) or the deployment key out of PAYMENTS_API_KEY. The scheme is matched case-insensitively and a bare key with no scheme also works. An unknown key, a revoked key and a suspended merchant are one 401 with no way to tell them apart, so there is nothing in a 401 to branch on."}},"schemas":{"OrderStatus":{"type":"string","enum":["quote_requested","placed","quoted","awaiting_payment","pending_payment","paid","expired","cancelled"],"x-payd-transitions":{"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":[]}},"PaymentStatus":{"type":"string","enum":["awaiting","confirmed","expired","void"]},"InvoiceStateValue":{"type":"string","enum":["settled","none","short","confirming","expired","awaiting"]},"Settlement":{"type":"string","enum":["none","short","exact","over"]},"Scope":{"type":"string","enum":["write","admin"]},"Event":{"type":"string","enum":["payment.sighted","payment.short","payment.settled","payment.settle-refused","payment.expired","order.status"]},"WebhookEvent":{"type":"string","enum":["payment.settled","payment.settle-refused","payment.short","payment.expired","order.status"]},"TimelineKind":{"type":"string","enum":["issued","sighted","picked","short","settled","expired","shifted"]},"Rail":{"type":"string","enum":["BTC","LTC","ETH","USDT","USDC"]},"Asset":{"type":"string","enum":["BTC","LTC","ETH","USDT_ERC20","USDC_ERC20","USDT_BEP20","USDC_BEP20","USDT_TRC20","USDC_TRC20","USDC_BASE"]},"Keyring":{"type":"string","enum":["BTC","LTC","ETH","TRX"]},"Chain":{"type":"string","enum":["BTC","LTC","ETH","BSC","TRX","BASE"]},"Currency":{"type":"string","enum":["USD"]},"Method":{"type":"string","enum":["crypto"]},"Channel":{"type":"string","enum":["web","telegram"]},"Order":{"type":"object","description":"An order, staff side. What `GET /api/orders/:reference` returns under `order`.","required":["id","merchantId","reference","publicToken","status","currency","customerRef","customerEmail","locale","channel","needsQuote","goodsCents","shippingCents","railDiscountCents","totalCents","paymentMethod","paymentAsset","paymentOnramp","offeredRails","offeredAssets","refundDestination","metadata","createdAt","updatedAt"],"properties":{"id":{"type":"integer","description":"Sequential, deployment-wide. Never shown to a customer."},"merchantId":{"type":"integer","description":"Whose order this is. Always your own."},"reference":{"type":"string","description":"The handle a person says out loud. Unique within a shop."},"publicToken":{"type":["string","null"],"description":"The customer's own handle for this order. Send them to /i/<publicToken>. 131 bits, never logged, never shown where the reference belongs."},"status":{"$ref":"#/components/schemas/OrderStatus","description":"Where the order is."},"currency":{"type":"string","description":"ISO 4217, upper case. What the cents are cents of."},"customerRef":{"type":["string","null"],"description":"Your own handle for the person. Never reaches /i."},"customerEmail":{"type":["string","null"],"description":"Never reaches /i."},"locale":{"type":"string","description":"Two letters. Defaults to en."},"channel":{"$ref":"#/components/schemas/Channel","description":"Decides whether an outstanding instruction holds goods."},"needsQuote":{"type":"boolean","description":"True while nobody has priced it. The totals are null and null is NOT zero."},"goodsCents":{"type":["integer","null"],"description":"Integer minor units. Null means not priced yet."},"shippingCents":{"type":["integer","null"],"description":"Integer minor units. Null means not priced yet."},"railDiscountCents":{"type":"integer","description":"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":{"type":["integer","null"],"description":"goodsCents - railDiscountCents + shippingCents, or null while either is. Zero means free and the order is registered already paid."},"paymentMethod":{"type":["string","null"],"description":"What they SAID they would pay with."},"paymentAsset":{"type":["string","null"],"description":"A stated preference. The instruction is the truth."},"paymentOnramp":{"type":["string","null"],"description":"Residue of the withdrawn card lane. Always empty."},"offeredRails":{"type":"array","items":{"type":"string"},"description":"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":{"type":"array","items":{"type":"string"},"description":"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":{"type":["string","null"],"description":"Where the customer would like money sent back."},"metadata":{"type":["object","null"],"description":"Your own blob, replaced wholesale. Nothing here reads it."},"createdAt":{"type":"string","description":"SQL UTC, `YYYY-MM-DD HH:MM:SS`. Not ISO 8601, and no Z."},"updatedAt":{"type":"string","description":"SQL UTC text: `YYYY-MM-DD HH:MM:SS`, no timezone and no Z."}},"additionalProperties":false},"CustomerOrder":{"type":"object","description":"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.","required":["reference","status","currency","locale","needsQuote","goodsCents","shippingCents","railDiscountCents","totalCents","createdAt"],"properties":{"reference":{"type":"string","description":"The handle a person says out loud."},"status":{"$ref":"#/components/schemas/OrderStatus","description":"Where the order is."},"currency":{"type":"string","description":"ISO 4217, upper case."},"locale":{"type":"string","description":"Two letters, as the host application supplied them."},"needsQuote":{"type":"boolean","description":"True while nobody has priced it."},"goodsCents":{"type":["integer","null"],"description":"Integer minor units."},"shippingCents":{"type":["integer","null"],"description":"Integer minor units."},"railDiscountCents":{"type":"integer","description":"Zero on everything registered now."},"totalCents":{"type":["integer","null"],"description":"What is owed, in integer minor units."},"createdAt":{"type":"string","description":"SQL UTC text: `YYYY-MM-DD HH:MM:SS`, no timezone and no Z."}},"additionalProperties":false},"Payment":{"type":"object","description":"A payment instruction, staff side. An instruction to a customer, never a movement of money — payd holds public keys and cannot send anything.","required":["id","merchantId","orderId","method","status","amountCents","currency","reference","rail","asset","address","derivationIndex","pickedAsset","legs","quotedAmount","quotedRateCents","quotedAt","rateExpiresAt","expiresAt","declaredAt","chainSeenAt","chainAmount","chainConfirmations","shortfallUnits","slippageBps","confirmedAt","confirmedBy","externalRef","receivedCents","note","createdAt"],"properties":{"id":{"type":"integer","description":"Small and sequential. What /api/payments/:id takes."},"merchantId":{"type":"integer","description":"Copied from the order at issue."},"orderId":{"type":"integer","description":"The order this bills."},"method":{"$ref":"#/components/schemas/Method","description":"crypto. There is no other."},"status":{"$ref":"#/components/schemas/PaymentStatus","description":"awaiting, confirmed, expired or void."},"amountCents":{"type":"integer","description":"What was owed, snapshotted at issue."},"currency":{"type":"string","description":"ISO 4217, upper case. What the cents are cents of."},"reference":{"type":["string","null"],"description":"Written empty. The handle lives on the order."},"rail":{"anyOf":[{"$ref":"#/components/schemas/Rail"},{"type":"null"}],"description":"The TICKER the customer chose and the amount is quoted in."},"asset":{"anyOf":[{"$ref":"#/components/schemas/Asset"},{"type":"null"}],"description":"The LEDGER being watched. A rail narrowed to one network."},"address":{"type":["string","null"],"description":"Where to send it. Derived from the shop's extended PUBLIC key."},"derivationIndex":{"type":"integer","description":"Staff side only. A running count of the shop's orders."},"pickedAsset":{"anyOf":[{"$ref":"#/components/schemas/Asset"},{"type":"null"}],"description":"Which ledger this settles from. Null on instructions issued before networks were picked one at a time; otherwise always equal to `asset`."},"legs":{"type":"array","items":{"$ref":"#/components/schemas/PaymentLeg"},"description":"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":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"What to send, as an exact decimal string. NEVER a float: one ether is 1e18 wei."},"quotedRateCents":{"type":"integer","description":"Fiat cents per whole unit at the moment of the lock."},"quotedAt":{"type":"string","description":"SQL UTC text: `YYYY-MM-DD HH:MM:SS`, no timezone and no Z."},"rateExpiresAt":{"type":["string","null"],"description":"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":{"type":["string","null"],"description":"When the HOLD lapses. Not when the address stops working."},"declaredAt":{"type":["string","null"],"description":"When somebody pressed 'I've sent it'. Never evidence."},"chainSeenAt":{"type":["string","null"],"description":"When the chain was last read for this instruction."},"chainAmount":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"What the chain has seen, as an exact string."},"chainConfirmations":{"type":["integer","null"],"description":"How deep it is buried."},"shortfallUnits":{"type":"string","x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"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":{"type":"integer","description":"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":{"type":["string","null"],"description":"When it settled."},"confirmedBy":{"type":["string","null"],"description":"Staff side only."},"externalRef":{"type":["string","null"],"description":"A transaction id somebody recorded by hand."},"receivedCents":{"type":["integer","null"],"description":"What actually arrived, in fiat cents."},"note":{"type":["string","null"],"description":"Staff side only. The operator's own note."},"createdAt":{"type":"string","description":"SQL UTC text: `YYYY-MM-DD HH:MM:SS`, no timezone and no Z."}},"additionalProperties":false},"PaymentLeg":{"type":"object","description":"One ledger of one instruction, staff side. Two legs can share an address: ERC-20 and BEP-20 are two balances at one `0x…`.","required":["asset","address","network","derivationIndex","chainSeenAt","chainAmount","chainConfirmations"],"properties":{"asset":{"$ref":"#/components/schemas/Asset","description":"Which ledger."},"address":{"type":"string","description":"Derived from the shop's extended public key."},"network":{"type":"string","description":"What to tell the customer. 'Tron (TRC-20)', not 'TRX'."},"derivationIndex":{"type":"integer","description":"Staff side only. Counted per keyring."},"chainSeenAt":{"type":["string","null"],"description":"When this leg was last read."},"chainAmount":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"What this leg has seen."},"chainConfirmations":{"type":["integer","null"],"description":"How deep, on this ledger."}},"additionalProperties":false},"CustomerPayment":{"type":"object","description":"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.","required":["id","orderId","method","status","amountCents","currency","reference","rail","asset","address","pickedAsset","legs","quotedAmount","quotedRateCents","quotedAt","rateExpiresAt","expiresAt","declaredAt","chainSeenAt","chainAmount","chainConfirmations","confirmedAt","externalRef","receivedCents","createdAt"],"properties":{"id":{"type":"integer","description":"Kept here, stripped again on /i — see InvoicePayment."},"orderId":{"type":"integer","description":"Kept here, stripped again on /i."},"method":{"$ref":"#/components/schemas/Method","description":"crypto. There is no other method."},"status":{"$ref":"#/components/schemas/PaymentStatus","description":"awaiting, confirmed, expired or void."},"amountCents":{"type":"integer","description":"What was owed, at issue."},"currency":{"type":"string","description":"ISO 4217, upper case. What the cents are cents of."},"reference":{"type":["string","null"],"description":"Written empty."},"rail":{"anyOf":[{"$ref":"#/components/schemas/Rail"},{"type":"null"}],"description":"The ticker the amount is quoted in."},"asset":{"anyOf":[{"$ref":"#/components/schemas/Asset"},{"type":"null"}],"description":"The ledger the address belongs to."},"address":{"type":["string","null"],"description":"Where to send it."},"pickedAsset":{"anyOf":[{"$ref":"#/components/schemas/Asset"},{"type":"null"}],"description":"Which ledger this settles from."},"legs":{"type":"array","items":{"$ref":"#/components/schemas/CustomerPaymentLeg"},"description":"Every ledger an address was handed out on."},"quotedAmount":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"Exact decimal string. Never a float."},"quotedRateCents":{"type":"integer","description":"Fiat cents per whole unit at the lock."},"quotedAt":{"type":"string","description":"SQL UTC text: `YYYY-MM-DD HH:MM:SS`, no timezone and no Z."},"rateExpiresAt":{"type":["string","null"],"description":"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":{"type":["string","null"],"description":"When the hold lapses."},"declaredAt":{"type":["string","null"],"description":"When somebody said they had sent it."},"chainSeenAt":{"type":["string","null"],"description":"When the chain was last read."},"chainAmount":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"What the chain has seen."},"chainConfirmations":{"type":["integer","null"],"description":"How deep it is buried, in confirmations."},"confirmedAt":{"type":["string","null"],"description":"When it settled."},"externalRef":{"type":["string","null"],"description":"A transaction id recorded by hand."},"receivedCents":{"type":["integer","null"],"description":"What arrived, in fiat cents."},"createdAt":{"type":"string","description":"SQL UTC text: `YYYY-MM-DD HH:MM:SS`, no timezone and no Z."}},"additionalProperties":false},"CustomerPaymentLeg":{"type":"object","description":"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.","required":["asset","address","network","chainSeenAt","chainAmount","chainConfirmations"],"properties":{"asset":{"$ref":"#/components/schemas/Asset","description":"Which ledger."},"address":{"type":"string","description":"Where to send it."},"network":{"type":"string","description":"What to tell the customer."},"chainSeenAt":{"type":["string","null"],"description":"When this leg was last read."},"chainAmount":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"What this leg has seen."},"chainConfirmations":{"type":["integer","null"],"description":"How deep, on this ledger."}},"additionalProperties":false},"OrderPayment":{"type":"object","description":"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.","required":["id","orderId","method","status","amountCents","currency","reference","rail","asset","address","pickedAsset","legs","quotedAmount","quotedRateCents","quotedAt","rateExpiresAt","expiresAt","declaredAt","chainSeenAt","chainAmount","chainConfirmations","confirmedAt","externalRef","receivedCents","createdAt","requiredConfirmations","settlement","outstanding"],"properties":{"id":{"type":"integer","description":"Kept here, stripped again on /i — see InvoicePayment."},"orderId":{"type":"integer","description":"Kept here, stripped again on /i."},"method":{"$ref":"#/components/schemas/Method","description":"crypto. There is no other method."},"status":{"$ref":"#/components/schemas/PaymentStatus","description":"awaiting, confirmed, expired or void."},"amountCents":{"type":"integer","description":"What was owed, at issue."},"currency":{"type":"string","description":"ISO 4217, upper case. What the cents are cents of."},"reference":{"type":["string","null"],"description":"Written empty."},"rail":{"anyOf":[{"$ref":"#/components/schemas/Rail"},{"type":"null"}],"description":"The ticker the amount is quoted in."},"asset":{"anyOf":[{"$ref":"#/components/schemas/Asset"},{"type":"null"}],"description":"The ledger the address belongs to."},"address":{"type":["string","null"],"description":"Where to send it."},"pickedAsset":{"anyOf":[{"$ref":"#/components/schemas/Asset"},{"type":"null"}],"description":"Which ledger this settles from."},"legs":{"type":"array","items":{"$ref":"#/components/schemas/OrderPaymentLeg"},"description":"Every ledger an address was handed out on."},"quotedAmount":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"Exact decimal string. Never a float."},"quotedRateCents":{"type":"integer","description":"Fiat cents per whole unit at the lock."},"quotedAt":{"type":"string","description":"SQL UTC text: `YYYY-MM-DD HH:MM:SS`, no timezone and no Z."},"rateExpiresAt":{"type":["string","null"],"description":"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":{"type":["string","null"],"description":"When the hold lapses."},"declaredAt":{"type":["string","null"],"description":"When somebody said they had sent it."},"chainSeenAt":{"type":["string","null"],"description":"When the chain was last read."},"chainAmount":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"What the chain has seen."},"chainConfirmations":{"type":["integer","null"],"description":"How deep it is buried, in confirmations."},"confirmedAt":{"type":["string","null"],"description":"When it settled."},"externalRef":{"type":["string","null"],"description":"A transaction id recorded by hand."},"receivedCents":{"type":["integer","null"],"description":"What arrived, in fiat cents."},"createdAt":{"type":"string","description":"SQL UTC text: `YYYY-MM-DD HH:MM:SS`, no timezone and no Z."},"requiredConfirmations":{"type":["integer","null"],"description":"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":{"$ref":"#/components/schemas/Settlement","description":"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":{"type":"string","x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"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."}},"additionalProperties":false},"OrderPaymentLeg":{"type":"object","description":"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.","required":["asset","address","network","chainSeenAt","chainAmount","chainConfirmations","requiredConfirmations"],"properties":{"asset":{"$ref":"#/components/schemas/Asset","description":"Which ledger."},"address":{"type":"string","description":"Where to send it."},"network":{"type":"string","description":"What to tell the customer."},"chainSeenAt":{"type":["string","null"],"description":"When this leg was last read."},"chainAmount":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"What this leg has seen."},"chainConfirmations":{"type":["integer","null"],"description":"How deep, on this ledger."},"requiredConfirmations":{"type":"integer","description":"How deep this ledger has to be buried on this deployment."}},"additionalProperties":false},"InvoicePayment":{"type":"object","description":"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.","required":["method","status","amountCents","currency","reference","rail","asset","address","pickedAsset","legs","quotedAmount","quotedRateCents","quotedAt","rateExpiresAt","expiresAt","declaredAt","chainSeenAt","chainAmount","chainConfirmations","confirmedAt","externalRef","receivedCents","createdAt","requiredConfirmations"],"properties":{"method":{"$ref":"#/components/schemas/Method","description":"crypto. There is no other method."},"status":{"$ref":"#/components/schemas/PaymentStatus","description":"awaiting, confirmed, expired or void."},"amountCents":{"type":"integer","description":"What was owed."},"currency":{"type":"string","description":"ISO 4217, upper case. What the cents are cents of."},"reference":{"type":["string","null"],"description":"Written empty."},"rail":{"anyOf":[{"$ref":"#/components/schemas/Rail"},{"type":"null"}],"description":"The ticker the amount is quoted in."},"asset":{"anyOf":[{"$ref":"#/components/schemas/Asset"},{"type":"null"}],"description":"The ledger being watched."},"address":{"type":["string","null"],"description":"Where to send it."},"pickedAsset":{"anyOf":[{"$ref":"#/components/schemas/Asset"},{"type":"null"}],"description":"Which ledger this settles from."},"legs":{"type":"array","items":{"$ref":"#/components/schemas/InvoiceLeg"},"description":"Every ledger an address was handed out on."},"quotedAmount":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"Exact decimal string."},"quotedRateCents":{"type":"integer","description":"Fiat cents per whole unit at the lock."},"quotedAt":{"type":"string","description":"SQL UTC text: `YYYY-MM-DD HH:MM:SS`, no timezone and no Z."},"rateExpiresAt":{"type":["string","null"],"description":"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":{"type":["string","null"],"description":"When the hold lapses."},"declaredAt":{"type":["string","null"],"description":"When somebody said they had sent it."},"chainSeenAt":{"type":["string","null"],"description":"When the chain was last read."},"chainAmount":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"What the chain has seen."},"chainConfirmations":{"type":["integer","null"],"description":"How deep it is buried, in confirmations."},"confirmedAt":{"type":["string","null"],"description":"When it settled."},"externalRef":{"type":["string","null"],"description":"A transaction id recorded by hand."},"receivedCents":{"type":["integer","null"],"description":"What arrived, in fiat cents."},"createdAt":{"type":"string","description":"SQL UTC text: `YYYY-MM-DD HH:MM:SS`, no timezone and no Z."},"requiredConfirmations":{"type":["integer","null"],"description":"How deep the picked ledger has to be buried. Null when no ledger is picked."}},"additionalProperties":false},"InvoiceLeg":{"type":"object","description":"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.","required":["asset","address","network","chainSeenAt","chainAmount","chainConfirmations","requiredConfirmations"],"properties":{"asset":{"$ref":"#/components/schemas/Asset","description":"Which ledger."},"address":{"type":"string","description":"Where to send it."},"network":{"type":"string","description":"What to tell the customer."},"chainSeenAt":{"type":["string","null"],"description":"When this leg was last read."},"chainAmount":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"What this leg has seen."},"chainConfirmations":{"type":["integer","null"],"description":"How deep, on this ledger."},"requiredConfirmations":{"type":"integer","description":"How deep this ledger has to be buried."}},"additionalProperties":false},"InvoiceState":{"type":"object","description":"The whole of what `GET /i/:token/state` returns. No key. The token in the path is the entire authorisation.","required":["order","payment","state","timeline"],"properties":{"order":{"$ref":"#/components/schemas/CustomerOrder","description":"The order, projected through the allow list."},"payment":{"anyOf":[{"$ref":"#/components/schemas/InvoicePayment"},{"type":"null"}],"description":"The outstanding instruction, or null."},"state":{"$ref":"#/components/schemas/InvoiceStateValue","description":"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":{"type":"array","items":{"$ref":"#/components/schemas/TimelineEvent"},"description":"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."}},"additionalProperties":false},"TimelineEvent":{"type":"object","description":"One moment in an instruction's life. Append only, never revised, customer-safe by construction — there is nothing on it to strip.","required":["kind","asset","amount","confirmations","required","blockHeight","detail","at"],"properties":{"kind":{"$ref":"#/components/schemas/TimelineKind","description":"What happened."},"asset":{"anyOf":[{"$ref":"#/components/schemas/Asset"},{"type":"null"}],"description":"Which ledger, where one is responsible."},"amount":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"The figure, as an exact string."},"confirmations":{"type":["integer","null"],"description":"How deep it was."},"required":{"type":["integer","null"],"description":"How deep it needed to be."},"blockHeight":{"type":["integer","null"],"description":"Null until something reads blocks rather than balances."},"detail":{"type":["object","null"],"description":"Kind-specific. Arrivals, a remaining figure, a pick."},"at":{"type":"string","description":"SQL UTC text: `YYYY-MM-DD HH:MM:SS`, no timezone and no Z."}},"additionalProperties":false},"Refund":{"type":"object","description":"A record that money was sent back. RECORDS ONLY — payd sends nothing and cannot: it holds extended public keys. A person made the transfer.","required":["id","orderId","paymentId","amountCents","currency","method","destination","asset","assetAmount","externalRef","note","refundedBy","createdAt"],"properties":{"id":{"type":"integer","description":"Sequential, and shared by every merchant on the deployment."},"orderId":{"type":"integer","description":"Which order this belongs to."},"paymentId":{"type":"integer","description":"Which settled instruction it is refunded against."},"amountCents":{"type":"integer","description":"Integer minor units."},"currency":{"type":"string","description":"ISO 4217, upper case. What the cents are cents of."},"method":{"$ref":"#/components/schemas/Method","description":"Copied from the payment."},"destination":{"type":["string","null"],"description":"Where the person sent it."},"asset":{"anyOf":[{"$ref":"#/components/schemas/Asset"},{"type":"null"}],"description":"Which ledger it went out on."},"assetAmount":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"The crypto figure, as an exact string."},"externalRef":{"type":["string","null"],"description":"The transaction id."},"note":{"type":["string","null"],"description":"Why the money was sent back."},"refundedBy":{"type":["string","null"],"description":"Who recorded it."},"createdAt":{"type":"string","description":"SQL UTC text: `YYYY-MM-DD HH:MM:SS`, no timezone and no Z."}},"additionalProperties":false},"RefundPosition":{"type":"object","description":"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.","required":["payment","receivedCents","refundedCents","owedCents"],"properties":{"payment":{"anyOf":[{"$ref":"#/components/schemas/PaymentRow"},{"type":"null"}],"description":"The settled instruction, as it comes out of SQL. snake_case throughout — see PaymentRow."},"receivedCents":{"type":"integer","description":"What arrived."},"refundedCents":{"type":"integer","description":"What has been recorded as sent back."},"owedCents":{"type":"integer","description":"receivedCents - refundedCents, never below zero."}},"additionalProperties":false},"PaymentRow":{"type":"object","description":"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.","required":["id","merchant_id","order_id","method","status","amount_cents","currency","reference","rail","asset","address","derivation_index","picked_asset","issued_plan","quoted_amount","quoted_rate_cents","quoted_at","expires_at","declared_at","chain_seen_at","chain_amount","chain_confirmations","shortfall_units","slippage_bps","onramp_started_at","onramp_status","onramp_ref","onramp_updated_at","onramp_tx","confirmed_at","confirmed_by","external_ref","received_cents","note","created_at"],"properties":{"id":{"type":"integer","description":"Sequential, and shared by every merchant on the deployment."},"merchant_id":{"type":"integer","description":"Whose this is. Always your own."},"order_id":{"type":"integer","description":"Which order this belongs to."},"method":{"$ref":"#/components/schemas/Method","description":"crypto. There is no other method."},"status":{"$ref":"#/components/schemas/PaymentStatus","description":"confirmed, by construction."},"amount_cents":{"type":"integer","description":"What was owed."},"currency":{"type":"string","description":"ISO 4217, upper case. What the cents are cents of."},"reference":{"type":["string","null"],"description":"Written empty."},"rail":{"anyOf":[{"$ref":"#/components/schemas/Rail"},{"type":"null"}],"description":"The ticker the amount is quoted in."},"asset":{"anyOf":[{"$ref":"#/components/schemas/Asset"},{"type":"null"}],"description":"The ledger the address belongs to."},"address":{"type":["string","null"],"description":"Where money was asked for."},"derivation_index":{"type":"integer","description":"The index this address came from."},"picked_asset":{"anyOf":[{"$ref":"#/components/schemas/Asset"},{"type":"null"}],"description":"Which ledger it settled from."},"issued_plan":{"type":["string","null"],"description":"The payd.sh plan the instruction was issued under. Nothing records it yet, so it is null."},"quoted_amount":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"TEXT in SQLite, compared as a string."},"quoted_rate_cents":{"type":"integer","description":"The locked rate."},"quoted_at":{"type":["string","null"],"description":"SQL UTC text: `YYYY-MM-DD HH:MM:SS`, no timezone and no Z."},"expires_at":{"type":["string","null"],"description":"When the hold lapsed."},"declared_at":{"type":["string","null"],"description":"When somebody said they had sent it."},"chain_seen_at":{"type":["string","null"],"description":"When the chain was last read."},"chain_amount":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"TEXT in SQLite, and compared as a string."},"chain_confirmations":{"type":["integer","null"],"description":"How deep it is buried, in confirmations."},"shortfall_units":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"TEXT. The shortfall allowance."},"slippage_bps":{"type":"integer","description":"The shop's slippage when it was issued, in basis points of the quote. 0 for none."},"onramp_started_at":{"type":["string","null"],"description":"Residue of the withdrawn card lane. Never set."},"onramp_status":{"type":"string","description":"Residue of the withdrawn card lane. Always empty."},"onramp_ref":{"type":"string","description":"Residue of the withdrawn card lane. Always empty."},"onramp_updated_at":{"type":["string","null"],"description":"Residue of the withdrawn card lane. Never set."},"onramp_tx":{"type":"string","description":"Residue of the withdrawn card lane. Always empty."},"confirmed_at":{"type":["string","null"],"description":"When it settled."},"confirmed_by":{"type":["string","null"],"description":"Who settled it."},"external_ref":{"type":["string","null"],"description":"A transaction id."},"received_cents":{"type":["integer","null"],"description":"What arrived, in fiat cents."},"note":{"type":["string","null"],"description":"The operator's note."},"created_at":{"type":"string","description":"SQL UTC text: `YYYY-MM-DD HH:MM:SS`, no timezone and no Z."}},"additionalProperties":false},"Check":{"type":"object","description":"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.","required":["verdict","seen","expected","remaining","confirmations","required"],"properties":{"verdict":{"type":"string","description":"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":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"What the chain showed."},"expected":{"type":"string","x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"What was asked for."},"remaining":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"Still owed, on a shortfall only."},"confirmations":{"type":["integer","null"],"description":"How deep it is buried, in confirmations."},"required":{"type":["integer","null"],"description":"How deep it has to be."}},"additionalProperties":false},"Policy":{"type":"object","description":"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.","required":["chains","assets"],"properties":{"chains":{"type":"array","items":{"$ref":"#/components/schemas/ChainPolicy"},"description":"One row per chain this build can watch."},"assets":{"type":"array","items":{"$ref":"#/components/schemas/AssetPolicy"},"description":"One row per ledger this build knows — NOT the `assets` list beside it, which is what this shop can be billed on."}},"additionalProperties":false},"ChainPolicy":{"type":"object","description":"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.","required":["chain","confirmations","chainId"],"properties":{"chain":{"$ref":"#/components/schemas/Chain","description":"Which ledger family."},"confirmations":{"type":"integer","description":"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":{"type":["integer","null"],"description":"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."}},"additionalProperties":false},"AssetPolicy":{"type":"object","description":"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.","required":["asset","chain","tolerance","decimals","quoteDecimals"],"properties":{"asset":{"$ref":"#/components/schemas/Asset","description":"Which ledger."},"chain":{"$ref":"#/components/schemas/Chain","description":"Which chain carries it, and so which depth applies."},"tolerance":{"type":"string","x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"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":{"type":"integer","description":"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":{"type":"integer","description":"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."}},"additionalProperties":false},"MerchantWhoami":{"type":"object","description":"Everything a shop needs to know about its own setup, in one call. Step zero of any integration.","required":["merchant","key","canBeBilled","rails","wallets","slippageBps"],"properties":{"merchant":{"type":"object","description":"`{ id, name }`. Your own."},"key":{"type":"object","description":"`{ 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":{"type":"boolean","description":"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":{"type":"array","items":{"type":"object"},"description":"`{ rail, label, ticker, networks[] }` — what to offer a customer."},"wallets":{"type":"array","items":{"type":"object"},"description":"`{ keyring, label, path, configured, assets[] }` — what to export from a wallet and what each export switches on."},"slippageBps":{"type":"integer","description":"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."}},"additionalProperties":false},"Key":{"type":"object","description":"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.","required":["id","merchant_id","label","prefix","scope","created_at"],"properties":{"id":{"type":"integer","description":"What DELETE /api/merchant/keys/:id takes."},"merchant_id":{"type":"integer","description":"Whose this is. Always your own."},"label":{"type":"string","description":"Whatever you called it."},"prefix":{"type":"string","description":"The first characters, kept in the clear so a list can name it."},"scope":{"$ref":"#/components/schemas/Scope","description":"write or admin."},"created_at":{"type":"string","description":"SQL UTC text: `YYYY-MM-DD HH:MM:SS`, no timezone and no Z."}},"additionalProperties":false},"KeyListing":{"type":"object","description":"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.","required":["id","label","prefix","scope","created_at","last_used_at","revoked_at"],"properties":{"id":{"type":"integer","description":"What revoke takes."},"label":{"type":"string","description":"Whatever you called it."},"prefix":{"type":"string","description":"The first characters."},"scope":{"$ref":"#/components/schemas/Scope","description":"write or admin."},"created_at":{"type":"string","description":"SQL UTC text: `YYYY-MM-DD HH:MM:SS`, no timezone and no Z."},"last_used_at":{"type":["string","null"],"description":"Coarse: stamped at most once a minute."},"revoked_at":{"type":["string","null"],"description":"Null while it still works."}},"additionalProperties":false},"Wallet":{"type":"object","description":"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.","required":["keyring","xpub","verifiedAddress","verifiedAt","source"],"properties":{"keyring":{"$ref":"#/components/schemas/Keyring","description":"BTC, LTC, ETH or TRX."},"xpub":{"type":"string","description":"The extended PUBLIC key. Any of xpub, ypub, zpub, Ltub or Mtub."},"verifiedAddress":{"type":["string","null"],"description":"The address at index 0 the merchant checked. Null for an environment key, which nobody confirmed through payd."},"verifiedAt":{"type":["string","null"],"description":"SQL UTC text: `YYYY-MM-DD HH:MM:SS`, no timezone and no Z. Null for an environment key."},"source":{"type":"string","description":"`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."}},"additionalProperties":false},"Address":{"type":"object","description":"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.","required":["index","address","instructions"],"properties":{"index":{"type":"integer","description":"The derivation index under the keyring."},"address":{"type":"string","description":"The address itself."},"instructions":{"type":"array","items":{"type":"object"},"description":"`{ paymentId, orderReference, rail, status, confirmedAt, issuedAt, assets[] }`. Several, routinely: one address can carry a void instruction and a live one."}},"additionalProperties":false},"Webhook":{"type":"object","description":"A delivery endpoint. snake_case — the row is returned as SQLite hands it over.","required":["id","url","created_at","disabled_at"],"properties":{"id":{"type":"integer","description":"What DELETE and enable take."},"url":{"type":"string","description":"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":{"type":"string","description":"SQL UTC text: `YYYY-MM-DD HH:MM:SS`, no timezone and no Z."},"disabled_at":{"type":["string","null"],"description":"Set by the breaker. Null while it is live."}},"additionalProperties":false},"Delivery":{"type":"object","description":"One attempt to tell you something. snake_case, for the same reason.","required":["id","webhook_id","event","attempts","delivered_at","gave_up_at","next_try_at","last_error","created_at"],"properties":{"id":{"type":"integer","description":"Sequential, and shared by every merchant on the deployment."},"webhook_id":{"type":"integer","description":"Which endpoint."},"event":{"$ref":"#/components/schemas/WebhookEvent","description":"What happened."},"attempts":{"type":"integer","description":"How many tries so far, up to six."},"delivered_at":{"type":["string","null"],"description":"When it was accepted. Null while it has not been."},"gave_up_at":{"type":["string","null"],"description":"When the ladder ran out."},"next_try_at":{"type":["string","null"],"description":"When it will be tried again."},"last_error":{"type":["string","null"],"description":"What went wrong last time."},"created_at":{"type":"string","description":"SQL UTC text: `YYYY-MM-DD HH:MM:SS`, no timezone and no Z."}},"additionalProperties":false},"WebhookBody":{"type":"object","description":"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>.","required":["event","at","order","payment"],"properties":{"event":{"$ref":"#/components/schemas/WebhookEvent","description":"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":{"type":"string","description":"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":{"$ref":"#/components/schemas/WebhookBodyOrder","description":"Which order this is about, and where it now stands."},"payment":{"anyOf":[{"$ref":"#/components/schemas/WebhookBodyPayment"},{"type":"null"}],"description":"The instruction this is about, or null where the event has none — an expiry sweep and a free order both carry no instruction."}},"additionalProperties":false},"WebhookBodyOrder":{"type":"object","description":"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.","required":["reference","status","totalCents","currency"],"properties":{"reference":{"type":"string","description":"Yours, the one you sent on POST /api/orders."},"status":{"$ref":"#/components/schemas/OrderStatus","description":"Where the order stands as this was composed."},"totalCents":{"type":["integer","null"],"description":"What the order came to, in fiat minor units."},"currency":{"type":"string","description":"ISO 4217, upper case. What the cents are cents of."}},"additionalProperties":false},"WebhookBodyPayment":{"type":"object","description":"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.","required":["id","rail","asset","amountCents","receivedCents","status","chainAmount","outstanding","overCents"],"properties":{"id":{"type":["integer","null"],"description":"Which instruction, for GET /api/payments/:id."},"rail":{"anyOf":[{"$ref":"#/components/schemas/Rail"},{"type":"null"}],"description":"The ticker the amount was quoted in."},"asset":{"anyOf":[{"$ref":"#/components/schemas/Asset"},{"type":"null"}],"description":"The ledger the address belongs to."},"amountCents":{"type":["integer","null"],"description":"What was owed, at issue."},"receivedCents":{"type":["integer","null"],"description":"What arrived, in fiat cents at the quoted rate."},"status":{"$ref":"#/components/schemas/PaymentStatus","description":"awaiting, confirmed, expired or void."},"chainAmount":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"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":{"type":["string","null"],"x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"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":{"type":["integer","null"],"description":"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."}},"additionalProperties":false},"Health":{"type":"object","description":"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.","required":["ok","version","schema","chain","rates","capabilities"],"properties":{"ok":{"type":"boolean","description":"This process is up and answering. Always true if you got a body."},"version":{"type":"string","description":"Which build is running. Matches deno.json."},"schema":{"type":"integer","description":"The database generation this build reads."},"chain":{"type":"object","description":"`{ 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":{"type":"object","description":"`{ fresh, ageSeconds }`. Both null when nothing has ever been priced."},"capabilities":{"type":"array","items":{"type":"string"},"description":"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."}},"additionalProperties":false},"FieldErrors":{"type":"object","description":"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>\" }`.","required":["errors"],"properties":{"errors":{"type":"object","description":"Field name to sentence. One entry per field that was refused."}},"additionalProperties":false},"Error":{"type":"object","description":"Every refusal except field validation. `{\"error\": \"<sentence>\"}`, and `code` beside it on the one refusal that carries one.","required":["error"],"properties":{"error":{"type":"string","description":"One sentence saying what was NOT done. It never names another merchant, an address you were not given, a key, a token or a secret, and it does not explain how to get closer."},"code":{"type":"string","enum":["plan_ceiling"],"description":"Present only on a refusal that names one, for a host to branch on without matching the sentence: `plan_ceiling`, the 409 POST /api/orders/{reference}/payment answers when a plan's open-invoice ceiling refuses a new invoice. Every other refusal is the sentence alone."}},"additionalProperties":false}},"responses":{"FieldErrors":{"description":"Field validation. The only plural error shape in this API: `{\"errors\": {\"<field>\": \"<sentence>\"}}`. A reference already in use arrives here and means ALREADY REGISTERED, not \"bad request\".","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FieldErrors"}}}},"BadRequest":{"description":"The request could not be used. Either field validation, `{\"errors\": {\"<field>\": \"<sentence>\"}}`, where a reference already in use means ALREADY REGISTERED; or a body that is not one JSON object, `{\"error\": \"<sentence>\"}`, and then nothing at all was done. An empty body is not refused: it is read as no body.","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/FieldErrors"},{"$ref":"#/components/schemas/Error"}]}}}},"Unauthorized":{"description":"The credential did not resolve. An unknown key, a revoked key and a suspended merchant are one answer with no way to tell them apart, so there is nothing here to branch on.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Forbidden":{"description":"The key is real and is the wrong scope. Only on the admin routes, where you already know your own account exists — everywhere else the same situation is a 404.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"Gone, not yours, or not a route. Byte-identical in all three cases, on purpose. Do not branch on it and do not log it as a bug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Conflict":{"description":"The request was well formed and the state forbids it. Read the resource before retrying; a blind retry answers the same way.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"RateLimited":{"description":"A rate limit. `Retry-After` is set on every one of these. Honour it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Failed":{"description":"Something went wrong, said the same way every time: `{\"error\": \"Something went wrong.\"}` and nothing else, ever. A failure never names what failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"paths":{"/health":{"get":{"operationId":"health","summary":"Is this deployment alive, and is it watching the chain.","description":"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.","tags":["service"],"security":[],"x-payd-scope":null,"x-payd-effect":"reads nothing about any merchant","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Nothing changes. Poll it as often as your probe wants.","x-payd-rate-limit":null,"x-payd-next":["getMerchant"],"x-payd-emits":[],"x-payd-audit":null,"x-payd-refusals":[],"responses":{"200":{"description":"Is this deployment alive, and is it watching the chain.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Health"}}}},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/merchant":{"get":{"operationId":"getMerchant","summary":"Who am I, what may this key do, and can this shop be paid yet.","description":"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.","tags":["merchant"],"security":[{"merchantKey":[]}],"x-payd-scope":"any","x-payd-effect":"reads only","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Nothing changes.","x-payd-rate-limit":null,"x-payd-next":["paymentsConfig","createOrder"],"x-payd-emits":[],"x-payd-audit":null,"x-payd-refusals":[{"status":401,"error":"Unauthorized.","meaning":"An unknown key, a revoked key and a suspended merchant, which are deliberately indistinguishable.","fix":"Check the key. There is nothing here to branch on."}],"responses":{"200":{"description":"Who am I, what may this key do, and can this shop be paid yet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MerchantWhoami"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/merchant/keys":{"get":{"operationId":"listKeys","summary":"Every key this shop holds, and when each was last used.","description":"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.","tags":["merchant"],"security":[{"merchantKey":[]}],"x-payd-scope":"admin","x-payd-effect":"reads only","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Nothing changes.","x-payd-rate-limit":null,"x-payd-next":["mintKey","revokeKey"],"x-payd-emits":[],"x-payd-audit":null,"x-payd-refusals":[{"status":403,"error":"That key may take payments but not change where they go. Use an admin key for this.","meaning":"A write key asked an admin question.","fix":"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."}],"responses":{"200":{"description":"Every key this shop holds, and when each was last used.","content":{"application/json":{"schema":{"type":"object","properties":{"keys":{"type":"array","items":{"$ref":"#/components/schemas/KeyListing"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/Failed"}}},"post":{"operationId":"mintKey","summary":"Mint a key, and see it once.","description":"`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.","tags":["merchant"],"security":[{"merchantKey":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"label":{"type":"string","description":"Whatever you want to call it."},"scope":{"$ref":"#/components/schemas/Scope","description":"Defaults to `write`, which is the key that cannot repoint the wallet."}}}}}},"x-payd-scope":"admin","x-payd-effect":"creates a credential","x-payd-safe-to-retry":false,"x-payd-idempotent":false,"x-payd-repeat":"Mints a SECOND key. There is no de-duplication; a retry after a timeout leaves two.","x-payd-rate-limit":null,"x-payd-next":["listKeys","revokeKey"],"x-payd-emits":[],"x-payd-audit":"merchant.key.mint","x-payd-refusals":[{"status":400,"error":"Unknown scope {scope}. One of: {scopes}.","meaning":"The scope is not one of the two.","fix":"Send `write` or `admin`. The response echoes `scopes` beside the error."},{"status":403,"error":"That key may take payments but not change where they go. Use an admin key for this.","meaning":"A write key asked an admin question.","fix":"Use the admin key."},{"status":400,"error":"The request body is not valid JSON.","meaning":"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.","fix":"Send one JSON object, or no body at all. A form-encoded body is never read here."},{"status":400,"error":"The request body must be a JSON object.","meaning":"The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done.","fix":"Send the fields as one JSON object, or no body at all."}],"responses":{"201":{"description":"Mint a key, and see it once.","content":{"application/json":{"schema":{"type":"object","properties":{"key":{"type":"string"},"record":{"$ref":"#/components/schemas/Key"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/merchant/keys/{id}":{"delete":{"operationId":"revokeKey","summary":"Revoke one, including the one making the request.","description":"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.","tags":["merchant"],"security":[{"merchantKey":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"The payment, key or webhook id, as the route's own collection returned it.","schema":{"type":"string"}}],"x-payd-scope":"admin","x-payd-effect":"destroys a credential","x-payd-safe-to-retry":false,"x-payd-idempotent":true,"x-payd-repeat":"The second call answers 404. It is still revoked.","x-payd-rate-limit":null,"x-payd-next":["listKeys"],"x-payd-emits":[],"x-payd-audit":"merchant.key.revoke","x-payd-refusals":[{"status":404,"error":"No such key, or it was already revoked.","meaning":"Gone, already revoked, or not yours. Deliberately one answer.","fix":"Read the list. Do not branch on it."},{"status":403,"error":"That key may take payments but not change where they go. Use an admin key for this.","meaning":"A write key asked an admin question.","fix":"Use the admin key."}],"responses":{"200":{"description":"Revoke one, including the one making the request.","content":{"application/json":{"schema":{"type":"object","properties":{"revoked":{"type":"integer"},"thisKey":{"type":"boolean"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/merchant/wallets":{"get":{"operationId":"listWallets","summary":"The extended PUBLIC keys this shop is billed on, and where each lives.","description":"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.","tags":["wallet"],"security":[{"merchantKey":[]}],"x-payd-scope":"admin","x-payd-effect":"reads only","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Nothing changes.","x-payd-rate-limit":null,"x-payd-next":["setWallet","listAddresses"],"x-payd-emits":[],"x-payd-audit":null,"x-payd-refusals":[{"status":403,"error":"That key may take payments but not change where they go. Use an admin key for this.","meaning":"A write key asked an admin question.","fix":"Use the admin key."}],"responses":{"200":{"description":"The extended PUBLIC keys this shop is billed on, and where each lives.","content":{"application/json":{"schema":{"type":"object","properties":{"wallets":{"type":"array","items":{"$ref":"#/components/schemas/Wallet"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/Failed"}}},"post":{"operationId":"setWallet","summary":"Point a keyring at an extended public key, in two steps.","description":"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.","tags":["wallet"],"security":[{"merchantKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["keyring","xpub"],"properties":{"keyring":{"$ref":"#/components/schemas/Keyring","description":"BTC, LTC, ETH or TRX."},"xpub":{"type":"string","description":"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":{"type":"string","description":"The address at index 0, checked against the wallet itself. Omit it to be shown what to check."}}}}}},"x-payd-scope":"admin","x-payd-effect":"decides where every future payment lands","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Setting the same key again answers 200 with `unchanged: true`.","x-payd-rate-limit":null,"x-payd-next":["listWallets","paymentsConfig"],"x-payd-emits":[],"x-payd-audit":"merchant.wallet.set","x-payd-refusals":[{"status":400,"error":"That's a PRIVATE key.","meaning":"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.","fix":"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."},{"status":400,"error":"That isn't a valid extended key","meaning":"The base58 checksum does not match.","fix":"Re-copy it. `firstAddress: null` rides beside every 400 from this route so both refusals have one shape."},{"status":409,"error":"already been issued","meaning":"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.","fix":"Do not retry. Use a new keyring, or a new shop."},{"status":403,"error":"That key may take payments but not change where they go. Use an admin key for this.","meaning":"A write key tried to decide where money goes. This is the boundary the two scopes exist for.","fix":"Use the admin key."},{"status":400,"error":"The request body is not valid JSON.","meaning":"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.","fix":"Send one JSON object, or no body at all. A form-encoded body is never read here."},{"status":400,"error":"The request body must be a JSON object.","meaning":"The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done.","fix":"Send the fields as one JSON object, or no body at all."}],"responses":{"200":{"description":"Point a keyring at an extended public key, in two steps.","content":{"application/json":{"schema":{"type":"object","properties":{"wallets":{"type":"array","items":{"$ref":"#/components/schemas/Wallet"}},"unchanged":{"type":"boolean"}}}}}},"202":{"description":"Understood, and deliberately not done yet. Nothing has been stored.","content":{"application/json":{"schema":{"type":"object","properties":{"confirm":{"type":"object"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"$ref":"#/components/responses/Conflict"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/merchant/addresses":{"get":{"operationId":"listAddresses","summary":"Every address this shop has been issued on one keyring.","description":"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.","tags":["wallet"],"security":[{"merchantKey":[]}],"parameters":[{"name":"keyring","in":"query","required":true,"description":"In the query string.","schema":{"$ref":"#/components/schemas/Keyring"}},{"name":"since","in":"query","required":false,"description":"Resume from this index.","schema":{"type":"integer"}},{"name":"limit","in":"query","required":false,"description":"Indices, not rows. Up to 1000.","schema":{"type":"integer"}}],"x-payd-scope":"admin","x-payd-effect":"reads only","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Nothing changes.","x-payd-rate-limit":null,"x-payd-next":["listWallets"],"x-payd-emits":[],"x-payd-audit":null,"x-payd-refusals":[{"status":400,"error":"Unknown keyring {keyring}. One of: {keyrings}.","meaning":"Not one of the four.","fix":"The response echoes `keyrings`. An unconfigured keyring is a 200 with an empty list, not this."},{"status":403,"error":"That key may take payments but not change where they go. Use an admin key for this.","meaning":"A write key asked an admin question.","fix":"Use the admin key."}],"responses":{"200":{"description":"`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.","content":{"application/json":{"schema":{"type":"object","properties":{"keyring":{"$ref":"#/components/schemas/Keyring"},"nextIndex":{"type":"integer"},"next":{"type":"integer"},"addresses":{"type":"array","items":{"$ref":"#/components/schemas/Address"}}}}}}},"400":{"$ref":"#/components/responses/FieldErrors"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/merchant/webhooks":{"get":{"operationId":"listWebhooks","summary":"Where settlement notices are sent.","description":"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.","tags":["webhooks"],"security":[{"merchantKey":[]}],"x-payd-scope":"admin","x-payd-effect":"reads only","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Nothing changes.","x-payd-rate-limit":null,"x-payd-next":["addWebhook","listDeliveries"],"x-payd-emits":[],"x-payd-audit":null,"x-payd-refusals":[{"status":403,"error":"That key may take payments but not change where they go. Use an admin key for this.","meaning":"A write key asked an admin question.","fix":"Use the admin key."}],"responses":{"200":{"description":"Where settlement notices are sent.","content":{"application/json":{"schema":{"type":"object","properties":{"webhooks":{"type":"array","items":{"$ref":"#/components/schemas/Webhook"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/Failed"}}},"post":{"operationId":"addWebhook","summary":"Add an endpoint — an https URL or a Telegram chat — and see a URL's signing secret once.","description":"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.","tags":["webhooks"],"security":[{"merchantKey":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","description":"https, and reachable. Send this or `telegram`, not both."},"telegram":{"type":"object","description":"`{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."}}}}}},"x-payd-scope":"admin","x-payd-effect":"decides where settlement notices go","x-payd-safe-to-retry":false,"x-payd-idempotent":false,"x-payd-repeat":"Adds a SECOND endpoint with a second secret. Every event is then delivered twice.","x-payd-rate-limit":null,"x-payd-next":["listWebhooks","listDeliveries"],"x-payd-emits":[],"x-payd-audit":"merchant.webhook.add","x-payd-refusals":[{"status":400,"error":"That is not a URL.","meaning":"It did not parse.","fix":"Send an absolute https URL."},{"status":400,"error":"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.","meaning":"`url` points at api.telegram.org, which would put a bot token in a field that is listed, audited and logged.","fix":"Send `telegram: {botToken, chatId}` instead of `url`."},{"status":400,"error":"Send `url` for an https endpoint or `telegram` for a Telegram chat, not both.","meaning":"The body carried both kinds of endpoint.","fix":"Send one of the two."},{"status":400,"error":"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.","meaning":"`telegram.botToken` is missing or is not shaped like a Bot API token.","fix":"Paste the token exactly as @BotFather printed it."},{"status":400,"error":"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.","meaning":"`telegram.chatId` is missing or is neither a numeric id nor an @username.","fix":"Send the chat's id as a string."},{"status":400,"error":"That chat is already registered.","meaning":"This shop already has an endpoint for that chat.","fix":"Nothing to do; remove the existing one first to change its bot."},{"status":403,"error":"That key may take payments but not change where they go. Use an admin key for this.","meaning":"A write key asked an admin question.","fix":"Use the admin key."},{"status":400,"error":"The request body is not valid JSON.","meaning":"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.","fix":"Send one JSON object, or no body at all. A form-encoded body is never read here."},{"status":400,"error":"The request body must be a JSON object.","meaning":"The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done.","fix":"Send the fields as one JSON object, or no body at all."}],"responses":{"201":{"description":"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.","content":{"application/json":{"schema":{"type":"object","properties":{"webhook":{"$ref":"#/components/schemas/Webhook"},"secret":{"type":"string"},"verify":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/merchant/webhooks/{id}":{"delete":{"operationId":"removeWebhook","summary":"Stop delivering to an endpoint.","description":"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.","tags":["webhooks"],"security":[{"merchantKey":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"The payment, key or webhook id, as the route's own collection returned it.","schema":{"type":"string"}}],"x-payd-scope":"admin","x-payd-effect":"destroys delivery history","x-payd-safe-to-retry":false,"x-payd-idempotent":true,"x-payd-repeat":"The second call answers 404.","x-payd-rate-limit":null,"x-payd-next":["listWebhooks"],"x-payd-emits":[],"x-payd-audit":"merchant.webhook.remove","x-payd-refusals":[{"status":404,"error":"No such webhook.","meaning":"Gone, or not yours.","fix":"Do not branch on it."},{"status":403,"error":"That key may take payments but not change where they go. Use an admin key for this.","meaning":"A write key asked an admin question.","fix":"Use the admin key."}],"responses":{"200":{"description":"Stop delivering to an endpoint.","content":{"application/json":{"schema":{"type":"object","properties":{"removed":{"type":"integer"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/merchant/webhooks/{id}/enable":{"post":{"operationId":"enableWebhook","summary":"Turn an endpoint the breaker disabled back on.","description":"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.","tags":["webhooks"],"security":[{"merchantKey":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"The payment, key or webhook id, as the route's own collection returned it.","schema":{"type":"string"}}],"x-payd-scope":"admin","x-payd-effect":"resumes delivery","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Enabling an enabled endpoint changes nothing.","x-payd-rate-limit":null,"x-payd-next":["listDeliveries"],"x-payd-emits":[],"x-payd-audit":"merchant.webhook.enable","x-payd-refusals":[{"status":404,"error":"No such webhook.","meaning":"Gone, or not yours.","fix":"Do not branch on it."},{"status":403,"error":"That key may take payments but not change where they go. Use an admin key for this.","meaning":"A write key asked an admin question.","fix":"Use the admin key."}],"responses":{"200":{"description":"Turn an endpoint the breaker disabled back on.","content":{"application/json":{"schema":{"type":"object","properties":{"enabled":{"type":"integer"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/merchant/webhooks/deliveries":{"get":{"operationId":"listDeliveries","summary":"What was actually sent, and what happened to it.","description":"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.","tags":["webhooks"],"security":[{"merchantKey":[]}],"parameters":[{"name":"limit","in":"query","required":false,"description":"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.","schema":{"type":"integer"}}],"x-payd-scope":"admin","x-payd-effect":"reads only","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Nothing changes.","x-payd-rate-limit":null,"x-payd-next":["enableWebhook"],"x-payd-emits":[],"x-payd-audit":null,"x-payd-refusals":[{"status":403,"error":"That key may take payments but not change where they go. Use an admin key for this.","meaning":"A write key asked an admin question.","fix":"Use the admin key."}],"responses":{"200":{"description":"What was actually sent, and what happened to it.","content":{"application/json":{"schema":{"type":"object","properties":{"deliveries":{"type":"array","items":{"$ref":"#/components/schemas/Delivery"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/orders":{"post":{"operationId":"createOrder","summary":"Register something to bill.","description":"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.","tags":["orders"],"security":[{"merchantKey":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"reference":{"type":"string","description":"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":{"$ref":"#/components/schemas/Currency","description":"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":{"type":"string","description":"Your handle for the person."},"customerEmail":{"type":"string","description":"Never reaches /i."},"locale":{"type":"string","description":"Defaults to en."},"channel":{"$ref":"#/components/schemas/Channel","description":"`web` holds goods from the moment it is placed; `telegram` holds nothing until money lands."},"goodsCents":{"type":"integer","description":"Whole minor units, or null for not-priced-yet. Never a float."},"shippingCents":{"type":"integer","description":"Whole minor units."},"rails":{"type":"array","items":{"type":"string"},"description":"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":{"type":"array","items":{"type":"string"},"description":"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":{"type":"object","description":"Your blob. Nothing reads it."}}}}}},"x-payd-scope":"any","x-payd-effect":"registers an order; moves no money","x-payd-safe-to-retry":false,"x-payd-idempotent":false,"x-payd-repeat":"A repeat with the same reference answers 400. See above — that is success.","x-payd-rate-limit":null,"x-payd-next":["issuePayment","quoteOrder","getOrder"],"x-payd-emits":[],"x-payd-audit":"order.create","x-payd-refusals":[{"status":400,"error":"Reference {reference} is already in use.","meaning":"THIS IS THE DUPLICATE ANSWER, and a retry after a timeout must read it as ALREADY REGISTERED rather than as a bad request.","fix":"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."},{"status":400,"error":"{field} must be a whole number of cents.","meaning":"A money figure was fractional, negative, or not an integer.","fix":"Integer minor units. A float that looks right in your language is a float that has already lost a cent somewhere."},{"status":400,"error":"currency must be one of the currencies this service can price: {codes}.","meaning":"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.","fix":"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."},{"status":400,"error":"rails must name only coins this service settles: {codes}.","meaning":"A value in `rails` that is not a coin code, on any day. Nothing was registered.","fix":"Send codes from the list, or omit `rails` to let the shop's default decide."},{"status":400,"error":"assets must name only ledgers this service settles: {codes}.","meaning":"A value in `assets` that is not a ledger code — `USDT` is a coin, `USDT_TRC20` is a ledger. Nothing was registered.","fix":"Send ledger codes from the list, or send `rails` instead to name coins without networks."},{"status":400,"error":"Send rails or assets, not both: assets already says which coins.","meaning":"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.","fix":"Send `assets` alone when you are naming networks, `rails` alone when you are not."},{"status":400,"error":"The request body is not valid JSON.","meaning":"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.","fix":"Send one JSON object, or no body at all. A form-encoded body is never read here."},{"status":400,"error":"The request body must be a JSON object.","meaning":"The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done.","fix":"Send the fields as one JSON object, or no body at all."}],"responses":{"201":{"description":"`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.","content":{"application/json":{"schema":{"type":"object","properties":{"order":{"$ref":"#/components/schemas/Order"},"invoiceUrl":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/Failed"}}},"get":{"operationId":"listOrders","summary":"Every order this key may read, newest first, a page at a time.","description":"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.\n\nPages 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.\n\nThis 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.","tags":["orders"],"security":[{"merchantKey":[]}],"parameters":[{"name":"limit","in":"query","required":false,"description":"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.","schema":{"type":"integer"}},{"name":"cursor","in":"query","required":false,"description":"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.","schema":{"type":"string"}},{"name":"status","in":"query","required":false,"description":"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.","schema":{"$ref":"#/components/schemas/OrderStatus"}}],"x-payd-scope":"any","x-payd-effect":"reads only","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Nothing changes.","x-payd-rate-limit":null,"x-payd-next":["getOrder","issuePayment"],"x-payd-emits":[],"x-payd-audit":null,"x-payd-refusals":[{"status":400,"error":"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.","meaning":"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.","fix":"Send `next` back exactly as it came, or leave `cursor` out to start again from the newest."},{"status":400,"error":"status must be one of the statuses an order can be in: {statuses}.","meaning":"Under `errors.status`. A status outside the orderStatus enum, or one differing by case or padding. Nothing was filtered and no page was read.","fix":"Send one value from the orderStatus enum, spelled as listed, or leave `status` out for every status."},{"status":401,"error":"Unauthorized.","meaning":"Unknown key, revoked key or suspended shop.","fix":"Check the key."}],"responses":{"200":{"description":"`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.","content":{"application/json":{"schema":{"type":"object","properties":{"orders":{"type":"array","items":{"$ref":"#/components/schemas/Order"}},"next":{"type":["string","null"]}}}}}},"400":{"$ref":"#/components/responses/FieldErrors"},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/orders/{reference}":{"get":{"operationId":"getOrder","summary":"Read one order back, with its outstanding instruction.","description":"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.","tags":["orders"],"security":[{"merchantKey":[]}],"parameters":[{"name":"reference","in":"path","required":true,"description":"The order's reference — yours if you sent one, minted by payd if you did not.","schema":{"type":"string"}}],"x-payd-scope":"any","x-payd-effect":"reads only","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Nothing changes.","x-payd-rate-limit":null,"x-payd-next":["issuePayment","listRefunds"],"x-payd-emits":[],"x-payd-audit":null,"x-payd-refusals":[{"status":404,"error":"No such order.","meaning":"Gone, not yours, or never existed. Byte-identical in all three cases.","fix":"Do not branch on it and do not log it as a bug."}],"responses":{"200":{"description":"`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.\n\nTHIS 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.\n\nAND 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.\n\n`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.","content":{"application/json":{"schema":{"type":"object","properties":{"order":{"$ref":"#/components/schemas/Order"},"payment":{"anyOf":[{"$ref":"#/components/schemas/OrderPayment"},{"type":"null"}]},"payments":{"type":"array","items":{"$ref":"#/components/schemas/Payment"}},"paymentsOmitted":{"type":"integer"},"invoiceUrl":{"type":"string"},"state":{"$ref":"#/components/schemas/InvoiceStateValue"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/orders/{reference}/quote":{"post":{"operationId":"quoteOrder","summary":"Fill in a price that was outstanding.","description":"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.","tags":["orders"],"security":[{"merchantKey":[]}],"parameters":[{"name":"reference","in":"path","required":true,"description":"The order's reference — yours if you sent one, minted by payd if you did not.","schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"goodsCents":{"type":"integer","description":"Whole minor units."},"shippingCents":{"type":"integer","description":"Whole minor units."}}}}}},"x-payd-scope":"any","x-payd-effect":"changes what is owed; moves no money","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Re-pricing to the same figure is a no-op that answers 200.","x-payd-rate-limit":null,"x-payd-next":["issuePayment"],"x-payd-emits":[],"x-payd-audit":"order.quote","x-payd-refusals":[{"status":409,"error":"An order that's {status} can't be re-priced.","meaning":"It has left the quote lane.","fix":"Issue a new instruction instead, which supersedes the old one properly."},{"status":400,"error":"{field} must be a whole number of cents.","meaning":"A money figure was not an integer.","fix":"Integer minor units."},{"status":400,"error":"The request body is not valid JSON.","meaning":"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.","fix":"Send one JSON object, or no body at all. A form-encoded body is never read here."},{"status":400,"error":"The request body must be a JSON object.","meaning":"The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done.","fix":"Send the fields as one JSON object, or no body at all."}],"responses":{"200":{"description":"Fill in a price that was outstanding.","content":{"application/json":{"schema":{"type":"object","properties":{"order":{"$ref":"#/components/schemas/Order"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"$ref":"#/components/responses/Conflict"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/orders/{reference}/status":{"post":{"operationId":"setOrderStatus","summary":"Move an order along by hand.","description":"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.","tags":["orders"],"security":[{"merchantKey":[]}],"parameters":[{"name":"reference","in":"path","required":true,"description":"The order's reference — yours if you sent one, minted by payd if you did not.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["status"],"properties":{"status":{"$ref":"#/components/schemas/OrderStatus","description":"Where to move it."}}}}}},"x-payd-scope":"any","x-payd-effect":"moves an order; cancelling also voids any outstanding instruction","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Moving to the status it already holds answers 200 and changes nothing.","x-payd-rate-limit":null,"x-payd-next":["getOrder"],"x-payd-emits":["order.status"],"x-payd-audit":"order.status","x-payd-refusals":[{"status":409,"error":"An order that's {from} can't become {to}.","meaning":"Not a legal transition. See the table.","fix":"Read the order first."},{"status":409,"error":"That order still has to be paid for.","meaning":"A billable order was asked to become `paid` without an instruction ever having been matched.","fix":"Issue an instruction and let the chain settle it, or confirm the payment by hand and say so."},{"status":409,"error":"That order needs a price before it can be confirmed.","meaning":"Committing to an order nobody has priced would mean charging an unknown total.","fix":"Quote it first."},{"status":400,"error":"The request body is not valid JSON.","meaning":"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.","fix":"Send one JSON object, or no body at all. A form-encoded body is never read here."},{"status":400,"error":"The request body must be a JSON object.","meaning":"The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done.","fix":"Send the fields as one JSON object, or no body at all."}],"responses":{"200":{"description":"Move an order along by hand.","content":{"application/json":{"schema":{"type":"object","properties":{"order":{"$ref":"#/components/schemas/Order"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"$ref":"#/components/responses/Conflict"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/orders/{reference}/metadata":{"post":{"operationId":"setOrderMetadata","summary":"Replace your own blob on an order.","description":"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.","tags":["orders"],"security":[{"merchantKey":[]}],"parameters":[{"name":"reference","in":"path","required":true,"description":"The order's reference — yours if you sent one, minted by payd if you did not.","schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"metadata":{"type":"object","description":"Omit it to blank."}}}}}},"x-payd-scope":"any","x-payd-effect":"replaces opaque data; moves no money","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Replaces it again with the same value.","x-payd-rate-limit":null,"x-payd-next":["getOrder"],"x-payd-emits":[],"x-payd-audit":"order.metadata","x-payd-refusals":[{"status":404,"error":"No such order.","meaning":"Gone, or not yours.","fix":"Do not branch on it."},{"status":400,"error":"The request body is not valid JSON.","meaning":"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.","fix":"Send one JSON object, or no body at all. A form-encoded body is never read here."},{"status":400,"error":"The request body must be a JSON object.","meaning":"The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done.","fix":"Send the fields as one JSON object, or no body at all."}],"responses":{"200":{"description":"Replace your own blob on an order.","content":{"application/json":{"schema":{"type":"object","properties":{"order":{"$ref":"#/components/schemas/Order"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/orders/{reference}/payment":{"post":{"operationId":"issuePayment","summary":"Issue a payment instruction against an order.","description":"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.","tags":["billing"],"security":[{"merchantKey":[]}],"parameters":[{"name":"reference","in":"path","required":true,"description":"The order's reference — yours if you sent one, minted by payd if you did not.","schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"rail":{"$ref":"#/components/schemas/Rail","description":"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":{"$ref":"#/components/schemas/Asset","description":"A LEDGER. Narrows to one network and sets the rail from it."}}}}}},"x-payd-scope":"any","x-payd-effect":"issues an instruction; moves no money","x-payd-safe-to-retry":false,"x-payd-idempotent":false,"x-payd-repeat":"Voids the outstanding instruction, and any expired one watching the same address on the same network, and issues a new one — at the SAME address on the same coin, because the index is held per order per keyring. Money already seen on that address moves to the new instruction and is credited once, at its quote. Refused once the chain has seen money against the outstanding one.","x-payd-rate-limit":{"max":20,"windowSeconds":60,"keyedOn":"merchant"},"x-payd-next":["getOrder","requotePayment","declarePaid","invoiceState"],"x-payd-emits":["payment.settled","order.status"],"x-payd-audit":"payment.issue","x-payd-refusals":[{"status":400,"error":"No {rail} wallet is configured.","meaning":"This shop holds no extended public key for that coin.","fix":"Call GET /api/payments/config and offer only the rails it returns."},{"status":400,"error":"Unknown payment rail.","meaning":"Not one of the five tickers.","fix":"Read `rails` off GET /api/payments/config."},{"status":409,"error":"An order that's {status} isn't waiting to be billed.","meaning":"The order's status forbids a new instruction.","fix":"Read the order first. Do not retry."},{"status":409,"error":"Price every line and the shipping before asking for payment.","meaning":"The total is still null, and null is not zero.","fix":"Quote it."},{"status":409,"error":"That order is in a currency this service cannot price, so no rate was struck and nothing was issued.","meaning":"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.","fix":"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."},{"status":409,"error":"That merchant is suspended, so nothing can be billed to them.","meaning":"The shop is switched off. Money already in flight still arrives and still settles.","fix":"Nothing you can do from here."},{"status":409,"error":"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.","code":"plan_ceiling","meaning":"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.","fix":"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."},{"status":429,"error":"Too many attempts. Try again in {n}s.","meaning":"Twenty instructions in sixty seconds for this shop.","fix":"Honour Retry-After. The bucket is per merchant, not per order, so using more references does not buy more budget."},{"status":400,"error":"The request body is not valid JSON.","meaning":"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.","fix":"Send one JSON object, or no body at all. A form-encoded body is never read here."},{"status":400,"error":"The request body must be a JSON object.","meaning":"The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done.","fix":"Send the fields as one JSON object, or no body at all."}],"responses":{"200":{"description":"`payment` is the instruction issued, or null on an order that costs nothing, which settles instead and answers `free: true`.","content":{"application/json":{"schema":{"type":"object","properties":{"order":{"$ref":"#/components/schemas/Order"},"payment":{"anyOf":[{"$ref":"#/components/schemas/CustomerPayment"},{"type":"null"}]},"free":{"type":"boolean"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"$ref":"#/components/responses/Conflict"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/orders/{reference}/payment/requote":{"post":{"operationId":"requotePayment","summary":"Refresh the crypto amount against the current rate.","description":"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.","tags":["billing"],"security":[{"merchantKey":[]}],"parameters":[{"name":"reference","in":"path","required":true,"description":"The order's reference — yours if you sent one, minted by payd if you did not.","schema":{"type":"string"}}],"x-payd-scope":"any","x-payd-effect":"changes what the customer is asked to send; moves no money","x-payd-safe-to-retry":true,"x-payd-idempotent":false,"x-payd-repeat":"Re-locks at the current rate again. The address does not move.","x-payd-rate-limit":{"max":15,"windowSeconds":60,"keyedOn":"merchant"},"x-payd-next":["invoiceState","getOrder"],"x-payd-emits":[],"x-payd-audit":"payment.requote","x-payd-refusals":[{"status":404,"error":"There's nothing to pay on that order.","meaning":"No outstanding instruction.","fix":"Issue one."},{"status":409,"error":"That payment is already settled.","meaning":"It confirmed.","fix":"Nothing to do."},{"status":409,"error":"That payment was superseded.","meaning":"A newer instruction replaced it.","fix":"Read the order and use the current one."},{"status":409,"error":"That payment is in a currency this service cannot price, so it was not re-quoted.","meaning":"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.","fix":"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."},{"status":429,"error":"Too many attempts. Try again in {n}s.","meaning":"Fifteen in sixty seconds for this shop.","fix":"Honour Retry-After."}],"responses":{"200":{"description":"Refresh the crypto amount against the current rate.","content":{"application/json":{"schema":{"type":"object","properties":{"payment":{"$ref":"#/components/schemas/CustomerPayment"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/orders/{reference}/paid":{"post":{"operationId":"declarePaid","summary":"'I've sent it' — which the server goes and checks.","description":"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.","tags":["billing"],"security":[{"merchantKey":[]}],"parameters":[{"name":"reference","in":"path","required":true,"description":"The order's reference — yours if you sent one, minted by payd if you did not.","schema":{"type":"string"}}],"x-payd-scope":"any","x-payd-effect":"reads the chain and may settle; moves no money","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Reads the chain again. The declaration timestamp is not moved by a second call.","x-payd-rate-limit":{"max":6,"windowSeconds":60,"keyedOn":"merchant"},"x-payd-next":["getOrder"],"x-payd-emits":["payment.sighted","payment.short","payment.settled","order.status"],"x-payd-audit":null,"x-payd-refusals":[{"status":404,"error":"There's nothing to pay on that order.","meaning":"No outstanding instruction.","fix":"Issue one."},{"status":409,"error":"That instruction was superseded.","meaning":"A newer instruction replaced it.","fix":"Read the order and use the current one."},{"status":429,"error":"Too many attempts. Try again in {n}s.","meaning":"Six in sixty seconds for this shop — tighter than its neighbours because each call is an outbound request to a public explorer.","fix":"Honour Retry-After. Poll GET /api/orders/<reference> instead."}],"responses":{"200":{"description":"'I've sent it' — which the server goes and checks.","content":{"application/json":{"schema":{"type":"object","properties":{"order":{"$ref":"#/components/schemas/Order"},"payment":{"$ref":"#/components/schemas/CustomerPayment"},"check":{"$ref":"#/components/schemas/Check"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/orders/{reference}/refunds":{"get":{"operationId":"listRefunds","summary":"What has been sent back, and what is still owed.","description":"`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.","tags":["refunds"],"security":[{"merchantKey":[]}],"parameters":[{"name":"reference","in":"path","required":true,"description":"The order's reference — yours if you sent one, minted by payd if you did not.","schema":{"type":"string"}}],"x-payd-scope":"any","x-payd-effect":"reads only","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Nothing changes.","x-payd-rate-limit":null,"x-payd-next":["recordRefund"],"x-payd-emits":[],"x-payd-audit":null,"x-payd-refusals":[{"status":404,"error":"No such order.","meaning":"Gone, or not yours.","fix":"Do not branch on it."}],"responses":{"200":{"description":"What has been sent back, and what is still owed.","content":{"application/json":{"schema":{"type":"object","properties":{"refunds":{"type":"array","items":{"$ref":"#/components/schemas/Refund"}},"position":{"$ref":"#/components/schemas/RefundPosition"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/Failed"}}},"post":{"operationId":"recordRefund","summary":"Write down that money was sent back.","description":"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.","tags":["refunds"],"security":[{"merchantKey":[]}],"parameters":[{"name":"reference","in":"path","required":true,"description":"The order's reference — yours if you sent one, minted by payd if you did not.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["amountCents","destination"],"properties":{"amountCents":{"type":"integer","description":"Whole minor units."},"destination":{"type":"string","description":"Where the money went."},"asset":{"$ref":"#/components/schemas/Asset","description":"Which ledger it went out on."},"assetAmount":{"type":"string","x-payd-money":"An exact decimal string. Compare it as a string; never parse it into a float.","description":"Exact decimal string. Never a float."},"externalRef":{"type":"string","description":"The transaction id."},"note":{"type":"string","description":"Why."}}}}}},"x-payd-scope":"any","x-payd-effect":"records only; moves nothing","x-payd-safe-to-retry":false,"x-payd-idempotent":false,"x-payd-repeat":"Records a SECOND refund. There is no de-duplication; check the position first.","x-payd-rate-limit":null,"x-payd-next":["listRefunds"],"x-payd-emits":[],"x-payd-audit":"order.refund","x-payd-refusals":[{"status":409,"error":"Nothing was ever paid on that order, so there's nothing to send back.","meaning":"No confirmed instruction on the order.","fix":"Nothing to do."},{"status":400,"error":"Enter an amount to refund.","meaning":"Missing, zero, negative or fractional.","fix":"Whole minor units, above zero."},{"status":400,"error":"is left to refund.","meaning":"More than is outstanding. The sentence names what is left.","fix":"Read `position.owedCents` first."},{"status":400,"error":"Say where the money went.","meaning":"No destination.","fix":"An address, an account, a sentence — whatever a person would need in six months."},{"status":400,"error":"The request body is not valid JSON.","meaning":"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.","fix":"Send one JSON object, or no body at all. A form-encoded body is never read here."},{"status":400,"error":"The request body must be a JSON object.","meaning":"The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done.","fix":"Send the fields as one JSON object, or no body at all."}],"responses":{"200":{"description":"Write down that money was sent back.","content":{"application/json":{"schema":{"type":"object","properties":{"refund":{"$ref":"#/components/schemas/Refund"},"position":{"$ref":"#/components/schemas/RefundPosition"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"$ref":"#/components/responses/Conflict"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/orders/{reference}/refund-destination":{"post":{"operationId":"setRefundDestination","summary":"Where the customer would like it sent.","description":"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.","tags":["refunds"],"security":[{"merchantKey":[]}],"parameters":[{"name":"reference","in":"path","required":true,"description":"The order's reference — yours if you sent one, minted by payd if you did not.","schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"destination":{"type":"string","description":"Omit or blank it to clear."}}}}}},"x-payd-scope":"any","x-payd-effect":"records a destination; moves nothing","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Sets it again.","x-payd-rate-limit":null,"x-payd-next":["recordRefund"],"x-payd-emits":[],"x-payd-audit":"order.refund-destination","x-payd-refusals":[{"status":404,"error":"No such order.","meaning":"Gone, or not yours.","fix":"Do not branch on it."},{"status":400,"error":"The request body is not valid JSON.","meaning":"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.","fix":"Send one JSON object, or no body at all. A form-encoded body is never read here."},{"status":400,"error":"The request body must be a JSON object.","meaning":"The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done.","fix":"Send the fields as one JSON object, or no body at all."}],"responses":{"200":{"description":"Where the customer would like it sent.","content":{"application/json":{"schema":{"type":"object","properties":{"order":{"$ref":"#/components/schemas/Order"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/payments/config":{"get":{"operationId":"paymentsConfig","summary":"What this shop can actually be billed on right now.","description":"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.","tags":["payments"],"security":[{"merchantKey":[]}],"x-payd-scope":"any","x-payd-effect":"reads only","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Nothing changes.","x-payd-rate-limit":null,"x-payd-next":["issuePayment"],"x-payd-emits":[],"x-payd-audit":null,"x-payd-refusals":[{"status":401,"error":"Unauthorized.","meaning":"Unknown key, revoked key or suspended shop. One answer for all three.","fix":"Check the key."}],"responses":{"200":{"description":"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.","content":{"application/json":{"schema":{"type":"object","properties":{"methods":{"type":"array","items":{"type":"string"}},"rails":{"type":"array","items":{"type":"object"}},"assets":{"type":"array","items":{"type":"string"}},"wallets":{"type":"array","items":{"type":"object"}},"policy":{"$ref":"#/components/schemas/Policy"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/payments/outstanding":{"get":{"operationId":"listOutstanding","summary":"Every crypto instruction still being watched, for this shop, a page at a time.","description":"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.","tags":["payments"],"security":[{"merchantKey":[]}],"parameters":[{"name":"limit","in":"query","required":false,"description":"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.","schema":{"type":"integer"}},{"name":"cursor","in":"query","required":false,"description":"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.","schema":{"type":"string"}}],"x-payd-scope":"any","x-payd-effect":"reads only","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Nothing changes.","x-payd-rate-limit":null,"x-payd-next":["getPayment","confirmPayment"],"x-payd-emits":[],"x-payd-audit":null,"x-payd-refusals":[{"status":400,"error":"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.","meaning":"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.","fix":"Send `next` back exactly as it came, or leave `cursor` out to start again."},{"status":401,"error":"Unauthorized.","meaning":"Unknown key, revoked key or suspended shop.","fix":"Check the key."}],"responses":{"200":{"description":"`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.","content":{"application/json":{"schema":{"type":"object","properties":{"payments":{"type":"array","items":{"$ref":"#/components/schemas/Payment"}},"next":{"type":["string","null"]}}}}}},"400":{"$ref":"#/components/responses/FieldErrors"},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/payments/{id}":{"get":{"operationId":"getPayment","summary":"One instruction, with the order it bills.","description":"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.","tags":["payments"],"security":[{"merchantKey":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"The payment, key or webhook id, as the route's own collection returned it.","schema":{"type":"string"}}],"x-payd-scope":"any","x-payd-effect":"reads only","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Nothing changes.","x-payd-rate-limit":null,"x-payd-next":["confirmPayment","settleShort"],"x-payd-emits":[],"x-payd-audit":null,"x-payd-refusals":[{"status":404,"error":"No such payment.","meaning":"Gone, or not yours.","fix":"Do not branch on it."}],"responses":{"200":{"description":"One instruction, with the order it bills.","content":{"application/json":{"schema":{"type":"object","properties":{"payment":{"$ref":"#/components/schemas/Payment"},"order":{"$ref":"#/components/schemas/Order"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/payments/{id}/confirm":{"post":{"operationId":"confirmPayment","summary":"Record by hand that money arrived in full.","description":"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.","tags":["payments"],"security":[{"merchantKey":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"The payment, key or webhook id, as the route's own collection returned it.","schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"receivedCents":{"type":"integer","description":"Whole cents, or leave it out."},"externalRef":{"type":"string","description":"A transaction id."},"note":{"type":"string","description":"Why."}}}}}},"x-payd-scope":"any","x-payd-effect":"settles an order on a person's word","x-payd-safe-to-retry":false,"x-payd-idempotent":true,"x-payd-repeat":"The second call answers 409. It is still settled.","x-payd-rate-limit":null,"x-payd-next":["getOrder"],"x-payd-emits":["payment.settled","order.status"],"x-payd-audit":null,"x-payd-refusals":[{"status":400,"error":"Whole cents, or leave it out.","meaning":"receivedCents was not an integer.","fix":"Integer minor units, or omit the field."},{"status":409,"error":"That payment is already confirmed.","meaning":"It settled.","fix":"Nothing to do."},{"status":409,"error":"That instruction was superseded","meaning":"A newer instruction replaced it.","fix":"Read the order and confirm the current one."},{"status":404,"error":"No such payment.","meaning":"Gone, or not yours.","fix":"Do not branch on it."},{"status":400,"error":"The request body is not valid JSON.","meaning":"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.","fix":"Send one JSON object, or no body at all. A form-encoded body is never read here."},{"status":400,"error":"The request body must be a JSON object.","meaning":"The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done.","fix":"Send the fields as one JSON object, or no body at all."}],"responses":{"200":{"description":"Record by hand that money arrived in full.","content":{"application/json":{"schema":{"type":"object","properties":{"order":{"$ref":"#/components/schemas/Order"},"payment":{"$ref":"#/components/schemas/Payment"},"payments":{"type":"array","items":{"$ref":"#/components/schemas/Payment"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/payments/{id}/settle-short":{"post":{"operationId":"settleShort","summary":"Accept a payment that landed short.","description":"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.","tags":["payments"],"security":[{"merchantKey":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"The payment, key or webhook id, as the route's own collection returned it.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["note"],"properties":{"note":{"type":"string","description":"Say why this is being accepted short. Refused without it."},"externalRef":{"type":"string","description":"A transaction id."}}}}}},"x-payd-scope":"any","x-payd-effect":"settles an order for less than was asked, on a person's word","x-payd-safe-to-retry":false,"x-payd-idempotent":true,"x-payd-repeat":"The second call answers 409.","x-payd-rate-limit":null,"x-payd-next":["getOrder"],"x-payd-emits":["payment.settled","order.status"],"x-payd-audit":null,"x-payd-refusals":[{"status":400,"error":"Say why this is being accepted short.","meaning":"No note.","fix":"Write one. It is the record."},{"status":409,"error":"Only a crypto payment settles from the chain.","meaning":"There is no ledger to read.","fix":"Confirm it by hand instead."},{"status":409,"error":"Nothing has been seen at that address yet. Use confirm if you're recording it by hand.","meaning":"There is no shortfall to accept, because there is no arrival.","fix":"Use confirm."},{"status":409,"error":"That payment is already settled.","meaning":"It confirmed.","fix":"Nothing to do."},{"status":400,"error":"The request body is not valid JSON.","meaning":"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.","fix":"Send one JSON object, or no body at all. A form-encoded body is never read here."},{"status":400,"error":"The request body must be a JSON object.","meaning":"The body is valid JSON and not an object: an array, a string, a number, true, false or null. Nothing was done.","fix":"Send the fields as one JSON object, or no body at all."}],"responses":{"200":{"description":"Accept a payment that landed short.","content":{"application/json":{"schema":{"type":"object","properties":{"order":{"$ref":"#/components/schemas/Order"},"payment":{"$ref":"#/components/schemas/Payment"},"payments":{"type":"array","items":{"$ref":"#/components/schemas/Payment"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"$ref":"#/components/responses/Conflict"},"500":{"$ref":"#/components/responses/Failed"}}}},"/api/payments/audit/log":{"get":{"operationId":"auditLog","summary":"The deployment's audit trail — every merchant's.","description":"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.","tags":["service"],"security":[{"merchantKey":[]}],"parameters":[{"name":"action","in":"query","required":false,"description":"In the query. Filter by action.","schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"description":"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.","schema":{"type":"integer"}},{"name":"offset","in":"query","required":false,"description":"In the query. Entries to skip. Clamped, never refused: below 0 reads as 0, and a fraction is truncated.","schema":{"type":"integer"}}],"x-payd-scope":"deployment","x-payd-effect":"reads every merchant's trail","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Nothing changes.","x-payd-rate-limit":null,"x-payd-next":[],"x-payd-emits":[],"x-payd-audit":null,"x-payd-refusals":[{"status":404,"error":"Not found.","meaning":"A merchant key asked. 404 and not 403, for the same reason every other 404 here is one: it says nothing about what exists.","fix":"Nothing. A merchant key cannot read this, and no scope makes it able to."}],"responses":{"200":{"description":"The deployment's audit trail — every merchant's.","content":{"application/json":{"schema":{"type":"object","properties":{"entries":{"type":"array","items":{"type":"object"}},"total":{"type":"integer"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/Failed"}}}},"/i/{token}/state":{"get":{"operationId":"invoiceState","summary":"What is happening to one invoice, as JSON.","description":"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.","tags":["invoice"],"security":[],"parameters":[{"name":"token","in":"path","required":true,"description":"The order's `publicToken`. The whole of the authorisation: no key is sent to /i.","schema":{"type":"string"}}],"x-payd-scope":null,"x-payd-effect":"reads only, except re-striking a lapsed rate lock, which moves no money","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Nothing changes, except that the first read after a rate lock lapses, on an instruction a live chain read finds nothing arriving at, re-strikes it once at the current rate.","x-payd-rate-limit":{"max":60,"windowSeconds":60,"keyedOn":"token"},"x-payd-next":["invoiceEvents"],"x-payd-emits":[],"x-payd-audit":"payment.requote","x-payd-refusals":[{"status":404,"error":"Not found.","meaning":"A wrong-length token, a well-formed unknown one, and a deleted order all produce the same four words.","fix":"Nothing. An endpoint that distinguished them would help somebody narrow a search."},{"status":429,"error":"Too many attempts. Try again in {n}s.","meaning":"Sixty a minute per TOKEN once it resolves, or twenty a minute per IP on MISSES. Keyed differently because they defend against different things.","fix":"Poll less often, or hold the event stream open instead."}],"responses":{"200":{"description":"What is happening to one invoice, as JSON.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoiceState"}}}},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/Failed"}}}},"/i/{token}/socket":{"get":{"operationId":"invoiceSocket","summary":"The WebSocket the invoice page patches itself from.","description":"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.","tags":["invoice"],"security":[],"parameters":[{"name":"token","in":"path","required":true,"description":"The order's `publicToken`. The whole of the authorisation: no key is sent to /i.","schema":{"type":"string"}}],"x-payd-scope":null,"x-payd-effect":"reads only; holds a connection","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Opens a second socket. Close the first.","x-payd-rate-limit":null,"x-payd-next":["invoiceState"],"x-payd-emits":[],"x-payd-audit":null,"x-payd-refusals":[{"status":404,"error":"Not found.","meaning":"Unknown token.","fix":"Do not branch on it."},{"status":426,"error":"This route takes a WebSocket.","meaning":"An ordinary GET, or a HEAD, which Hono runs through the same handler. The route says what it is and nothing about the invoice.","fix":"Open a WebSocket, or read /state instead."}],"responses":{"101":{"description":"The handshake. What follows on the connection is JSON frames of rendered markup — no schema, by design.","content":{"application/json":{}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/Failed"}}}},"/i/{token}/events":{"get":{"operationId":"invoiceEvents","summary":"Server-sent events, for a deployment the socket cannot get through.","description":"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.","tags":["invoice"],"security":[],"parameters":[{"name":"token","in":"path","required":true,"description":"The order's `publicToken`. The whole of the authorisation: no key is sent to /i.","schema":{"type":"string"}}],"x-payd-scope":null,"x-payd-effect":"reads only; holds a connection","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Opens a second stream. Close the first.","x-payd-rate-limit":null,"x-payd-next":["invoiceState"],"x-payd-emits":[],"x-payd-audit":null,"x-payd-refusals":[{"status":404,"error":"Not found.","meaning":"Unknown token.","fix":"Do not branch on it."}],"responses":{"200":{"description":"A stream of named events. No JSON schema, by design — read /state for the state.","content":{"text/event-stream":{}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/i/{token}":{"get":{"operationId":"invoicePage","summary":"The invoice itself, as a page for a person.","description":"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.","tags":["invoice"],"security":[],"parameters":[{"name":"token","in":"path","required":true,"description":"The order's `publicToken`. The whole of the authorisation: no key is sent to /i.","schema":{"type":"string"}},{"name":"coin","in":"query","required":false,"description":"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.","schema":{"$ref":"#/components/schemas/Rail"}}],"x-payd-scope":null,"x-payd-effect":"reads only, except re-striking a lapsed rate lock, which moves no money","x-payd-safe-to-retry":true,"x-payd-idempotent":true,"x-payd-repeat":"Nothing changes unless the rate lock has lapsed on an instruction a live chain read finds nothing arriving at, which is re-struck once.","x-payd-rate-limit":{"max":60,"windowSeconds":60,"keyedOn":"token"},"x-payd-next":["invoiceState"],"x-payd-emits":[],"x-payd-audit":"payment.requote","x-payd-refusals":[{"status":404,"error":"Not found.","meaning":"Unknown token.","fix":"Do not branch on it."},{"status":429,"error":"Too many attempts. Try again in {n}s.","meaning":"Sixty a minute per token, or twenty a minute per IP on misses.","fix":"Honour Retry-After."}],"responses":{"200":{"description":"The invoice itself, as a page for a person.","content":{"text/html":{}}},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/i/{token}/rail":{"post":{"operationId":"invoicePickCoin","summary":"The buyer picks which coin to be billed in.","description":"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.","tags":["invoice"],"security":[],"parameters":[{"name":"token","in":"path","required":true,"description":"The order's `publicToken`. The whole of the authorisation: no key is sent to /i.","schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"rail":{"$ref":"#/components/schemas/Rail","description":"Form-encoded. The coin to be billed in. Must be one this order offers."},"asset":{"$ref":"#/components/schemas/Asset","description":"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."}}}}}},"x-payd-scope":null,"x-payd-effect":"issues a payment instruction, which strikes a rate and derives an address","x-payd-safe-to-retry":false,"x-payd-idempotent":false,"x-payd-repeat":"The second press changes nothing: an order already being billed is redirected without issuing, so a double submit cannot bill twice.","x-payd-rate-limit":{"max":60,"windowSeconds":60,"keyedOn":"token"},"x-payd-next":["invoicePage"],"x-payd-emits":[],"x-payd-audit":"payment.issue","x-payd-refusals":[{"status":404,"error":"Not found.","meaning":"Unknown token.","fix":"Do not branch on it."},{"status":429,"error":"Too many attempts. Try again in {n}s.","meaning":"Sixty a minute per token.","fix":"Honour Retry-After."}],"responses":{"303":{"description":"Always. The Location is the invoice, whether the pick was taken or refused — the page is the answer.","content":{"text/html":{}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/i/{token}/sideshift":{"post":{"operationId":"invoiceSwap","summary":"The buyer opens a SideShift.ai swap to pay in a coin the invoice does not take.","description":"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.","tags":["invoice"],"security":[],"parameters":[{"name":"token","in":"path","required":true,"description":"The order's `publicToken`. The whole of the authorisation: no key is sent to /i.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["deposit","refund"],"properties":{"deposit":{"type":"string","description":"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":{"type":"string","description":"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":{"type":"string","description":"Form-encoded. The memo or tag the refund address needs, on networks that use one."}}}}}},"x-payd-scope":null,"x-payd-effect":"opens a fixed-rate swap at SideShift.ai settling this instruction's own figure at its own address, and writes one timeline line","x-payd-safe-to-retry":false,"x-payd-idempotent":false,"x-payd-repeat":"A second press while a swap is open is redirected without asking SideShift.ai anything. An instruction opens at most three swaps in its life, and this process asks SideShift.ai at most ten times for one, refused or not.","x-payd-rate-limit":{"max":60,"windowSeconds":60,"keyedOn":"token"},"x-payd-next":["invoicePage"],"x-payd-emits":[],"x-payd-audit":null,"x-payd-refusals":[{"status":404,"error":"Not found.","meaning":"Unknown token.","fix":"Do not branch on it."},{"status":429,"error":"Too many attempts. Try again in {n}s.","meaning":"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.","fix":"Honour Retry-After."}],"responses":{"303":{"description":"Always. The Location is the invoice, with `?swap=<reason>` when SideShift.ai refused.","content":{"text/html":{}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}}}}