---
name: chromia-pay
description: Integrate Chromia Pay — accept CHR, USDC or USDT on Chromia and BNB Smart Chain with a hosted or embedded checkout and signed webhooks. Use when adding crypto payments, choosing a settlement asset, wiring a webhook consumer, debugging a payment that did not confirm, or taking an integration live.
---

# Integrating Chromia Pay

Chromia Pay is a non-custodial crypto payment gateway. The customer's funds go straight from their
wallet to the merchant's own wallet; the gateway holds no keys and never takes custody. Its job is to
watch the chain and tell the merchant's server, over a signed webhook, when money has arrived and is
final.

It settles in **CHR, USDC or USDT**. CHR pays on Chromia's economy chain or as a BEP-20 on BNB Smart
Chain; the stablecoins pay on BNB Smart Chain. **Base is sandbox-only** — Base Sepolia carries CHR and
USDC for testing, but live checkouts are never offered Base, because Base mainnet has no CHR bridge.
Do not build a live integration around a Base rail. One payment settles in exactly one asset, chosen by
the merchant when the payment is created.

This skill is the whole integration, in the order it should be done. Read the invariants first — most
integration bugs are one of them being violated.

## Invariants

1. **Fulfil from the webhook, never from the browser.** The redirect back to `success_url` is a
   courtesy. A customer can close the tab, and anything a browser can tell your server, an attacker
   can also tell your server. `payment.confirmed` and `payment.overpaid` are the only two events that
   mean paid.
2. **Verify the signature on every delivery, over the raw request body.** An unverified webhook is an
   unauthenticated stranger claiming a customer paid. A body that has been JSON-parsed and
   re-serialized will not match the signature — mount the route before any body parser.
3. **De-duplicate on `event.id`.** A delivery whose 2xx we did not see is retried. Fulfilment must be
   idempotent.
4. **Amounts are decimal strings, never floats.** `"12.50"`, not `12.5`. Six decimals maximum, for
   every asset — that is the API's unit, not the token's. USDT on BNB Smart Chain is an 18-decimal
   contract, and the gateway does that scaling; you never do.
5. **A secret key never reaches a browser.** Payments are created server-side. The embedded checkout
   script takes a URL, not a key.
6. **Environments do not mix.** A test key cannot move real CHR, a sandbox webhook endpoint never
   receives a live payment, and a live payout wallet is a different row from a sandbox one.
7. **Trust the status, not your own arithmetic on the amounts.** A store may set an underpayment
   tolerance, after which a payment confirms with `amount_paid` slightly *below* `amount` — that is
   the merchant forgiving an exchange fee on purpose. Code that ships only when
   `amount_paid === amount` will silently refuse those payments.

## Prerequisites, and how to check them

An integration cannot work without all three of these, per environment. They are set up in the
dashboard, not through the API.

| Thing | Where | Symptom if missing |
| --- | --- | --- |
| Verified payout wallet | Settings → Payout wallets | Checkout renders "this store has not finished setting up crypto payments"; a rail with no proven address is not offered |
| API key (`cpay_sk_test_…` / `cpay_sk_live_…`) | Developers → New key | `401` on every API call |
| Webhook endpoint + signing secret | Developers → Add endpoint | Payments confirm and are recorded, but nothing is ever told, so nothing ships |

Confirm the key works before writing any integration code:

```bash
curl -s -o /dev/null -w '%{http_code}\n' \
  -H "authorization: Bearer $CPAY_SECRET_KEY" \
  "$CPAY_API_URL/v1/webhook_endpoints"
# 200 = key is good. 401 = wrong or revoked key.
```

## Environment variables to set up

```
CPAY_SECRET_KEY=cpay_sk_test_...      # server-side only, never in a client bundle
CPAY_WEBHOOK_SECRET=whsec_...         # per endpoint, shown once at creation
CPAY_API_URL=https://pay-api.chromia.com
```

If the project has a `.env.example` or equivalent, add all three there too, with placeholder values.
Never commit real values. Check whether the project's `.gitignore` actually covers the env file
before writing secrets into it — a tracked `.env` is how live keys leak.

## Step 1 — install

```bash
npm install @chromia-pay/node     # or bun add / pnpm add
```

Zero dependencies: global `fetch` plus `node:crypto`. If the project cannot take the dependency, every
call in this skill is one HTTP request and can be made with `fetch` directly.

## Step 2 — create a payment server-side

