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.
Invoices you create through this API are paid straight to the address you pass in. Cherum watches the chain, applies the status machine and calls your webhook; the funds never move through Cherum on this path.
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 |
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.
Errors & rate limits¶
Errors carry the same envelope: {"error":{"code":"…","message":"…"}}. Validation errors add details — an array of {path, message} straight from the schema.
| 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. |
| 409 | address_busy | An open invoice already exists for this address + coin + network. |
| 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 | Your receiving address. Stored lower-cased. |
amountAtomic | string of digits | no | Exact amount expected, in atoms. Must be > 0. |
tokenDecimals | integer 0…36 | no | Decimals of that token, so amounts render correctly. |
The last five fields are what make an invoice payable and watchable: pass coin, network, address, amountAtomic and tokenDecimals together. Omit them and you get a valid invoice with no requisites — useful only if you settle it yourself.
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",
"address": "0xYourReceivingAddress",
"amountAtomic": "49900000",
"tokenDecimals": 6,
"expiresInMinutes": 30,
"metadata": { "customer": "cus_812" }
}'{
"invoice": {
"id": "inv_2f5a91c0d4e7b8a3c1f0e2d4",
"orderId": "4417",
"mode": "live",
"amountUsd": 49.9,
"coin": "USDC",
"network": "base",
"address": "0xyourreceivingaddress",
"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
}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 amountAtomic was set.
Hosted checkout. Cherum hosts a payer page at https://pay.cherum.io/i/{invoice.id}. This API does not return that URL — build it from the id yourself.
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. A second open invoice for the same address + coin + network returns 409 address_busy — two live invoices on one address cannot be told apart on-chain.
Read an invoice¶
GET /invoices/:id · needs invoices.read. Returns the invoice plus every payment matched to it.
{
"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), 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": {…}}.
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. Default tolerance is 1% (100 bps). - 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. |
GET /webhooks | List endpoints: id, url, format, events, active, disabled_at, failing_since, created_at. Secrets are never listed. |
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 and clear the failure streak. {"ok":true}. |
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. {"ok":true}. |
Omitting events subscribes the endpoint to all seven. 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. 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.
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 a failure, and only some failures are worth retrying: network errors, timeouts, 429 and 5xx. Other 4xx responses are treated as a deliberate rejection and the delivery is exhausted at once.
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 failing continuously for 5 days is disabled automatically and its queued deliveries are marked exhausted, so nothing hangs forever. Re-enable it with POST /webhooks/:id/enable, which also clears the failure streak. 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, created_at; the single-delivery route adds endpoint_id. These are snake_case, like payment rows.
POST /deliveries/:id/resend puts a delivery back in the queue right away. It refuses with 404 if the delivery is being sent at this very moment or its endpoint is disabled — resending an in-flight delivery would double it.
Refunds¶
There is no refund route in this API today, and no field in it that moves money. Refunds are non-custodial by construction: Cherum never holds the payment, so only you can send it back.
How an overpayment is handled: the 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.
Today the sandbox lives in the dashboard, not in this API: keys issued from Dashboard → Developers are live-mode, and test invoices are created and driven — paid, underpaid, overpaid, expired — from the Pay section there. Simulated payments run through the same status machine and fire real webhooks to your endpoint, which is exactly what you want to test against. If you need a test-mode API key, write to [email protected].
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. |
POST /api/checkout/v1/invoice/:id/refund-request | Payer leaves a return address for an overpayment. |
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 /webhooks | webhooks.manage | Webhook endpoints |
GET /webhooks | 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.