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 |
|---|---|
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 |
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.
On the native Chromia rail, prefer an address you control. A payer's transfer carries Chromia Pay's
own cpay:<REF> memo, so 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.
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 this store has no verified payout wallet on a chain carrying it |
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.
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.
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, or a secret key on /v1/account | 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.