Chromia Pay

Hosted checkout

Create a payment server-side and redirect the customer to it. The default, and the least code.

Install

bun add @chromia-pay/node

Zero dependencies — global fetch plus node:crypto. If you would rather not take the dependency, every call here is one HTTP request; see the API reference.

Create the payment

app/api/checkout/route.ts
import { ChromiaPay } from "@chromia-pay/node"

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

const payment = await cpay.payments.create({
  amount: "12.50",                       // decimal string, up to 6 decimals
  description: "Order #1041 — two plushies",
  metadata: { order_id: "1041" },        // comes back on every webhook
  successUrl: "https://shop.example.com/orders/1041/thanks",
  cancelUrl: "https://shop.example.com/cart",
  idempotencyKey: "order-1041",          // safe to retry
})

// Send the customer there. Do not mark the order paid yet.
return Response.redirect(payment.checkout_url, 303)

Store payment.id against your order before redirecting. The webhook arrives carrying your metadata, but an order row that knows its payment id is what makes reconciliation possible when something goes sideways months later.

checkout_url is a credential

It is single-use and unguessable, and it is the only thing standing between a stranger and that payment page. Do not log it, and do not put it anywhere a third party can read it back.

Pricing 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 — so the number the customer agreed to is the number they pay.

const payment = await cpay.payments.create({
  amount: "19.99",
  currency: "usd",
  successUrl: "https://shop.example.com/thanks",
})
// payment.price = "19.99", payment.price_currency = "usd",
// payment.rate = the locked CHR/USD rate, payment.amount = the CHR to send

If no rate can be obtained the request fails with 503 rather than inventing one. Do not retry that in a tight loop — it means an upstream price source is down, and selling goods at a made-up rate is worse than not selling them for a minute.

Taking stablecoins

settlement_asset decides what actually moves on chain: "chr" (the default), "usdc" or "usdt". It is separate from currency, which decides how the order is priced — and the useful combination is both at once:

const payment = await cpay.payments.create({
  amount: "19.99",
  currency: "usd",          // priced in dollars
  settlementAsset: "usdc",  // paid in dollars
  successUrl: "https://shop.example.com/thanks",
})
// payment.amount = "19.990000", payment.rate = null

A dollar price paid in a dollar stablecoin needs no exchange rate, so none is quoted and rate stays null. That also changes what the expiry means. On a CHR payment the expiry is the rate lock, which is why it has to be short; on a stablecoin it means only what it says, and you can set it as long as your fulfilment window needs.

Stablecoins settle on BNB Smart Chain. Chromia's economy chain carries no stablecoin, so a usdc or usdt payment is never offered the native rail — and a store with no verified payout wallet on BSC is refused at create with 400 rather than sending its customer to a page with nothing to pay with.

Letting the buyer choose

One payment settles in exactly one asset. That does not mean you choose for your buyers — it means the choice happens one step earlier, on your own cart page:

[ Pay with CHR ]   [ Pay with USDT ]     ← two buttons on your cart
        |                  |
        +--------+---------+
                 v
   POST /v1/payments { settlement_asset, idempotency_key: order_id }
                 v
          redirect to checkout_url        ← one payment, one asset, one webhook

You already have a cart UI and already call the API from a click, so this costs one extra parameter.

Key on the order id alone — never order_id:asset. Creating one payment per asset leaves two payable links alive for the same goods. The buyer can pay both, or pay one while your fulfilment watches the other, and neither can be refunded — this gateway holds no keys. Keying on the order id makes the second create fail with 409, which is the outcome you want.

Idempotency

Send an Idempotency-Key on creation. A retry with the same key returns the original payment instead of creating a second one. A retry with the same key but a different amount is refused with 409 — which is the failure you want, because the alternative is two payment links for one order.

On this page