Build your own checkout
Our hosted page is co-branded and takes about five minutes. This page is for when you want the checkout page to be entirely yours: your domain, your markup, no ChainOS chrome, with ChainOS as the settlement engine behind it.
Everything here works the same whether you take payments for yourself or for sub-merchants.
1. What ChainOS gives you
When you create a session you get back a complete, authoritative description of what the payer must do. These fields are the contract:
| Field | What it is |
|---|---|
address | Where to pay. Render it in full, never truncated. |
paymentUri | A chain-specific URI for your QR code, built server-side |
amount | A decimal string in the asset's own units, exactly as it must be paid |
asset, assetContract, decimals | Snapshotted at creation and never re-resolved |
expiresAt | When the checkout stops accepting |
serverTime | Our clock, so your countdown does not depend on the payer's device |
confirmations, confirmationsRequired | Progress toward settlement |
status | The same vocabulary we use internally |
reference | The public pay_… id, safe to show |
2. What you build
The page, the QR image, the countdown, the copy button, and the copy for each terminal state. Nothing else.
3. Which credential to call from where
This is the part people get wrong, and it is worth being exact.
/v1/payments/** takes a secret key (pmk_… or your flk_…). Your server only.
A secret key in a browser is a full payments credential for every merchant you have.
/v1/checkout/** takes a publishable key (ppk_…). It is origin-locked and
amount-capped and is the only plane a browser may touch.
Origin locking and maxAmount bound the upside only. In publishable-key mode the
browser asserts the amount, so a buyer can request 0.01 against a 100.00 order and the
webhook will truthfully report it paid in full. That is why server-created sessions are
the documented default, and why the confirmation webhook carries both requestedAmount
and amountPaid.
4. The rules that lose money if you break them
- Never fulfil on
detected. Only onconfirmed. A detected payment can still be reorganised out of existence. - Never fulfil from a client callback.
onSuccessruns in your own JavaScript and its argument can be synthesised from a console. No signature fixes this — whatever key the browser holds, an attacker holds too. Fulfil from the webhook, or from a read with your secret key. - Always compare
amountPaidagainst your own order total, server-side. Yours is the only place the real total exists. - Never build the payment URI client-side. Use the
paymentUriwe return. EIP-681 carries smallest units and Solana Pay carries decimal ones, and an EIP-681 token URI targets the contract with the recipient as a parameter. Invert that and you send a payer's ETH to the USDT contract. - Never render an address the client supplied. It comes from the session.
expiredis not the end. Handleexpired_paid: funds sent to the address are still credited untilmonitorUntil, weeks later. Tell the payer so — see verifying payments.
5. The records to keep
Two tables. The shape matters more than the dialect.
-- Your merchants, joined to ours by the reference YOU chose, never by name.
CREATE TABLE merchants (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
chainos_merchant_id UUID UNIQUE NOT NULL, -- returned when you provisioned them
external_ref TEXT UNIQUE NOT NULL -- what you sent us as externalRef
);
-- One row per checkout. This is YOUR idempotency boundary, not ours.
CREATE TABLE checkouts (
id BIGSERIAL PRIMARY KEY,
order_id TEXT NOT NULL UNIQUE,
merchant_id BIGINT NOT NULL REFERENCES merchants(id),
chainos_reference TEXT NOT NULL UNIQUE, -- pay_…
amount_expected NUMERIC(78,0) NOT NULL, -- YOUR total, in smallest units
asset TEXT NOT NULL,
chain TEXT NOT NULL,
decimals SMALLINT NOT NULL,
status TEXT NOT NULL,
fulfilled_at TIMESTAMPTZ, -- set once, ever
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Two lines are load-bearing.
chainos_reference … UNIQUE is what lets a redelivered webhook find the row it already
wrote rather than creating a second one.
fulfilled_at set once is what stops you shipping twice. Webhooks are at-least-once
by contract: a retry ladder that fulfils on every delivery ships goods on every delivery.
6. The processing logic
import { createHmac, timingSafeEqual } from 'node:crypto';
/** Create the session on YOUR server, with YOUR amount. */
export async function startCheckout(order: Order) {
const res = await fetch(`${CHAINOS}/v1/payments/sessions`, {
method: 'POST',
headers: {
'X-API-Key': process.env.CHAINOS_SECRET_KEY!, // pmk_… — never in a browser
'Content-Type': 'application/json',
'Idempotency-Key': order.id, // retrying must not burn a second address
},
body: JSON.stringify({
chain: order.chain,
asset: order.asset,
amount: order.total, // a decimal string, never a float
reference: order.id,
merchantId: order.chainosMerchantId, // omit if you sell for yourself
}),
});
const { data } = await res.json();
await db.query(
`INSERT INTO checkouts (order_id, merchant_id, chainos_reference,
amount_expected, asset, chain, decimals, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,'created')
ON CONFLICT (order_id) DO NOTHING`,
[order.id, order.merchantId, data.reference, order.totalSmallestUnits,
data.asset, data.chain, data.decimals],
);
// Render your page from these. Do not reformat `amount`, and do not rebuild the URI.
return { address: data.address, amount: data.amount, paymentUri: data.paymentUri,
expiresAt: data.expiresAt, serverTime: data.serverTime };
}
Verifying the webhook
Sign over the raw bytes, not a re-serialised body. Re-encoding JSON changes key order and whitespace, and the signature will not match — this is the single most common webhook bug in any product.
import express from 'express';
const app = express();
// express.raw, NOT express.json. The signature is over exactly what arrived.
app.post('/webhooks/chainos', express.raw({ type: '*/*' }), async (req, res) => {
const signature = req.header('X-ChainOS-Signature') ?? '';
const expected = createHmac('sha256', process.env.CHAINOS_WEBHOOK_SECRET!)
.update(req.body) // a Buffer, byte for byte
.digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
await handle(event);
// 2xx quickly. Do the slow work after, or the retry ladder fires while you are
// still succeeding.
res.status(204).end();
});
Fulfilling, exactly once
async function handle(event: ChainOsEvent) {
// Rule 1: only confirmed money ships anything.
if (event.type !== 'payment_confirmed') {
await db.query(
`UPDATE checkouts SET status = $2, updated_at = now() WHERE chainos_reference = $1`,
[event.data.reference, event.data.status],
);
return;
}
const { rows } = await db.query(
`SELECT id, amount_expected, fulfilled_at FROM checkouts WHERE chainos_reference = $1`,
[event.data.reference],
);
const checkout = rows[0];
if (!checkout) return; // not ours; ignore rather than guess
// Rule 3: compare against YOUR total. Never against anything in the payload.
const paid = BigInt(event.data.amountPaidSmallest);
if (paid < BigInt(checkout.amount_expected)) {
await flagUnderpaid(checkout.id, paid);
return;
}
// Rule 2 + the idempotency boundary. The WHERE clause is what makes a redelivered
// webhook a no-op instead of a second shipment.
const claimed = await db.query(
`UPDATE checkouts SET fulfilled_at = now(), status = 'confirmed', updated_at = now()
WHERE id = $1 AND fulfilled_at IS NULL
RETURNING id`,
[checkout.id],
);
if (claimed.rowCount === 0) return; // somebody already shipped it
await shipOrder(checkout.id);
}
7. The countdown
The one piece of UI that is wrong almost everywhere.
Compute the skew once from serverTime, then render (expiresAt + skew) - now()
recomputed from the wall clock on every tick — never decremented, because a
decrementing counter stops when a mobile tab is backgrounded and resumes minutes behind.
Re-poll on visibilitychange and online.
Never declare expiry client-side. At zero, show "Checking…" and ask the server. A fast device clock must not cancel a live payment.
Stop the countdown the instant a payment is detected. Watching a clock run out on money already sent is a support ticket every time.
8. Attribution
Our hosted page carries a small "Powered by ChainOS" mark. On a page you build and host yourself there is no such requirement, and nothing in the API asks for one.
Testing it
sandbox.simulateDeposit drives a session to confirmed with no chain call, so you can
exercise the whole path above — including the redelivery case — before any real money
moves. See testing.
Run your webhook handler against it and then send the same delivery twice. If the
second one ships a second order, the fulfilled_at guard is not where you think it is.