API reference
Every route, every field on a payment, and what each error code means.
Base URL https://pay-api.chromia.com. Every request carries
Authorization: Bearer <secret key>. Amounts are decimal strings.
Routes
| Route | Purpose |
|---|---|
GET /v1/assets | What this store can be paid in. Ask this before drawing an asset picker |
POST /v1/payments | Create. Send Idempotency-Key. Returns the payment with checkout_url |
GET /v1/payments/:id | Read one back, with its deposits |
POST /v1/payments/:id/cancel | Cancel an unpaid one. 409 if it is already final |
POST /v1/webhook_endpoints | Register. Signing secret in the response, once. Optional enabled_events |
GET /v1/webhook_endpoints | List them for this key's environment |
POST /v1/webhook_endpoints/:id | Change enabled_events; signing secret untouched |
DELETE /v1/webhook_endpoints/:id | Stop delivering |
GET /v1/events/:id | Re-read a canonical event rather than trusting a body you were handed |
GET /v1/rates | Which pairs are quoted, and which are the peg rather than a feed |
GET /v1/rates/:pair | The rate now, and optionally a conversion. ?usd=19.99 or ?base=100 |
GET /v1/rates/:pair/history | Recorded points for a chart. ?from=&to=&limit= |
POST /v1/test/payments/:id/simulate_payment | Test keys only. Force any outcome |
The /v1/account routes below take an agent token instead, and refuse a secret key with 403.
Managing the account
Everything the dashboard can do, for an agent acting on your behalf. These need an
Authorization: Bearer cpay_at_… token, minted under Developers → Agent tokens — a secret key
authenticates and is then turned away, because widening one credential must not widen the other.
| Route | Purpose |
|---|---|
GET /v1/account | Which merchant, environment and mode, and whose behalf |
GET /v1/account/api_keys | Prefixes only. Secrets exist once, in the response that made them |
POST /v1/account/api_keys | Mint one. { "name": "…" }. Secret returned once |
DELETE /v1/account/api_keys/:id | Revoke |
GET /v1/account/wallets | Payout addresses, and how each was verified |
POST /v1/account/wallets | Set one. { "chain", "address", "unverified": true } |
DELETE /v1/account/wallets/:chain | Retire, and stop offering that chain |
GET/POST /v1/account/preferences | Underpayment tolerance, logo URL, accent colour |
GET /v1/account/team | |
POST /v1/account/team | { "email", "role": "admin" | "member" } |
DELETE /v1/account/team/:email | |
GET /v1/account/environments | |
POST /v1/account/environments | Create a sandbox. { "name": "…" } |
GET /v1/account/unattributed | The reconciliation queue |
POST /v1/account/unattributed/attach | { "deposit_id", "payment_id" }. Settles the payment |
GET /v1/account/audit | Every manual action, with actor_kind and actor_token_id |
GET /v1/account/tokens | |
DELETE /v1/account/tokens/:id | Revoke, including the token making the call |
Minting a token is not here on purpose. A credential that can mint its own successors cannot be revoked — revoke it and the one it made keeps working — so that step stays with a person in the dashboard.
An agent token is full access, and nothing here is reversible
Chromia Pay holds no keys, so it cannot undo any of this. API keys a token creates keep working after
the token is revoked. A payout address it sets redirects every later payment, and it can set one without
the ownership signature the dashboard asks for. Dashboard access it grants also survives revocation.
Revoking stops it acting; GET /v1/account/audit is how you find what to unwind by hand. Tokens expire
after at most 90 days.
Payout addresses without proof
POST /v1/account/wallets requires unverified: true and refuses the call without it, because an agent
cannot produce an ownership signature. The address is then recorded as asserted rather than proved, and
GET /v1/account/wallets reports which it was.
That path exists because the proof is impossible for the address merchants most often want: you hold no
key for an exchange deposit address, and you cannot send from it either, so both personal_sign and a
self-transfer are unavailable. The address shape is still validated — skipping the proof is a decision
about trust, while accepting a malformed address is a bug.
It costs one thing, and it is the same fact stated from the other side: an asserted address cannot
refund. Sending money back needs a key, so the dashboard marks such an address receive only and
declines to offer the button. Nothing about receiving is affected. If the address is a wallet you do
hold, proving it with a signature turns refunds on for it.
On the native Chromia rail, prefer an address you control — and it must require transfer memos.
A payer's Chromia transfer carries Chromia Pay's own cpay:<REF> memo, and on that rail the memo is
the only thing that says which order was paid. FT4 memos are decided by the receiving account, and
they are never merely optional: until you run enable_transfer_memo a memo cannot be attached at all,
and afterwards a transfer without one is refused by the chain. Rather than take money it cannot
attribute, the checkout withholds the Chromia option entirely until the payout account requires
memos; your other chains are unaffected. Turn it on once, from the wallet that controls the account,
in Settings → Wallets → Transfer memos.
Know the trade before you do. Once memos are required, any sender that cannot attach ours can no longer pay you in CHR — an exchange withdrawal is the common case, since it overwrites the memo with its own. Those transfers are refused on chain and the sender keeps their funds, which is the better outcome: the alternative is money arriving with nothing to identify it, and no keys here to return it with.
The same memo is why an exchange address is a poor payout address here: an exchange that needs its own memo to credit your deposit gets ours instead, and the funds are stranded inside the exchange. Exchange addresses are fine on BNB Smart Chain and Base, which carry no memo at all.
What this store can be paid in
GET /v1/assets — with a secret key, because the caller is an application server building a payment
form.
{
"object": "list",
"data": [
{ "object": "asset", "id": "chr", "label": "CHR", "chains": ["chromia", "bsc"] },
{ "object": "asset", "id": "usdc", "label": "USDC", "chains": ["bsc"] }
],
"livemode": false
}id is what goes in settlement_asset, label is what to print for a customer, and chains are the
rails a payment in it can be made on, in the order the checkout will offer them.
Ask this instead of shipping a list. Which assets a store accepts is the merchant's own setting, so
a copy in your app is one that goes stale silently: the merchant switches USDT off, your picker keeps
offering it, and the first thing anybody notices is a 400 at creation — after the customer chose it.
That is not a hypothetical, it is what the first integration built against this did.
The answer is exactly what POST /v1/payments will accept: the store's selection intersected with what
this gateway offers, and narrowed to assets with a verified payout wallet on a chain that carries them.
Reporting the merchant's switches without the wallet check would trade one wrong list for another. An
empty data means this store cannot be paid at all yet — say so rather than opening a checkout.
Cache it for a minute or so if you render it often; it changes when a merchant changes a setting.
Creating a payment
| Field | Notes | |
|---|---|---|
amount | required | Decimal string, up to 6 decimals. "12.50", not 12.5 |
currency | optional | "chr" (default) or "usd". How the order is priced. USD locks a CHR quote at creation, held for the payment's lifetime |
settlement_asset | optional | "chr" (default), "usdc" or "usdt". What actually moves on chain. Refused with 400 if the store has switched the asset off, or has no verified payout wallet on a chain carrying it — the message says which |
success_url | required | Where the customer goes after paying. https in live. Also the only origin allowed to embed this payment |
cancel_url | optional | Where the "back to the store" exits point |
description | optional | Shown to the customer. Up to 500 characters |
metadata | optional | Any JSON object, returned on every event. Put your order id here |
expires_in | optional | Seconds until the link dies. 60–86400, default 1800 |
Headers: Idempotency-Key makes the create safely retryable — the same key returns the original
payment, and the same key with a different amount — or a different settlement_asset — is refused
with 409.
Two refusals here are about the store rather than the request, so retrying the same body will not fix
either. 403 means the store is paused or closed and is taking no new payments; the message says
which. 400 on settlement_asset can mean the store has switched that asset off in
Settings → Assets you accept, which reads differently from no rail carrying it and is fixed in a
different place. Both are worth surfacing to whoever runs the store rather than retrying — nobody
watching a queue can tell them apart from an outage.
Neither affects a payment that already exists. A payment created before a store paused stays payable, its transfer is still detected and credited, and its webhooks still fire — pausing stops the inflow, not the work already in motion.
The payment object
{
"id": "pi_01JQ...",
"object": "payment",
"status": "pending",
"livemode": false,
"currency": "CHR",
"settlement_asset": "chr",
"amount": "12.500000",
"price": null,
"price_currency": null,
"rate": null,
"rate_source": null,
"amount_expected": "12.500431",
"amount_paid": "0.000000",
"amount_pending": "12.500431",
"reference": "K7QP2M9XTB4C",
"chain": "chromia",
"description": "Order #1041 — two plushies",
"metadata": { "order_id": "1041" },
"deposits": [
{
"chain": "chromia",
"tx_id": "9f2c...",
"event_index": 0,
"amount": "12.500431",
"payer": "A82160...",
"status": "seen",
"memo": "cpay:K7QP2M9XTB4C"
}
],
"checkout_url": "https://pay-checkout.chromia.com/c/pay/ct_...",
"expires_at": 1786712345,
"created_at": 1786710545,
"confirmed_at": null
}amount is what you asked for. amount_expected is what the payer is told to send: the same figure,
plus attribution dust when another open payment in the same environment already claims that exact
total. Usually the two are equal; when they are not, amount_expected is the one that settles the
payment. price, price_currency, rate and rate_source are set only for a payment created with
currency: "usd", and the rate is the one locked at creation.
Statuses
created → pending → one of confirmed, overpaid, underpaid, expired, canceled. pending
means funds are on chain but not final. Terminal states do not change afterwards, with one exception:
an underpaid payment can be moved to confirmed or overpaid by an audited manual attach from the
dashboard, which is how a memo-less exchange send gets matched to its order.
Deposits
Every transfer we attributed to the payment. payer is the FT4 account id or the 0x address the
money came from — who to contact about a wrong payment. status is seen (on chain, not final),
confirmed, orphaned (reorged away, EVM only), or simulated.
Rates
GET /v1/rates/chr_usd is the rate a USD-priced payment would be quoted at right now, so a cart page
can show "≈ 1323 CHR" before anything is created. Pairs are chr_usd, usdc_usd and usdt_usd —
GET /v1/rates lists them. Anything else is a 404.
| Query | Notes | |
|---|---|---|
usd | optional | Dollars to convert into the base asset. Decimal string, at most 2 decimals |
base | optional | Amount of the base asset to value in dollars. At most 6 decimals |
Pass one or neither — both is a 400. With neither, quote comes back null and you get the rate alone.
{
"object": "rate",
"pair": "chr/usd",
"base": "chr",
"rate": "0.0151",
"rate_micro_usd": 15100,
"source": "binance:CHRUSDT",
"as_of": 1787508014,
"age_ms": 0,
"max_age_ms": 300000,
"quote": { "usd": "19.99", "base": "1323.850000" }
}rate is USD per 1 unit of the base asset, in the same format payment.rate carries. source is the
venue it came from, as_of when it was read, and max_age_ms the age past which this gateway refuses
to quote with a rate at all — a reading older than that is a dead feed, not a price.
usdc_usd and usdt_usd return "source": "peg:usdc" and a rate of 1. That is the same 1:1
assumption a USD-priced stablecoin payment already makes; it is not a market reading, and if a peg
breaks this endpoint will not be the thing that tells you.
Reading a rate does not reserve it
Only creating a payment locks a rate. quote uses the same feed and the same rounding as
POST /v1/payments with currency: "usd", so a create in the same moment agrees with it — but the next
reading differs, and the customer pays what the payment says. Show this figure as approximate.
503 if no rate can be obtained, the same as a USD-priced create: no price is ever invented.
History
GET /v1/rates/chr_usd/history returns what the feed said, newest first. The watcher samples every five
minutes and keeps a year, so nothing before this gateway started recording exists, and a gap means the
feed was unreachable then — no point is interpolated.
| Query | Notes | |
|---|---|---|
from | optional | Unix seconds. Defaults to 24 hours before to |
to | optional | Unix seconds. Defaults to now |
limit | optional | Points to return, default 500, clamped to 1000 rather than refused |
{
"object": "list",
"pair": "chr/usd",
"base": "chr",
"from": 1787421614,
"to": 1787508014,
"data": [{ "at": 1787507900, "rate": "0.0151", "rate_micro_usd": 15100, "source": "binance:CHRUSDT" }]
}A stablecoin pair is a 400: a peg is an assumption, not an observation, so there is no series to read.
Without a key, from a browser
GET /rates, GET /rates/:pair and GET /rates/:pair/history are the same three routes,
unauthenticated, any origin, and limited per IP — plus a cap on the public surface as a whole, since a
caller reaching the origin directly can write its own X-Forwarded-For. A cart page showing an approximate
CHR figure is browser code with no business holding a secret key, and the number is one the venues we
read publish to anyone.
const { quote } = await fetch("https://pay-api.chromia.com/rates/chr_usd?usd=19.99").then((r) => r.json())
// quote.base — the CHR to show next to the buttonResponses carry cache-control, so a CDN or the browser itself answers most of this traffic. Everything
else about the payload is identical to the authenticated form.
Refunds
There is no route here to trigger one, and there will not be: a refund moves money out of the merchant's own wallet and needs a signature only a person holding that key can give. So refunds start in the dashboard — open a payment, or the Reconcile queue, and press Refund — and are signed in the merchant's wallet on a page the gateway prepares.
What an integration sees of it is one event, payment.refunded, described in
Webhooks. The payment object itself does not change: a refunded payment is still
confirmed, and amount_paid still reports what arrived, because a refund is a transfer in the other
direction rather than a reversal of the first one. Anything you need to net off, net off from the
refund event.
Errors
{ "error": { "type": "invalid_request", "message": "amount must have at most 6 decimals", "param": "amount" } }| Code | Means | Retry? |
|---|---|---|
400 | Your request. param says which field | No, not unchanged |
401 | Key missing, wrong, revoked — or an agent token past its expiry | No |
403 | Live key on a test-only route, a secret key on /v1/account, or the store is paused or closed | No |
404 | No such id, or not yours | No |
409 | Conflicts with something already true — cancelling a final payment, reusing an idempotency key with a new amount | No |
429 | Rate limited | Yes, with backoff |
503 | No CHR/USD rate available for a USD-priced payment | Yes, with backoff |
Repo-layer scoping means a 404 on someone else's payment id, never a 403 — the id of a payment that
is not yours is indistinguishable from an id that does not exist.