Hosted checkout
Create a payment server-side and redirect the customer to it. The default, and the least code.
Install
bun add @chromia-pay/nodeZero 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
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 sendIf 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 = nullA 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 webhookYou 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.