```ts
import { ChromiaPay } from "@chromia-pay/node"

const cpay = new ChromiaPay(process.env.CPAY_SECRET_KEY!, {
  baseUrl: process.env.CPAY_API_URL,
})

const payment = await cpay.payments.create({
  amount: "12.50",                       // decimal string
  description: "Order #1041",            // shown to the customer
  metadata: { order_id: "1041" },        // returned on every webhook — put your own id here
  successUrl: "https://shop.example.com/orders/1041/thanks",
  cancelUrl: "https://shop.example.com/cart",
  expiresIn: 1800,                       // seconds, 60–86400, default 1800
  idempotencyKey: "order-1041",          // makes the create safely retryable
})
// payment.checkout_url — where the customer pays. Single-use, unguessable: treat it as a credential.
```

To price in fiat, pass `currency: "usd"` with a USD `amount`. The CHR figure is quoted once at
creation and that rate is held for the payment's lifetime. If no rate can be obtained the request
fails with `503` rather than inventing one — do not retry it in a tight loop.

To settle in a stablecoin, pass `settlement_asset: "usdc"` or `"usdt"` (default `"chr"`). It is a
different question from `currency`: one says how the order is **priced**, the other what **moves on
chain**. A USD-priced stablecoin payment needs no conversion at all — no rate is quoted, `rate` comes
back null, and the expiry stops doubling as a rate lock, so it can be as long as your fulfilment
window needs. Creating a payment in an asset the store has no verified payout wallet for is refused
with `400` at create, rather than discovered by the customer after the redirect.

**Letting the buyer choose an asset is a cart-page decision, not a checkout one.** Put two buttons on
your own cart, send a different `settlement_asset`, and key `Idempotency-Key` on the **order id
alone**. Never `order_id:asset` — that leaves two payable links alive for one order, the buyer can
pay both, and neither can be refunded because this gateway holds no keys. Keying on the order id
makes the second create fail with `409`, which is the outcome you want.

Store `payment.id` against your order **now**, before redirecting. The webhook arrives with
`metadata`, but an order row that knows its payment id is what makes reconciliation possible later.

## Step 3 — take the customer to the checkout

Pick one. Both produce identical payments and identical webhooks.

### Hosted (default)

```ts
return Response.redirect(payment.checkout_url, 303)
```

### Embedded (frame over the store's own page)

```html
<script src="https://pay-checkout.chromia.com/embed.js"></script>
<script>
  // /api/checkout is your own route: it calls payments.create server-side and returns { checkout_url }.
  const { checkout_url } = await (await fetch('/api/checkout', { method: 'POST' })).json()

  ChromiaPay.open({
    url: checkout_url,
    onStatus: (status) => {/* progress UI only */},
    onPaid:   () => location.assign('/orders/1041/thanks'),
    onClose:  (reason) => {/* "checkout" | "escape" | "backdrop" | "api" */},
  })
</script>
```

**The embedded constraint that catches everyone:** the page doing the embedding must be on the *same
origin* as that payment's `success_url`. `https://shop.example.com` embedding a payment whose
`success_url` is on `https://www.shop.example.com` is refused, and the frame shows a screen naming
both origins. If a store is spread across hostnames, set `success_url` on whichever origin runs the
checkout button. Plain `http` is accepted only on `localhost` / `127.0.0.1`, so the flow is testable
before deployment.

`onPaid` is for the spinner, not the shipment. Invariant 1 still applies.

## Step 4 — the webhook consumer

This is the part that must be correct. Everything else is plumbing.

```ts
import express from "express"
import { ChromiaPay, isPaid } from "@chromia-pay/node"

const cpay = new ChromiaPay(process.env.CPAY_SECRET_KEY!)
const app = express()

// express.raw, NOT express.json — the signature covers the exact bytes.
app.post("/webhooks/chromia-pay", express.raw({ type: "*/*" }), async (req, res) => {
  let event
  try {
    event = cpay.webhooks.constructEvent(
      req.body,                                   // raw Buffer
      req.header("chromia-pay-signature"),
      process.env.CPAY_WEBHOOK_SECRET!,
    )
  } catch {
    return res.status(400).end()                  // bad signature or replayed timestamp — not ours
  }

  if (await alreadyHandled(event.id)) return res.status(200).end()   // invariant 3

  if (isPaid(event.data.payment)) {
    await fulfil(event.data.payment.metadata.order_id)               // confirmed or overpaid
  }

  res.status(200).end()                           // 2xx promptly; slow work after, or a retry piles up
})
```

Framework notes:

- **Next.js route handler / Hono / Fetch API**: use `await req.text()` and pass that string. Do not
  `await req.json()` and re-stringify.
- **Fastify**: register a raw-body content parser for the webhook route only.
- **Not using the SDK**: the header is `Chromia-Pay-Signature: t=<unix>,v1=<hex>`. Compute
  `HMAC-SHA256(secret, "<t>.<rawBody>")`, compare in constant time (`crypto.timingSafeEqual`), and
  reject a `t` more than 300 seconds from now.

