Documentation

WidgetPay APIPlayground

Product

cherum.ioOpen appDashboard
DocsPay API

Pay API

Accept stablecoin payments from your own backend: create an invoice, show the payer an address, and get a signed webhook when the money confirms on-chain. Every field on this page is the field the server actually accepts or returns — nothing is aspirational.

Overview

Base URL: https://api.cherum.io/pay/v1. All request and response bodies are JSON. Amounts in USD are numbers; on-chain amounts are decimal strings in the token’s smallest unit (atoms) — never floats.

On a live key, an invoice that carries a coin and network is paid to a one-time deposit address Cherum issues for it. Cherum watches the chain, applies the status machine, calls your webhook, and credits the money to your Cherum balance with the 0.90% fee deducted — the details are under Create an invoice. On a test key no real money moves at all: a test invoice never carries a real address — it shows a test marker that no network accepts, and you drive its statuses with the simulator.

Watched coins. Cherum can only detect a payment for a coin and network it indexes: USDC on ethereum, base, arbitrum, optimism, polygon, bsc, and USDT on ethereum, arbitrum, polygon, bsc. Invoices for anything else are created, but nothing on-chain will move them — the create response tells you so via watched: false, and you resolve them yourself with mark.

Confirmation depth per network, after which a payment counts as confirmed: ethereum 2, base 3, arbitrum 3, optimism 3, bsc 15, polygon 30 blocks. The chain scanner ticks every 30 seconds.

Authentication

Send your key in the Authorization header. Both spellings work:

request headers
Authorization: token chm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# or
Authorization: Bearer chm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Keys look like chm_live_ or chm_test_ followed by 32 alphanumeric characters. The full secret is shown once, at creation, in the dashboard — Cherum stores only its hash, so a lost key is rotated, not recovered. Create and revoke keys under Dashboard → Developers.

PermissionUnlocks
invoices.createPOST /invoices, POST /invoices/:id/cancel, POST /invoices/:id/simulate
invoices.markPOST /invoices/:id/mark — your manual verdict on an invoice. Its own permission since 3 Oct 2026: a key that only creates invoices cannot also declare them paid.
invoices.readGET /invoices, GET /invoices/:id
webhooks.manageevery /webhooks and /deliveries route
refunds.manageevery /refunds route and GET /invoices/:id/refund-quote — see Refunds. Live keys only: refunds move real money.

A new key is created with invoices.create and invoices.read only. invoices.mark, webhooks.manage and refunds.manage are checkboxes on the same screen — tick them when you create the key if your code will mark invoices by hand, manage endpoints or open refunds, or do it in the dashboard instead. Permissions are fixed at creation: to change them, create a new key and revoke the old one.

A key sees only the invoices and webhook endpoints it created; balance, payouts and refunds belong to the account behind it. Calling a route your key lacks the permission for returns 403 forbidden; if the account behind the key is blocked, every call returns 403 account_blocked.

Who am I? GET /me answers with the key's own passport — prefix, label, mode, permissions, creation and expiry — and needs no permission beyond authenticating. It is the first call to make on an unexpected 403. Keys can be issued with an expiry (a checkbox in the dashboard): an expired key stops authenticating by itself — hand one to a contractor without letting it outlive the contract. The same answer carries features: what the service is doing right now, so your code can read it instead of guessing. Today that is one field, buyerReceipt — whether Cherum e-mails the payer a receipt for the customerEmail you send. It is false in production at the time of writing, and false there means the address you hand over buys the payer nothing.

How small an order can be. The same answer carries minimums, because there are two floors under a payment and knowing only one of them is how a checkout offers a method that cannot work. invoiceUsd is the floor on creating an invoice — under it the service will not mint one at all. cheapestRailUsd is the floor on paying one: it belongs to the coin and the network, moves with the cost of moving money, and an invoice where the buyer picks the coin is refused outright under it. byCoin lists every coin and network we accept with its own live floor, cheapest first: {"minimums":{"invoiceUsd":0.5,"cheapestRailUsd":2,"byCoin":[{"coin":"USDC","network":"base","minUsd":2}, …]}}. Restricting an invoice with allowedCoins raises the floor to the cheapest coin you allow — ask for that floor with GET /me?coins=USDC:base,USDT:tron, the same pairs in the same spelling; an unknown or switched-off pair is refused here exactly as it is on invoice creation. cheapestRailUsd is null when no coin can be quoted at all right now (the deposit rail is switched off, or the key is not linked to an account): under that, an invoice where the buyer picks the coin is refused whatever the amount. The figures are live — cache them for minutes, not days, and never ask for them while a shopper waits.

Errors & rate limits

Errors carry the same envelope: {"error":{"code":"…","message":"…"}}. Validation errors add details — an array of {path, message} straight from the schema. A refused refund adds detail — the numbers behind the refusal, so you can show them without parsing the sentence: {"error":{"code":"over_cap","message":"That is more than what is left refundable on this invoice. $3.20 is still refundable here.","detail":{"headroomUsd":3.2}}}. An amount under the accepted floor answers the same way: {"error":{"code":"below_minimum","message":"Minimum for USDC on base is $2. …","detail":{"minimumUsd":2,"asset":"USDC","network":"base"}}}. Read message for people and code for programs; detail is a convenience, never the only place a number appears.

