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 settles the money into your Cherum balance with the 0.90% fee deducted — the details are under Create an invoice. On a test key nothing is custodial: payments go straight to the address you pass in.
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 settle 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:
Authorization: token chm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# or
Authorization: Bearer chm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxKeys 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.
| Permission | Unlocks |
|---|---|
invoices.create | POST /invoices, POST /invoices/:id/mark |
invoices.read | GET /invoices, GET /invoices/:id |
webhooks.manage | every /webhooks and /deliveries route |
refunds.manage | every /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. webhooks.manage is a checkbox on the same screen — tick it when you create the key if your code will manage endpoints, or manage them 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 it created. 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.
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.
| HTTP | code | When |
|---|---|---|
| 400 | validation_error | Body failed the schema. See details. |
| 400 | idempotency_error | Same Idempotency-Key replayed with a different body. |
| 400 | invalid_cursor | starting_after is not an invoice id of this key. |
| 400 | unsafe_url | Webhook URL is not a public https endpoint. |
| 401 | unauthorized | Header missing, or key unknown, revoked or expired. |
| 403 | forbidden | Key lacks the permission this route needs. |
| 403 | account_blocked | The account behind the key is blocked. |
| 403 | screening_blocked | The receiving address is not permitted. |
| 404 | not_found | No such invoice, endpoint or delivery for this key. |
| 422 | below_minimum | The amount is under the floor for that coin and network. detail carries {minimumUsd, asset, network}; cheaper networks have a lower floor. |
| 409 | address_busy | An open invoice already exists for this address + coin + network. |
| 400 | coin_not_supported | That coin is not accepted on that network. Create the invoice without a coin and let the buyer pick. |
| 409 | coin_unavailable | The rail cannot take an invoice on that coin and network right now. |
| 409 | wallet_required | The account behind the key has no wallet to receive the payment. |
| 409 | idempotency_conflict | A request with this Idempotency-Key is still in flight. |
| 409 | endpoint_disabled | Ping sent to a disabled endpoint — enable it first. |
| 429 | ping_budget_exceeded | More than 60 test pings per hour for this key. |
Per-route limits, counted per IP per minute: create invoice 120, read one invoice 300, list invoices 120, mark 60, create webhook 30, list webhooks 120, rotate secret 30, ping 30, enable / disable / resend 60, deliveries 120. A global 600-per-minute ceiling applies on top. Exceeding a limit returns 429 from the rate limiter, whose body differs from the error envelope above:
{ "statusCode": 429, "error": "Too Many Requests", "message": "…" }Create an invoice¶
POST /invoices · needs invoices.create
| Field | Type | Required | Notes |
|---|---|---|---|
amountUsd | number | yes | Positive, at most 10,000,000. |
orderId | string ≤128 | no | Your reference. Echoed back and shown to the payer. |
metadata | object | no | Free-form, at most 8 KB serialised. Keys starting with _ are reserved and silently dropped. |
expiresInMinutes | integer 5…1440 | no | Rate window. Default 20. |
coin | string ≤16 | no | e.g. USDC. |
network | string ≤24 | no | e.g. base. |
address | string ≤128 | no | Ignored on live keys — every live invoice with a pair is issued with a one-time Cherum deposit address (accepted for compatibility, never used). On test keys: your receiving address, stored lower-cased. |
amountAtomic | string of digits | no | Ignored on live keys — Cherum quotes the exact crypto amount from amountUsd at the live rate. On test keys: exact amount expected, in atoms, > 0. |
tokenDecimals | integer 0…36 | no | Ignored on live keys — decimals come from Cherum's token registry. On test keys: decimals of that token. |
currency | string, ISO 4217 | no | Charge 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-two currencies, and this is the whole list: USD EUR GBP CHF JPY CAD AUD NZD SEK NOK DKK PLN CZK HUF RON BGN 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. |
allowedCoins | array of {coin, network} | no | Restrict 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. |
returnUrl | https URL ≤1024 | no | Where 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. |
cancelUrl | https URL ≤1024 | no | Where the payer goes if they leave without paying. Same rules as returnUrl. |
description | string ≤500 | no | What the payer is paying for, shown on the payment page. Plain text — not markup, not HTML. |
customerEmail | email ≤254 | no | The 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. Omit the pair and you get a valid invoice with no requisites — the payer then picks a payment method on the checkout page.
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 settles into your Cherum balance within minutes with the 0.90% fee deducted, and is paid out to the settlement pair you set in the dashboard (USDC on Base by default; other registered pairs supported) — after every payment or batched past your threshold.
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" }
}'{
"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.
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; on a test key it also needs your amountAtomic.
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. On test keys, where the address is yours, a second open invoice for the same address + coin + network returns 409 address_busy — two invoices on one address cannot be told apart on-chain. 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 settled asset/chain/atoms and creditedAt. It is the same record the invoice.credited webhook fires from, and null until settlement happens.
{
"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.
{
"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.create. Body: {"as":"confirmed"} or {"as":"invalid"}. This is your manual verdict for payments Cherum cannot see — an unwatched coin, an off-chain settlement, 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": {…}}.
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”.
| status | Meaning |
|---|---|
new | Created, nothing seen on-chain yet. |
seen | A payment is visible but has not reached the confirmation depth. |
confirmed | Enough confirmed value arrived. This is the one to fulfil on. |
expired | The rate window closed without sufficient payment. |
invalid | You marked it invalid. |
canceled | Reserved in the model; nothing sets it today. |
| anomaly | Meaning | anomalyAmount |
|---|---|---|
none | Nothing unusual. | null |
underpaid | Less than expected, beyond tolerance. | atoms still missing |
overpaid | More than expected, net of refunds already settled. | excess in atoms |
repriced | Paid after the window: the rate is stale, the call is yours. | null |
marked | Status 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
overpaidinstead. Excess is always counted net of refunds you have already settled. - Expiry never kills a paid invoice. An invoice paid above the floor but still maturing stays alive and confirms normally.
- Priority.
repricedoutranksoverpaid;markedandrepricedare 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.
| Route | Does |
|---|---|
POST /webhooks | Register 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 /webhooks | List endpoints: id, url, format, events, active, disabled_at, disabled_reason, failing_since, created_at. Secrets are never listed. |
GET /webhooks/:id | One endpoint, the same fields plus held_events — how many events are waiting for it to be switched back on. This is the cheap call for “is Cherum still sending to me?”. |
POST /webhooks/:id/rotate-secret | New secret, old one valid 24 h. Returns {"webhook":{"id","secret","previousValidFor":"24h"}}. |
POST /webhooks/:id/ping | Queue a test delivery. 202 with {"delivery":{"id","eventId"}}. |
POST /webhooks/:id/disable | Stop deliveries to this endpoint. {"ok":true}. |
POST /webhooks/:id/enable | Resume, clear the failure streak and release what was held while it was off. {"ok":true,"resent":N,"expired":M}. |
GET /webhooks/:id/deliveries | Last 100 deliveries for this endpoint, newest first. |
GET /deliveries/:id | One delivery, including the payload we sent and the response you gave. |
POST /deliveries/:id/resend | Queue 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. A URL that resolves to a private or loopback address is refused with 400 unsafe_url, at registration and again before every delivery. Redirects are not followed — 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 settled to your Cherum balance with the fee deducted — use it for accounting. Its data carries id, orderId, grossUsd, feeUsd, creditedUsd, asset, chain, amountAtomic and overpayCreditedUsd. 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 settle 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. 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.
{
"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 — so if one address is registered twice, once in the dashboard and once by an integration, it receives each event twice, with a different id and a different signature each time. Two endpoints on one URL is a duplicate, not a redundancy: keep one, or deduplicate on type together with createdAt and the invoice id, which are identical across the copies.
Verify a signature¶
Cherum signs with Standard Webhooks. Three headers travel with each delivery:
| Header | Value |
|---|---|
webhook-id | The event id, same as id in the body. |
webhook-timestamp | Unix seconds when we signed. |
webhook-signature | One 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.
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 (it must be public https) — 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 that happen while an endpoint is off are not lost. They are queued as deferred and held for 3 days from the moment each happened; POST /webhooks/:id/enable puts them back in the queue and tells you how many (resent), and how many had aged out (expired). Anything older than the hold window is closed as undelivered, and the way to catch up on it is GET /v1/invoices. Delivery statuses you will see in the journal: pending, sending, delivered, deferred, 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. These are snake_case, like payment rows. 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. 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, if its endpoint is disabled, or if it is deferred — a held delivery is already waiting for the endpoint to come back, and switching the endpoint on is what releases it. The dashboard button and this route follow one and the same rule.
Balance & payouts¶
GET /balance · needs invoices.read. Your settled 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). Payouts to your settlement address, newest first: amount and delivery fee in atoms, trigger (per_payment or threshold), status, txHash. Payouts are delivered in the asset and network of your settlement pair — 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 settle into 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.
| Route | What it does |
|---|---|
GET /invoices/:id/refund-quote | Prices a refund on a settled invoice: fee, network cost, your balance headroom. Optional ?amountUsd= adds a full breakdown. 404 not_credited if nothing settled. |
POST /refunds | Creates the refund: {"invoiceId","amountUsd","reason"?}. 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 /refunds | Lists refunds, newest first; ?invoiceId= narrows to one invoice. Statuses mirror the refund.* events. |
POST /refunds/:id/cancel | Cancels 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. |
The dashboard drives the very same pipeline — API and buttons cannot disagree.
The standard path. Payments land on a Cherum deposit address and settle into 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. Invoices issued back when they carried your own receiving address paid straight to your wallet — Cherum never held those funds, so only you can send them back. 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 sandbox invoices: no scanner watches them and no real money can settle 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) WITHOUT moving money — sandbox 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.
| Route | Does |
|---|---|
GET /api/checkout/v1/invoice/:id | Payer view of an invoice. |
GET /api/checkout/v1/invoice/:id/plain | Script-free HTML requisites. |
GET /api/checkout/v1/invoice/:id/refund-quote | What 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-request | Payer leaves a return address for an overpayment. |
POST /api/checkout/v1/invoice/:id/merchant-refund/address | Payer 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/claim | Payer 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/:token | Payment-link view. |
GET /api/checkout/v1/link/:token/plain | Script-free link summary. |
POST /api/checkout/v1/link/:token/pay | Mint an invoice from a payment link. |
GET /api/checkout/v1/invoice/:id/anytoken/quote | Quote for paying in another token. |
POST /api/checkout/v1/invoice/:id/anytoken/build | Build that payment transaction. |
POST /api/checkout/v1/invoice/:id/anytoken/sent | Report 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 settle 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. Funds never touch a Cherum account.
- Conversion fee: 0.30%, shown on the quote card before signing and already included in the quoted amount. No hidden spread — the route is quoted at market and the fee is a separate, published number.
- 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.
| Error | Meaning |
|---|---|
anytoken_disabled | The lane is switched off server-side. |
invoice_not_open | The invoice is no longer payable (confirmed, expired or cancelled). |
partially_paid | A payment was already seen — finish with a direct transfer instead. |
no_route | The aggregator has no route for this pair right now; another token usually works. |
allowance_pending | The token approval has not confirmed yet — retry the build shortly. |
test_invoice | Swaps are not simulated for sandbox invoices. |
amount_out_of_range | The invoice amount is outside the range the lane accepts. The hosted page simply hides the lane in this case. |
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.
| Method & path | Permission | Section |
|---|---|---|
POST /invoices | invoices.create | Create an invoice |
GET /invoices | invoices.read | List invoices |
GET /invoices/:id | invoices.read | Read an invoice |
POST /invoices/:id/mark | invoices.create | Mark an invoice |
POST /invoices/:id/cancel | invoices.create | Cancel an invoice |
POST /invoices/:id/simulate | invoices.create | Test mode |
GET /me | none — authentication only | Authentication |
GET /balance | invoices.read | Balance & payouts |
GET /payouts | invoices.read | Balance & payouts |
GET /invoices/:id/refund-quote | refunds.manage | Refunds |
POST /refunds | refunds.manage | Refunds |
GET /refunds | refunds.manage | Refunds |
POST /refunds/:id/cancel | refunds.manage | Refunds |
POST /webhooks | webhooks.manage | Webhook endpoints |
GET /webhooks | webhooks.manage | Webhook endpoints |
GET /webhooks/:id | webhooks.manage | Webhook endpoints |
POST /webhooks/:id/rotate-secret | webhooks.manage | Rotating the secret |
POST /webhooks/:id/ping | webhooks.manage | Webhook endpoints |
POST /webhooks/:id/disable | webhooks.manage | Webhook endpoints |
POST /webhooks/:id/enable | webhooks.manage | Webhook endpoints |
GET /webhooks/:id/deliveries | webhooks.manage | Delivery journal |
GET /deliveries/:id | webhooks.manage | Delivery journal |
POST /deliveries/:id/resend | webhooks.manage | Delivery journal |
Questions, or something here that does not match what the server did? Write to [email protected] — a documentation bug is a bug.
Next
Get an API key
Dashboard → Developers. The secret is shown once.