Chromia Pay

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

RoutePurpose
GET /v1/assetsWhat this store can be paid in. Ask this before drawing an asset picker
POST /v1/paymentsCreate. Send Idempotency-Key. Returns the payment with checkout_url
GET /v1/payments/:idRead one back, with its deposits
POST /v1/payments/:id/cancelCancel an unpaid one. 409 if it is already final
POST /v1/webhook_endpointsRegister. Signing secret in the response, once. Optional enabled_events
GET /v1/webhook_endpointsList them for this key's environment
POST /v1/webhook_endpoints/:idChange enabled_events; signing secret untouched
DELETE /v1/webhook_endpoints/:idStop delivering
GET /v1/events/:idRe-read a canonical event rather than trusting a body you were handed
GET /v1/ratesWhich pairs are quoted, and which are the peg rather than a feed
GET /v1/rates/:pairThe rate now, and optionally a conversion. ?usd=19.99 or ?base=100
GET /v1/rates/:pair/historyRecorded points for a chart. ?from=&to=&limit=
POST /v1/test/payments/:id/simulate_paymentTest 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.

RoutePurpose
GET /v1/accountWhich merchant, environment and mode, and whose behalf
GET /v1/account/api_keysPrefixes only. Secrets exist once, in the response that made them
POST /v1/account/api_keysMint one. { "name": "…" }. Secret returned once
DELETE /v1/account/api_keys/:idRevoke
GET /v1/account/walletsPayout addresses, and how each was verified
POST /v1/account/walletsSet one. { "chain", "address", "unverified": true }
DELETE /v1/account/wallets/:chainRetire, and stop offering that chain
GET/POST /v1/account/preferencesUnderpayment 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/environmentsCreate a sandbox. { "name": "…" }
GET /v1/account/unattributedThe reconciliation queue
POST /v1/account/unattributed/attach{ "deposit_id", "payment_id" }. Settles the payment
GET /v1/account/auditEvery manual action, with actor_kind and actor_token_id
GET /v1/account/tokens
DELETE /v1/account/tokens/:idRevoke, 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

FieldNotes
amountrequiredDecimal string, up to 6 decimals. "12.50", not 12.5
currencyoptional"chr" (default) or "usd". How the order is priced. USD locks a CHR quote at creation, held for the payment's lifetime
settlement_assetoptional"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_urlrequiredWhere the customer goes after paying. https in live. Also the only origin allowed to embed this payment
cancel_urloptionalWhere the "back to the store" exits point
descriptionoptionalShown to the customer. Up to 500 characters
metadataoptionalAny JSON object, returned on every event. Put your order id here
expires_inoptionalSeconds 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.

QueryNotes
usdoptionalDollars to convert into the base asset. Decimal string, at most 2 decimals
baseoptionalAmount 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.

QueryNotes
fromoptionalUnix seconds. Defaults to 24 hours before to
tooptionalUnix seconds. Defaults to now
limitoptionalPoints 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 button

Responses 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" } }
CodeMeansRetry?
400Your request. param says which fieldNo, not unchanged
401Key missing, wrong, revoked — or an agent token past its expiryNo
403Live key on a test-only route, a secret key on /v1/account, or the store is paused or closedNo
404No such id, or not yoursNo
409Conflicts with something already true — cancelling a final payment, reusing an idempotency key with a new amountNo
429Rate limitedYes, with backoff
503No CHR/USD rate available for a USD-priced paymentYes, 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.

On this page