HTTPcodeWhen
400validation_errorBody failed the schema. See details.
400invalid_descriptionThe invoice description has a link, a website address, markup or hidden characters. detail.problem says which; see Plain-text description. Enforced from the date in the API changelog — until then the invoice is created with a warnings entry.
400idempotency_errorSame Idempotency-Key replayed with a different body.
400invalid_cursorstarting_after is not an invoice id of this key.
400unsafe_urlWebhook URL is refused: it must be https:// on port 443 with a host name (not an IP address), without a user name or password. The message names the rule that failed. Where the host name points is checked before every delivery, not at registration.
401unauthorizedHeader missing, or key unknown, revoked or expired.
403forbiddenKey lacks the permission this route needs.
403account_blockedThe account behind the key is blocked.
403email_unconfirmedA live invoice was marked confirmed by an account with no confirmed email. Add an email in Dashboard → Settings and follow the link we send.
403screening_blockedThe receiving address is not permitted.
403live_key_requiredA test key called a refund route. Refunds move real money, so they take a live key.
403sandbox_key_requiredA live key called POST /invoices/:id/simulate.
404not_foundNo such invoice, endpoint or delivery for this key.
422below_minimumThe amount is under the floor for that coin and network. detail carries {minimumUsd, asset, network}; cheaper networks have a lower floor.
409address_busyAn open invoice already exists for this address + coin + network.
400coin_not_supportedThat coin is not accepted on that network. Create the invoice without a coin and let the buyer pick.
409coin_unavailableThe rail cannot take an invoice on that coin and network right now.
409wallet_requiredThe account behind the key has no wallet to receive the payment.
409idempotency_conflictA request with this Idempotency-Key is still in flight.
409endpoint_disabledPing sent to a disabled endpoint — enable it first.
409not_cancelableThe invoice is no longer new (a payment was seen, or it has closed), or the refund is already being sent or has closed.
429rate_limitedOver the limit for this route. Wait the number of seconds in Retry-After.
429ping_budget_exceededMore than 60 test pings per hour for this key.
429daily_limitA daily cap was reached: more than 10 new webhook endpoints in 24 hours for this account (or 20 from one network), or more than 300 test invoices in 24 hours for this account. Live invoices are not capped. Wait the number of seconds in Retry-After, or write to [email protected].
429resend_limitThis delivery was already resent 5 times in the last 24 hours. It can be resent again once the oldest of those resends is a day old.
429api_daily_capRefunds created with API keys of this account reached their daily total. detail carries capUsd and usedUsd. A refund created in the dashboard is not counted against it.

Every route has its own limit per minute, listed in the Full route index; each route is counted separately. Requests count against your API key once that key has made a successful call in the last 15 minutes, and against your IP address otherwise — so the very first call of a fresh key, or of one idle for a while, is counted by address. In front of that sits an edge limit of 30 requests per second per IP address, with bursts up to 60. Going over either limit returns 429 in the same envelope as every other error, with code rate_limited and a Retry-After header in seconds:

429 Too Many Requests
{ "error": { "code": "rate_limited", "message": "Too many requests — retry in 12s." }, "retryAfter": 12 }

The edge limit answers with the same code but without retryAfter in the body. Read the Retry-After header and both cases are covered.

Create an invoice

POST /invoices · needs invoices.create

FieldTypeRequiredNotes
amountUsdnumberyesPositive, at most 10,000,000.
orderIdstring ≤128noYour reference. Echoed back and shown to the payer.
metadataobjectnoFree-form, at most 8 KB serialised. Keys starting with _ are reserved and silently dropped.
expiresInMinutesinteger 5…1440noRate window. Default 20.
coinstring ≤16noe.g. USDC.
networkstring ≤24noe.g. base.
addressstring ≤128noIgnored — every live invoice with a pair is issued with a one-time Cherum deposit address, and every test invoice with a pair gets a test marker (sandbox-do-not-send-…) that no network accepts. Accepted for compatibility, never used.
amountAtomicstring of digitsnoIgnored — Cherum quotes the exact crypto amount from amountUsd at the live rate, for test invoices too.
tokenDecimalsinteger 0…36noIgnored — decimals come from Cherum's token registry.
currencystring, ISO 4217noCharge in your own currency instead of dollars: pass amount with currency in place of amountUsd. The rate is taken from two independent sources at the moment the invoice is created and both must agree within 2% — if they disagree we refuse rather than pick one. The rate used and its sources are returned on the invoice, so the number is auditable later. Thirty-one currencies, and this is the whole list: USD EUR GBP CHF JPY CAD AUD NZD SEK NOK DKK PLN CZK HUF RON TRY BRL MXN INR ZAR SGD HKD KRW CNY IDR MYR PHP THB ILS AED SAR. Anything else answers 400 fx_unsupported. JPY and KRW carry no decimals, so amount is whole units there.
allowedCoinsarray of {coin, network}noRestrict this one invoice to the coins you list — for example only what you can refund, or only what your contract names. Omit the field to accept everything you have enabled. An unknown or currently disabled pair is rejected with an error naming it, never dropped in silence: an invoice you believe is restricted while it is open to everything is worse than no restriction. An empty array is rejected too — it would leave nothing to pay with. The list is enforced when a payment method is quoted, not just when the checkout page is drawn.
returnUrlhttps URL ≤1024noWhere the payer goes after paying. Must be https and must not point at cherum.io. Shown as a “Return to the store” button on the payment page.
cancelUrlhttps URL ≤1024noWhere the payer goes if they leave without paying. Same rules as returnUrl.
descriptionstring ≤500noWhat the payer is paying for, shown on the payment page. Plain text only — no links, no website addresses, no markup, no hidden characters. See Plain-text description.
customerEmailemail ≤254noThe buyer’s email, if you already have it. Saves the payer typing it again on our page. Shown to you on the invoice; never shown to anyone else.