### Choosing which events to receive

An endpoint subscribes to all events by default, including types added in future. Narrow it from the
**Events** button beside the endpoint in the dashboard, or:

```ts
await cpay.webhookEndpoints.update(endpointId, {
  enabledEvents: ["payment.confirmed", "payment.overpaid"],
})
```

| Event | Meaning | Ship the order? |
| --- | --- | --- |
| `payment.pending` | Funds seen on chain, not yet final | No |
| `payment.confirmed` | Paid in full, final | **Yes** |
| `payment.overpaid` | Paid more than due, final | **Yes** — the excess is in the merchant's wallet |
| `payment.underpaid` | Expired holding part of the money | No — needs a human |
| `payment.expired` | Link ran out, nothing arrived | No |
| `payment.canceled` | Called off before payment | No |

Subscribing to only `payment.confirmed` and forgetting `payment.overpaid` is a real and common bug: a
customer who rounds up their transfer never gets their order.

## Step 5 — prove it works, without spending anything

With a **test** key, force every outcome. Do not stop at `confirmed`; the failure paths are where
consumers break.

```bash
for OUTCOME in confirmed partial overpaid underpaid expired; do
  # create a payment, then:
  curl -s -X POST "$CPAY_API_URL/v1/test/payments/$PAYMENT_ID/simulate_payment" \
    -H "authorization: Bearer $CPAY_SECRET_KEY" \
    -H "content-type: application/json" \
    -d "{\"outcome\":\"$OUTCOME\",\"chain\":\"chromia\"}"
done
```

`chain` is `chromia` or `bsc`, it must be one that carries the payment's `settlement_asset` — a USDC
payment cannot be simulated on Chromia, because the economy chain carries no stablecoin — and the
environment needs a verified test wallet on it. Simulation is refused outright for live keys.

`partial` is the one worth knowing about: it pays some of the total and leaves the payment open, which
is the only way to reach the top-up path. `underpaid` will not get you there — that verdict is only
reached at expiry, so it lands on a terminal payment with nothing left to top up. `confirmed` settles
whatever is still outstanding rather than the whole price again, so `partial` then `confirmed` is a
part payment followed by the rest, and confirms instead of reading as an overpayment.

Then verify, in this order:

1. The consumer received each event and returned 2xx.
2. A delivery with a **mangled signature** is rejected with 4xx and does **not** fulfil. Flip one
   character of the `v1=` hex and re-send it.
3. **Replaying** a valid delivery fulfils exactly once.
4. `GET /v1/payments/:id` agrees with what the consumer recorded.
5. The order is *not* fulfilled on `pending`, `underpaid`, `expired` or `canceled`.

For a local endpoint, expose it with a tunnel (`cloudflared tunnel --url http://localhost:3000`) and
register that URL; plain `http` endpoints are accepted in a sandbox for exactly this.

## API reference

Base `https://pay-api.chromia.com`. `Authorization: Bearer <secret key>` on everything.

| 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 already final |
| `POST /v1/webhook_endpoints` | Register. Signing secret in the response, once. Optional `enabled_events` |
| `GET /v1/webhook_endpoints` | List for this key's environment |
| `POST /v1/webhook_endpoints/:id` | Change `enabled_events`; secret unchanged |
| `DELETE /v1/webhook_endpoints/:id` | Stop delivering |
| `GET /v1/events/:id` | Re-read the canonical event instead of trusting a body |
| `POST /v1/test/payments/:id/simulate_payment` | Test keys only |

Create-payment fields: `amount` (required, decimal string), `currency` (`chr` default / `usd`),
`success_url` (required, https in live), `cancel_url`, `description` (≤500 chars), `metadata` (any
JSON object), `expires_in` (60–86400).

On the payment object: `amount` is what was asked for; `amount_expected` is what the payer is told to
send. They are equal unless another open payment in the same environment already claims that exact
total, in which case the newer one gains a few minor units of *attribution dust* so the two can be
told apart on a rail that carries no memo. Treat `amount_expected` as the figure that settles the
payment, and never assume it equals `amount`.

Errors return `{ error: { type, message, param? } }`. `400` your request, `401` the key, `404` the id,
`409` a conflict with something already true, `429` rate limit, `503` no rate available for a
USD-priced payment. Retry `429` and `503` with backoff; do not retry the others unchanged.

## Diagnosing a payment that did not confirm

Work down this list; it is ordered by how often each one is the answer.

1. **No verified payout wallet on that rail.** Checkout could not offer it. Dashboard → Settings →
   Payout wallets.
2. **Endpoint not subscribed to the event.** Check `enabled_events` on the endpoint —
   `payment.overpaid` missing is the classic.