On a live key, coin and network are what make an invoice payable: pass them together and Cherum issues the payment address, the exact crypto amount and the decimals itself. When that coin and network are the coin and network you get paid in, the exact amount is the invoice amount and nothing more: one to one in a stablecoin trading within 1% of the dollar, the invoice amount at the quote’s rate in any other coin (a stablecoin outside that band included). That payment is credited in the same coin — what arrived, up to the exact amount, less your fee, whatever the rate does afterwards; the dollar figures of its invoice.credited use the quote’s rate, and its payerFeeUsd is 0. If you change the coin or network you get paid in before the payment is credited, it is converted as on the path below, valued when it reaches the treasury, with no buffer (a stablecoin asked one to one still counts one dollar per coin). In any other coin or on any other network, the exact amount includes a 0.50% conversion fee and a rate buffer against the rate moving while the payment travels (0.10–1.25% by coin). The payer pays both, and the checkout page states both before they send. On that path you are credited the invoice amount at most: what the payment is worth above it when it reaches the treasury — the conversion fee and the part of the buffer the rate does not use, or more if the rate has moved further in the payer’s favour — is kept by Cherum, and invoice.credited reports it as payerFeeUsd. If the rate moves the other way by more than the buffer, you are credited what arrived, and shortfallUsd names the difference. Omit the pair and you get a valid invoice with no requisites — the payer then picks a payment method on the checkout page.

A coin value is an identifier, not a label. The coin of the TON network keeps the value TON everywhere the API carries a coin — coin, allowedCoins, minimums.byCoin, error details and webhooks. Wallets and explorers now show that coin as GRAM, and so do our payment page, receipts and dashboard. If you show the coin to your buyers, print GRAM when coin is TON and network is ton; match and store the value as it comes.

Transition note — the payment address is now issued by Cherum. Live invoices used to carry the address you sent; they no longer do. If your integration watches your own address for incoming payments, that watcher will not see new invoices being paid — rely on the invoice.* webhooks or poll GET /invoices/:id instead. Each confirmed payment is credited to your Cherum balance within minutes with the 0.90% fee deducted, and is paid out to the wallet that receives your payments, in the coin and network you set in the dashboard (USDC on Base by default; other registered pairs supported) — after every payment or batched past your threshold.

create.sh
curl -X POST https://api.cherum.io/pay/v1/invoices \
  -H "Authorization: token $CHERUM_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-4417" \
  -d '{
    "amountUsd": 49.90,
    "orderId": "4417",
    "coin": "USDC",
    "network": "base",
    "expiresInMinutes": 30,
    "metadata": { "customer": "cus_812" }
  }'
201 Created
{
  "invoice": {
    "id": "inv_2f5a91c0d4e7b8a3c1f0e2d4",
    "checkoutUrl": "https://pay.cherum.io/i/inv_2f5a91c0d4e7b8a3c1f0e2d4",
    "orderId": "4417",
    "mode": "live",
    "amountUsd": 49.9,
    "coin": "USDC",
    "network": "base",
    "address": "0xCherumIssuedDepositAddress",
    "memo": null,
    "amountCrypto": "49900000",
    "tokenDecimals": 6,
    "priceAtSeen": null,
    "status": "new",
    "anomaly": "none",
    "anomalyAmount": null,
    "metadata": { "customer": "cus_812" },
    "expiresAt": "2026-07-30T12:30:00.000Z",
    "seenAt": null,
    "confirmedAt": null,
    "createdAt": "2026-07-30T12:00:00.000Z"
  },
  "watched": true
}

memo is always null today, and that is not an omission: every network Cherum accepts issues a fresh address for each invoice, so a payment identifies itself by the address alone. The field is reserved for networks where one address serves everyone and a tag tells payments apart — XRP, Stellar, Cosmos. Do not branch on it; if it ever becomes non-null, it will be documented here first.