3. **Consumer returned non-2xx**, so the delivery is being retried and will eventually disable the
   endpoint. Dashboard → Developers → Deliveries has every attempt at every endpoint, with its status
   code and duration, and the exact body posted. Read the attempts before guessing: repeated 5xx is the
   handler, repeated timeouts at the same duration is the handler doing work before it responds, and
   "no response" is DNS, TLS, or a URL we refused to call.
4. **Signature verification failing** because the body was parsed before verification. Symptom: every
   delivery rejected, the secret is correct, and the raw bytes were never seen.
5. **The wrong environment.** A live payment against a sandbox endpoint, or a test key expecting live
   traffic. Check `livemode` on the event.
6. **The customer underpaid.** Status is `underpaid`, the money is in the merchant's wallet, and the
   payer is identified on the deposit. This resolves in the dashboard's Reconcile page, not in code.
7. **The link expired** before the customer finished. Status `expired`. Create a new payment; do not
   reuse the old checkout URL.

## MCP tools

If the `chromia-pay` MCP server is connected, these are available. With a secret key (`cpay_sk_…`) they
hit the same public API with the same key, so nothing here can do what your own code could not:

- `create_payment` — create one and get its `checkout_url`
- `get_payment` — read status and deposits
- `cancel_payment` — cancel an unpaid one
- `list_webhook_endpoints`, `create_webhook_endpoint`, `set_webhook_events`, `delete_webhook_endpoint`
- `simulate_payment` — force `confirmed` / `overpaid` / `underpaid` / `expired` (test keys only)
- `get_event` — re-read a canonical event

Use a **test** key. An agent iterating on an integration will create payments, and those should be
payments that cannot move real CHR.

### If the credential starts `cpay_at_`

That is an **agent token**, not a secret key, and it carries every action a dashboard admin can take.
The tools above still work, plus: `whoami`, `list_api_keys`, `create_api_key`, `revoke_api_key`,
`list_payout_wallets`, `set_payout_wallet`, `retire_payout_wallet`, `get_store_preferences`,
`set_store_preferences`, `list_team`, `grant_team_access`, `revoke_team_access`, `list_environments`,
`create_sandbox_environment`, `list_unattributed_deposits`, `attach_deposit_to_payment`,
`read_audit_log`, `list_agent_tokens`, `revoke_agent_token`.

Call `whoami` first. It says which merchant, environment and mode you are acting in and whose behalf you
are acting on, and nothing can be pointed anywhere else.

**Chromia Pay is non-custodial and holds no keys, so it cannot reverse anything you do with this token.**
Before using any of these, know which ones are one-way:

- `set_payout_wallet` is where a stranger's money will arrive. A wrong address sends customer funds
  somewhere nobody can recover them from. Read the address back to the user character by character and
  get an explicit yes. It requires `unverified: true`, because you cannot produce an ownership signature —
  that flag is you stating what you are doing, not a formality to fill in.
- `create_api_key` returns a secret once, and that key **keeps working after this token is revoked**.
  Say so when you use it. Ask before minting one on a live environment.
- `grant_team_access` also survives this token's revocation. A typo grants a stranger your user's store.
- `attach_deposit_to_payment` decides whose money a payment was, and settles it — which makes the
  merchant fulfil an order. Never infer the pairing from matching amounts: two orders wanting the same
  figure is exactly why deposits land in that queue. Put the payer, the amount and the transaction in
  front of the user and have them choose.
- `revoke_agent_token` accepts your own token id. Use it when a task is done rather than leaving a
  full-access credential live until it expires.

Every action you take is written to the audit log against the person who minted the token, marked as an
agent. `read_audit_log` is the only way to find out what an agent changed, so mention it when something
needs unwinding.

On the native Chromia rail, do not set an exchange deposit address as a payout wallet: the payer's
transfer carries Chromia Pay's `cpay:<REF>` memo, so an exchange needing its own memo credits nobody and
the funds are stranded. Exchange addresses are fine on BNB Smart Chain and Base.

## Do not

- Do not fulfil from `success_url`, from `onPaid`, or from any browser-supplied status.
- Do not re-serialize the webhook body before verifying it.
- Do not put a secret key, or a signing secret, in client code or a public repo.
- Do not treat `payment.pending` as paid; it is explicitly not final.
- Do not poll `GET /v1/payments/:id` in a loop in place of a webhook. It is rate limited, and it will
  not tell you about a payment you have forgotten to look at.
- Do not hard-code `amount_expected` anywhere; the dust component differs per payment.
- Do not assume a sandbox and live environment share wallets, keys or endpoints. They share nothing.
- Do not set a payout wallet, mint a live API key, or grant dashboard access without asking the person
  first. None of the three is reversible, and revoking the token you did it with does not undo any of it.