Plain-text description. The description is printed on a Cherum page the payer trusts, so it follows the same rule as everything else a merchant writes there: no links (https://…, www.…), no website addresses (a word, a dot and a real domain ending, such as cherum-pay.io — put a space after the dot to keep a version number or an abbreviation), no <, > or `, and no hidden or text-direction characters. Tabs and line breaks are fine; they print as spaces. A description that breaks the rule is refused with 400 invalid_description; detail.problem names what to fix — link, domain, markup or invisible — and message says it in words. A description longer than 500 characters is a 400 validation_error, as before.

Since 27 Oct 2026, 01:20 UTC (see the API changelog) such an invoice is refused. Before that date it was created, and the 201 answer said what would be refused: a warnings array next to invoice — {"code":"invalid_description","problem":"link","message":"…","enforcedFrom":"2026-10-27T01:20:40.000Z"} — and a Warning: 299 - "…" header with the same sentence. enforcedFrom is the moment refusals begin, or null while no date is set. The field is there only when there is something to say. Invoices created before the date keep their description; the payment page prints it as text and never turns it into a link.

watched is the honest answer to “will Cherum see this payment by itself”: it is true only when the coin and network are indexed and the invoice carries exact requisites — on a live key that happens whenever you passed the pair. Test invoices are never watched: their statuses move only through the simulator.

Hosted checkout. Cherum hosts a payer page at https://pay.cherum.io/i/{invoice.id}, and every invoice object carries it as checkoutUrl — send your customer straight there, no string building.

Idempotency. Send Idempotency-Key (≤128 chars) on create. Replaying it with the same body returns the first response with header idempotency-replayed: true; with a different body, 400 idempotency_error; while the first call is still running, 409 idempotency_conflict. If creation fails, the key is released so you can honestly retry. Records are swept after 24 hours.

One open invoice per address. Live invoices each get their own one-time address, so they never collide. An amount below the floor for the coin and network is a different answer — 422 below_minimum, with the floor in detail — not a busy address.

Read an invoice

GET /invoices/:id · needs invoices.read. Returns the invoice, every payment matched to it, and credited — the money truth for accounting: grossUsd, feeUsd, creditedUsd, the credited asset/chain/atoms, creditedAt, payerFeeUsd and shortfallUsd. It is the same record the invoice.credited webhook fires from, and null until the money is credited.

200 OK
{
  "invoice": { "id": "inv_2f5a…", "status": "confirmed", "anomaly": "none", "…": "…" },
  "payments": [
    {
      "tx_hash": "0x9c1e…",
      "network": "base",
      "amount_atomic": "49900000",
      "confirmed": true,
      "seen_at": "2026-07-30T12:04:11.000Z",
      "confirmed_at": "2026-07-30T12:04:47.000Z"
    }
  ]
}

Two naming styles in one response. The invoice object uses camelCase; payment rows come back in snake_case exactly as listed above. That is what the server sends today — parse accordingly.

List invoices

GET /invoices · needs invoices.read. Query: limit (1…100, default 20), status (one of the statuses below), orderId (exact match on the orderId you set at creation — the way back to a payment when your webhook receiver was down and you only have your own order number), starting_after (an invoice id of this key). Newest first.

200 OK
{
  "invoices": [ { "id": "inv_2f5a…", "…": "…" } ],
  "next": "inv_2f5a91c0d4e7b8a3c1f0e2d4"
}

next is the id to pass as starting_after for the following page, or null on the last page. The cursor walks the pair (created at, id), so invoices created in the same millisecond are never skipped. An unknown or foreign starting_after is rejected with 400 invalid_cursor rather than silently restarting from page one.

Mark an invoice

POST /invoices/:id/mark · needs invoices.mark. Body: {"as":"confirmed"} or {"as":"invalid"}. This is your manual verdict for payments Cherum cannot see — an unwatched coin, a payment made off-chain, a dispute you resolved. The invoice moves to that status with anomaly: "marked"; marking it confirmed emits invoice.confirmed, marking it invalid emits nothing. Response: {"invoice": {…}}.

An invoice you marked confirmed carries paidOutOfBand: true in every response and webhook payload from then on: Cherum did not see that payment, and no money moved through Cherum. Tell such an event apart from a payment that actually arrived. If no payment at all reached the invoice, the payment page, the dashboard and our emails say marked paid by the merchant — no payment received, never “Paid”, and the amount is not counted as revenue. Marking a live invoice confirmed needs a confirmed email on the account (403 email_unconfirmed otherwise). Test invoices and invalid have no such condition.

Cancel an invoice

POST /invoices/:id/cancel · needs invoices.create. No body required — if your HTTP client insists on Content-Type: application/json, send {}. Only a new invoice can be canceled: the payment page stops accepting and invoice.canceled is emitted in the same transaction. Canceling an already-canceled invoice is an idempotent 200 and emits nothing. Once a payment has been seen the answer is 409 not_cancelable — money in flight cannot be waved away, the way back is a refund. Response: {"invoice": {…}}.

Statuses & anomalies

An invoice carries two independent axes. status answers “where is this in its life”, anomaly answers “is anything off about the money”.

statusMeaning
newCreated, nothing seen on-chain yet.
seenA payment is visible but has not reached the confirmation depth.
confirmedEnough confirmed value arrived. This is the one to fulfil on.
expiredThe rate window closed without sufficient payment.
invalidYou marked it invalid.
canceledCanceled before any payment was seen — by you, with POST /invoices/:id/cancel, or by Cherum when it could not issue a payment address for the invoice.
anomalyMeaninganomalyAmount
noneNothing unusual.null
underpaidLess than expected, beyond tolerance.atoms still missing
overpaidMore than expected, net of refunds already sent.excess in atoms
repricedPaid after the window: the rate is stale, the call is yours.null
markedStatus came from your manual verdict, not from the chain.null

Rules that decide those transitions:

  • Tolerance. A payment counts as sufficient at or above expected − tolerance. The tolerance is the LARGER of a percentage of the invoice (0.1% today) and a flat amount ($2 today), and the flat side is capped at 3% of the invoice so it cannot swallow a real underpayment on a small one. On a $500 invoice that is $2; on a $50 invoice it is $1.50 (the 3% cap, not the full $2); on a $5,000 invoice it is $5 (the percentage wins). Both values are policy settings, not constants — the numbers above are the ones in force.
  • Late window. A payment arriving after expiry is accepted for another 24 hours by default and flagged repriced; later than that, nothing changes.
  • Confirmed is sticky. Once confirmed, an unconfirmed trickle cannot drag the invoice back to seen.
  • Extra money after confirmation. By default a duplicate payment is recorded but the invoice is not re-priced; the platform can be configured to re-price it into overpaid instead. Excess is always counted net of refunds you have already sent.
  • Expiry never kills a paid invoice. An invoice paid above the floor but still maturing stays alive and confirms normally.
  • Priority. repriced outranks overpaid; marked and repriced are never overwritten by a later duplicate.

Tolerance, late window and the duplicate-payment mode are platform settings, not per-request parameters. The defaults above are what runs unless Cherum tells you otherwise.

Webhook endpoints

All routes below need webhooks.manage.

RouteDoes
POST /webhooksRegister an endpoint. Body: url (https, ≤1024), optional events (≤16, from the dictionary below), optional format (only "native"). Returns {"webhook":{"id","url","secret"}} — the secret is shown only here. If your account already has an active endpoint on that same URL, it is switched off and its id comes back in replaced: one receiver holds one secret, so the older endpoint could only reject everything from that moment on.
GET /webhooksList endpoints: id, url, format, events, active, disabled_at, disabled_reason, failing_since, created_at. Secrets are never listed.
GET /webhooks/:idOne endpoint, the same fields. This is the cheap call for “is Cherum still sending to me?”.
POST /webhooks/:id/rotate-secretNew secret, old one valid 24 h. Returns {"webhook":{"id","secret","previousValidFor":"24h"}}.
POST /webhooks/:id/pingQueue a test delivery. 202 with {"delivery":{"id","eventId"}}.
POST /webhooks/:id/disableStop deliveries to this endpoint. {"ok":true}.
POST /webhooks/:id/enableResume deliveries and clear the failure streak. {"ok":true}. Events from the time the endpoint was off are not replayed — see Retries. If another active endpoint of your account uses the same URL, it is switched off and its id comes back in replaced.
GET /webhooks/:id/deliveriesLast 100 deliveries for this endpoint, newest first.
GET /deliveries/:idOne delivery, including the payload we sent and the response you gave.
POST /deliveries/:id/resendQueue it again immediately, as a new attempt marked a manual retry. {"ok":true,"manualRetry":true}.

Omitting events subscribes the endpoint to all 15 events of the dictionary below. The URL must be https:// on the standard port 443, use a host name rather than an IP address and carry no user name or password; anything else is refused at registration with 400 unsafe_url. The host name is resolved before every delivery: if it does not resolve, or resolves to a private or reserved address, the delivery is not sent and is logged as blocked: unresolved or blocked: private_network, without retries. We connect to the address we checked, verify the certificate against your host name, and do not follow redirects — a signed body never travels to a third host.

Events & payload

Subscribable events: invoice.created, invoice.seen, invoice.confirmed, invoice.expired, invoice.underpaid, invoice.overpaid, invoice.repriced, invoice.credited, invoice.late_inflow, invoice.canceled, refund.created, refund.completed, refund.failed, refund.canceled, refund.expired. For deposit-lane payments invoice.confirmed means the payer paid; invoice.credited fires later, when the funds have actually been credited to your Cherum balance with the fee deducted — use it for accounting. Its data carries id, orderId, grossUsd, feeUsd, creditedUsd, asset, chain, amountAtomic, overpayCreditedUsd, payerFeeUsd and shortfallUsd. overpayCreditedUsd appears only on an overpaid invoice whose excess ended up with you instead of going back to the payer — no refund claim was open and overpayments are set to go to the merchant, which is not the default. It is the part of creditedUsd that came from that excess. On every other payment the field is absent from the JSON, not sent as zero, so test for its presence rather than its value. payerFeeUsd is what the payment was worth above the invoice when it reached Cherum’s treasury, kept by Cherum beyond your fee: the conversion fee and the part of the rate buffer the rate did not use — or more, if the rate moved further than the buffer in the payer’s favour. An overpayment is not part of it, and neither is any other asset sent to the payment address. The payer pays it; it is never taken from your credit, and it is 0 when the payer paid in the coin and network you get paid in, unless you changed them before the payment was credited. shortfallUsd is how much less than the invoice arrived when the invoice still closed — within the underpayment tolerance, or because the rate moved further than the buffer — and 0 otherwise (in the coin and network you get paid in, only the tolerance can leave a shortfall, valued at the quote’s rate, unless you changed them before the payment was credited); grossUsd is smaller by exactly that much. invoice.late_inflow fires when money lands after the invoice closed, or in an asset the invoice did not ask for: nothing is credited automatically — you decide in the dashboard whether to take it or send it back. Its data carries ledgerId, asset, chain, amountAtomic, usd, arrivedAt and decideBy. invoice.canceled fires when you cancel a still-unpaid invoice (API POST /invoices/:id/cancel); only new invoices can be canceled — once a payment is seen, the way back is a refund. Test pings arrive as webhook.ping; it is deliberately outside the dictionary, so you cannot subscribe to it and it is never broadcast.

data is the same invoice object the API returns.

POST to your endpoint
{
  "id": "msg_7d2c4b1a9e8f0c3d5a6b7c8d",
  "type": "invoice.confirmed",
  "apiVersion": "2026-07-11",
  "createdAt": "2026-07-30T12:04:47.000Z",
  "data": { "id": "inv_2f5a…", "status": "confirmed", "…": "…" }
}

Every delivery gets its own id, unique per endpoint — use it to deduplicate. Events are queued inside the same database transaction that changes the invoice, so a confirmed payment cannot be committed without its event. Endpoints registered in the dashboard for your account receive events from your API keys too. One URL keeps one active endpoint per account: registering it again, in the dashboard or through POST /webhooks, switches the older endpoint off, so your receiver does not get each event twice under two secrets.

Verify a signature

Cherum signs with Standard Webhooks. Three headers travel with each delivery:

HeaderValue
webhook-idThe event id, same as id in the body.
webhook-timestampUnix seconds when we signed.
webhook-signatureOne or more space-separated v1,<base64> values.

The signed string is {webhook-id}.{webhook-timestamp}.{raw body}, HMAC-SHA256 with your secret, base64. The secret’s whsec_ prefix is stripped and the rest is base64-decoded to get the key bytes. Sign the raw body — re-serialising JSON changes the bytes and breaks the check.

verify.js
import { createHmac, timingSafeEqual } from 'crypto';

// rawBody: Buffer or string, exactly as received.
export function verify(rawBody, headers, secret) {
  const id = headers['webhook-id'];
  const ts = headers['webhook-timestamp'];
  const got = String(headers['webhook-signature'] || '').split(' ');

  // Reject stale deliveries — five minutes is a sane window.
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

  const key = secret.startsWith('whsec_')
    ? Buffer.from(secret.slice(6), 'base64')
    : Buffer.from(secret);
  const mine = 'v1,' + createHmac('sha256', key)
    .update(`${id}.${ts}.${rawBody}`)
    .digest('base64');

  return got.some((sig) => sig.length === mine.length
    && timingSafeEqual(Buffer.from(sig), Buffer.from(mine)));
}

Accept the delivery if any of the supplied signatures matches — during a secret rotation there are two.

Retries & auto-disable

Answer 2xx and the delivery is done. Anything else is retried: network errors, timeouts, 429, 5xx — and 4xx too, 401 and 403 included, because a receiver that is redeploying or rotating a secret answers exactly like that and should not cost you an event.

404 and 410 mean the URL does not exist on your side. They go on the same ladder — but a short one: if the address is still missing on the fourth attempt (about 35 minutes after the first failure) we switch the endpoint off with that reason on it and email you, instead of knocking for 28 hours. The grace exists because a plugin update, a maintenance page or a restart answers 404 for a minute, and one such minute used to cost a store its notifications for good. The only answer never retried is our own refusal to call an unsafe URL (see the rules above) — that one is never called at all.

Retry schedule, up to 8 attempts over roughly 28 hours: immediately, 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h. Each attempt waits at most 10 seconds for your response and stores the first 2 KB of your body in the journal — a useful place to leave a reason.

An endpoint is switched off automatically in three cases: a missing address (above), five days of unbroken failures, or another endpoint of your account taking over the same URL. Each writes its reason into disabled_reason and sends you an email naming it.

Events are not queued while an endpoint is off. Nothing is created for a disabled endpoint, and switching it back on replays nothing. A delivery that was still pending when the endpoint went off is closed as undelivered, with the note not delivered: the endpoint was switched off. To catch up on what happened in between, read GET /invoices. Delivery statuses you will see in the journal: pending, sending, delivered, exhausted.

Test pings are capped at 60 per hour per key; beyond that, 429 ping_budget_exceeded.

Rotating the secret

POST /webhooks/:id/rotate-secret issues a new secret and keeps the previous one valid for 24 hours. During that window every delivery carries two signatures, so deploy at your own pace: store the new secret, keep accepting the old one until you have rolled it out, and no delivery is lost. After 24 hours the old secret stops being signed with.

Delivery journal

Both delivery routes return the same fields: id, event_id, event_type, invoice_id, attempt, status, payload, response_code, response_body, next_retry_at, delivered_at, first_delivered_at, last_attempt_at, manual_retries, last_manual_retry_at, created_at; the single-delivery route adds endpoint_id; both add wait_reason. These are snake_case, like payment rows. We do not keep your endpoint’s reply: response_code holds its HTTP status, and response_body holds only our own words — blocked: <reason> when we refused the URL, no answer: <class> (<code>) when there was no HTTP answer, or null. wait_reason: "channel" means the delivery is waiting for our delivery channel: it spends no attempt and goes out on its own. first_delivered_at is the moment it first succeeded and is never overwritten — a manual retry adds an attempt, it does not rewrite history.

POST /deliveries/:id/resend puts a delivery back in the queue right away, counted in manual_retries. One delivery can be resent at most 5 times in 24 hours; the sixth answers 429 resend_limit. An already delivered one may be resent — it goes out with the same webhook-id, so a receiver that de-duplicates will recognise it. It refuses with 404 if the delivery is being sent at this very moment or if its endpoint is disabled — switch the endpoint on first. The dashboard button and this route follow one and the same rule.

Balance & payouts

GET /balance · needs invoices.read. Your credited balances by asset and chain, in atoms, plus refundReserves — money already set aside for open refunds (it left the balance when the refund was filed, this is where it went).

GET /payouts · needs invoices.read. Query: limit (1…100, default 50). Transfers to the wallet that receives your payments, newest first: amount and delivery fee in atoms, trigger (per_payment or threshold), status, txHash. Transfers arrive in the coin and network you set for that wallet: USDC on Base by default, other registered pairs as configured in the dashboard.

Live keys only. Both routes answer a chm_test_ key with empty lists and "liveOnly": true — not an error, and not your live figures either. There is no such thing as a test balance: only live invoices are credited to a balance, and payouts leave that same balance. The dashboard says the same thing on its own screens when you switch it to Test.

Refunds

Refunds are driven by the API since 26 Aug 2026 — the routes below need the refunds.manage permission (a checkbox at key issue time; existing keys need reissuing to gain it). Refunds pay out in USDC on Base to the wallet the payer names on the checkout page; the amount you name is what the payer receives, and our fee plus network cost is added on top and reserved against your balance.

RouteWhat it does
GET /invoices/:id/refund-quotePrices a refund on a credited invoice: fee, network cost, your balance headroom. Optional ?amountUsd= adds a full breakdown. 404 not_credited if nothing was credited.
POST /refundsCreates the refund: {"invoiceId","amountUsd","reason"?}. A refund created with an API key is held for 24 hours (holdUntil in the response): it is not paid out before then, even once the payer has named a wallet, and the account owner gets an email the moment it is created, so a refund nobody on your side started can be cancelled in the dashboard. Refunds created with API keys also share a daily total per account (429 api_daily_cap). Send Idempotency-Key — a retry with the same key returns the same open refund with 200 instead of 409 refund_already_open. The reserve (amount + fee + cost) is taken from your balance in the same transaction; refund.created fires. One open refund per invoice. A refusal answers 404 not_credited or 409 with code, message and, where numbers decide it, detail: over_cap carries headroomUsd, insufficient_balance carries balanceAtomic and neededAtomic.
GET /refundsLists refunds, newest first; ?invoiceId= narrows to one invoice. Statuses mirror the refund.* events.
POST /refunds/:id/cancelCancels a refund the payer has not been paid on yet: the reserve returns to your balance, refund.canceled fires. Already sending or closed → 409 not_cancelable.

All four routes need a live key: a chm_test_ key gets 403 live_key_required, whatever its permissions. Refunds are account-wide — a key sees and cancels refunds opened by any key of the account and from the dashboard. The dashboard drives the very same pipeline — API and buttons cannot disagree.

The standard path. Payments land on a Cherum deposit address and are credited to your balance with us — this lane is custodial — so refunds out of that balance are sent by Cherum on your instruction — you name the amount and the reason in the dashboard, the payer names the wallet on the checkout page, and we pay it in USDC on Base. The amount plus our fee and network cost is reserved against your balance when you file the request and released if it does not go through. The refund.* webhooks below report every outcome of this path.

Older direct invoices were paid straight to the merchant’s wallet; the money never passed through Cherum. On an overpayment such an invoice turns overpaid with the excess in anomalyAmount, the payer leaves a return address on the hosted checkout page, the request appears in your dashboard, you send the excess from your own wallet and close the request there. Closing it lowers the recorded excess, so a later duplicate payment never shows a refund you already made.

Test mode

Invoices carry a mode of live or test, inherited from the key that created them. Test invoices are excluded from revenue figures and cannot use the exchange-friendly deposit lane.

Create a test key yourself: Dashboard → Developers → Create key → Mode: Test. Test keys start with chm_test_ and work against this same API — same endpoints, same webhook signatures. Invoices they create are test invoices: no scanner watches them and no real money can pay them. Instead you drive them by hand from the Pay section of the dashboard — mark them paid, underpaid, overpaid or expired — and each transition runs through the same status machine and fires real webhooks to your endpoint, which is exactly what you want to test against. When you are ready, create a live key and change one string.

Drive it from CI. POST /invoices/:id/simulate with a chm_test_… key and {"event":"seen"|"confirmed"|"underpaid"|"overpaid"|"expired"|"credited"} pushes a test invoice through the same state machine production uses — statuses change and the webhooks fire for real. A live key gets 403 sandbox_key_required. Every event except expired needs requisites (coin/network/amountAtomic) on the invoice — without them there is no payment to move, and the call answers 409 no_amount. expired is the exception on purpose: an invoice with no coin on it is the ordinary shape when the payer picks the rail on the checkout page, and “the payer walked away without choosing” is the commonest way an invoice ends, so it has to be rehearsable. credited emits the accounting webhook with the production body shape (grossUsd/feeUsd/creditedUsd at your live rate, payerFeeUsd from the quote as if the rate had not moved) WITHOUT moving money: test mode never touches real balances. The dashboard button drives the very same simulator.

Payer endpoints

The hosted checkout at pay.cherum.io reads these public, unauthenticated endpoints. The invoice id is the capability — treat it as a secret, and do not build merchant logic on them; use the key-authenticated routes above.

RouteDoes
GET /api/checkout/v1/invoice/:idPayer view of an invoice.
GET /api/checkout/v1/invoice/:id/plainScript-free HTML requisites.
GET /api/checkout/v1/invoice/:id/refund-quoteWhat the payer would receive back on this invoice, and on which network — the payment page shows it before asking for an address.
POST /api/checkout/v1/invoice/:id/refund-requestPayer leaves a return address for an overpayment.
POST /api/checkout/v1/invoice/:id/merchant-refund/addressPayer names the wallet for a refund the merchant started. The payment page opens this form on its own once the refund exists; the address is checked for the refund network before it is stored.
POST /api/checkout/v1/invoice/:id/late-inflow/:ledgerId/claimPayer claims money that arrived after the invoice closed, or in the wrong asset. Each arrival has its own ledger reference, shown on the payment page; the claim names the wallet the funds go back to.
GET /api/checkout/v1/link/:tokenPayment-link view.
GET /api/checkout/v1/link/:token/plainScript-free link summary.
POST /api/checkout/v1/link/:token/payMint an invoice from a payment link.
GET /api/checkout/v1/invoice/:id/anytoken/quoteQuote for paying in another token.
POST /api/checkout/v1/invoice/:id/anytoken/buildBuild that payment transaction.
POST /api/checkout/v1/invoice/:id/anytoken/sentReport the broadcast swap hash so the payment shows as “via conversion” in the merchant dashboard. The hash is only matched against observed payments — it moves nothing.

Payment links themselves are created in the dashboard, not through this API.

Pay with anything

The merchant fixes the coin and network on the invoice; the payer can pay it with a different token they already hold on the same network. The checkout swaps it in the payer’s own wallet — an exact-buy through an on-chain aggregator, with the invoice address as the receiver. The merchant receives exactly the invoiced amount; whatever the swap does not use returns to the payer’s address in the same transaction. The swap itself runs in the payer’s wallet, and what it delivers to the invoice address is credited to the merchant’s Cherum balance like any other payment.

  • Conversion fee: 0.30%, shown on the quote card before signing and already included in the quoted amount. The route is quoted at market, and the fee is a separate, published number. The swap buys the invoice amount, which already carries any conversion fee and rate buffer the checkout page names.
  • Networks: Ethereum, Base, Arbitrum, Optimism, Polygon, BNB Chain. Curated payer-side tokens: ETH, USDC, USDT, DAI, WETH, cbBTC, ARB, OP, POL, BNB (network-dependent).
  • The lane is open while the invoice is unpaid. Once any payment is seen, the swap lane closes — a shortfall is topped up with a direct transfer of the invoice coin.
  • Quotes are single-use and expire in about a minute; building the transaction consumes the quote.
ErrorMeaning
anytoken_disabledThe lane is switched off server-side.
invoice_not_openThe invoice is no longer payable (confirmed, expired or cancelled).
partially_paidA payment was already seen — finish with a direct transfer instead.
no_routeThe aggregator has no route for this pair right now; another token usually works.
allowance_pendingThe token approval has not confirmed yet — retry the build shortly.
test_invoiceSwaps are not simulated for test invoices.
amount_out_of_rangeThe invoice amount is outside the range the lane accepts. The hosted page simply hides the lane in this case.

Claims — public endpoints

A claim campaign is created and funded in the dashboard. Once the contract has the campaign on chain, three public endpoints serve its recipients: the claim page at claim.cherum.io reads them, and so can a wallet or the organizer’s own site. They take no API key and no cookies. That is on purpose — the contract stores only the Merkle root, so the recipient list has to be public for anyone to claim, and the list URI the contract stores points to …/list below. All paths are relative to https://api.cherum.io/claim/v1.

Method & pathLimit / minReturns
GET /:chain/:campaignId120The campaign summary.
GET /:chain/:campaignId/list30The full recipient list — the document the contract’s list URI points to. It never changes for the life of the campaign, so it is sent with Cache-Control: public, max-age=300.
GET /:chain/:campaignId/eligibility?address=0x…120One recipient’s share, its stored Merkle proof and, while the claim can still go through, a ready claim transaction.

:chain is the network name in lower case: ethereum, arbitrum, optimism, base, polygon, bsc or hyperevm. :campaignId is the on-chain campaign id, 0x and 64 hex digits. Only campaigns the contract has seen created are served: a campaign that has not been funded yet answers 404.

Campaign summary. chain, campaignId, contract, token, tokenSymbol, tokenDecimals, title, status, totalAtomic, total, claimedAtomic, claimedCount, recipientCount, expiry. Amounts ending in Atomic are strings in the token’s smallest unit; total is the same amount in whole tokens. status is live, completed (everything has been claimed), expired or refunded (the organizer took the remainder back). title is the organizer’s own text, or null when there is none or Cherum has hidden it. Cherum checks it as plain text when a campaign is created — no links, no domain names, no <, > or `, no control or invisible characters. Titles of older campaigns may predate some of these checks, and in any case a title is someone else’s text: put it on a page as text, never as HTML.

Recipient list. chain, campaignId, contract, token, tokenSymbol, tokenDecimals, merkleRoot, totalAtomic and leaves — one {index, account, amountAtomic} per recipient, in leaf order.

Eligibility. campaign (the summary above), index, account, amountAtomic, amount, claimed, claimTxHash, proof, and claimTx — {kind, to, data, value} ready for the recipient’s wallet to sign. claimTx is present only while the campaign is live and this share has not been claimed: on an expired or refunded campaign the transaction would only burn gas.

GET /base/0x5f3c…e1a9/eligibility?address=0x9a1F…44C2
{
  "campaign": {
    "chain": "base", "campaignId": "0x5f3c…e1a9",
    "contract": "0x978106be459e54dB995d443a15742EBb41c75a4e",
    "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "tokenSymbol": "USDC", "tokenDecimals": 6,
    "title": "Community round 2", "status": "live",
    "totalAtomic": "250000000", "total": "250", "claimedAtomic": "40000000", "claimedCount": 4,
    "recipientCount": 25, "expiry": "2026-11-30T12:00:00.000Z"
  },
  "index": 7, "account": "0x9a1F…44C2", "amountAtomic": "10000000", "amount": "10",
  "claimed": false, "claimTxHash": null,
  "proof": ["0x…", "0x…", "0x…", "0x…", "0x…"],
  "claimTx": { "kind": "claim", "to": "0x978106be459e54dB995d443a15742EBb41c75a4e", "data": "0x…", "value": "0" }
}

Errors. These endpoints answer their own errors with a flat {"error":"…"}. Only the rate-limit and maintenance answers use the nested envelope of the rest of this page.

HTTPerrorWhen
404not_found:chain or :campaignId is malformed, the campaign is unknown, or it is not on chain yet.
400bad_addressaddress is missing or is not 0x and 40 hex digits.
200not_eligibleThe address is not on the list. The status is 200 — the question was answered: {"error":"not_eligible"}.
429rate_limitedOver the limit for this route: {"error":{"code":"rate_limited","message":"…"},"retryAfter":12}. The edge limit answers with the same code but without retryAfter in the body, as described under Errors & rate limits. In both cases wait the number of seconds in Retry-After.
503service_pausedCherum is paused for maintenance: {"error":{"code":"service_paused","message":"…"}}, with Retry-After.

Limits and CORS. The limits in the table are requests per minute per IP address, each route counted on its own; in front of them sits the same edge limit as everywhere on api.cherum.io — 30 requests per second per IP address, with bursts up to 60. The campaign data and the endpoint’s own errors (404, 400) are sent with Access-Control-Allow-Origin: *, and these are plain GET requests with no preflight, so a page on any origin can read them. A 429 or 503 may come without that header, and a browser on another origin then reports a network error instead of the answer — treat it the same way: back off and retry.

API changelog

Changes that can make a request that works today fail later. Each one is announced here and by email to the owners of live API keys before it takes effect.

AnnouncedChangeTakes effect
2026-10-25POST /invoices: the description must be plain text. A description with a link, a website address, markup or hidden characters is refused with 400 invalid_description. Until then such an invoice is created, and the answer carries warnings and a Warning: 299 header.27 Oct 2026, 01:20 UTC. Before that the API created such invoices and only warned.

Full route index

Every key-authenticated route that exists, so you can see there is nothing hidden. All are relative to https://api.cherum.io/pay/v1. The public endpoints without a key are listed under Payer endpoints and Claims — public endpoints. The limit is requests per minute for that route alone — how they are counted is under Errors & rate limits.

Method & pathPermissionLimit / minSection
POST /invoicesinvoices.create120Create an invoice
GET /invoicesinvoices.read120List invoices
GET /invoices/:idinvoices.read300Read an invoice
POST /invoices/:id/markinvoices.mark60Mark an invoice
POST /invoices/:id/cancelinvoices.create60Cancel an invoice
POST /invoices/:id/simulateinvoices.create60Test mode
GET /menone — authentication only120Authentication
GET /balanceinvoices.read120Balance & payouts
GET /payoutsinvoices.read120Balance & payouts
GET /invoices/:id/refund-quoterefunds.manage120Refunds
POST /refundsrefunds.manage30Refunds
GET /refundsrefunds.manage120Refunds
POST /refunds/:id/cancelrefunds.manage60Refunds
POST /webhookswebhooks.manage30Webhook endpoints
GET /webhookswebhooks.manage120Webhook endpoints
GET /webhooks/:idwebhooks.manage120Webhook endpoints
POST /webhooks/:id/rotate-secretwebhooks.manage30Rotating the secret
POST /webhooks/:id/pingwebhooks.manage30Webhook endpoints
POST /webhooks/:id/disablewebhooks.manage60Webhook endpoints
POST /webhooks/:id/enablewebhooks.manage60Webhook endpoints
GET /webhooks/:id/deliverieswebhooks.manage120Delivery journal
GET /deliveries/:idwebhooks.manage120Delivery journal
POST /deliveries/:id/resendwebhooks.manage60Delivery journal

Questions, or something here that does not match what the server did? Write to [email protected] — a documentation bug is a bug.