Verifying payments
The shortest page in these docs and the only one that can cost you money. Everything here is about one question: when is it safe to ship?
The rule
Ship when your server has verified a
payment_confirmedwebhook and comparedamountPaidagainst the order total in your own database.
Nothing else is proof. Not the browser. Not a redirect. Not onSuccess.
onSuccess is not proof
The React component takes an onSuccess callback, and it is genuinely useful — for
showing a spinner, for navigating to a thank-you page, for stopping the customer clicking
Pay again.
It is not evidence of payment, and it cannot be made into evidence:
<CryptoPayment
apiKey={pk}
amount="49.99"
chain="base"
onSuccess={(hint) => {
// hint.unverified is `true`, and its type says so.
router.push('/thanks'); // ✅ what this callback is for
// fulfilOrder(hint); // ❌ never
}}
/>
onSuccess runs in your own page's JavaScript. Its argument can be synthesised from a
browser console in about four seconds. No signature fixes this: whatever key the browser
holds, so does the person reading the page source.
That is why the payload's type is PaymentSucceededHint and carries
readonly unverified: true — your editor shows the warning at the call site, where it is
still cheap to act on.
If you supply onSuccess and your account has no payment_* webhook configured, the SDK
logs one console warning in development. It costs a correct integration nothing and
catches the wrong one at the moment it can still be fixed.
Verify the webhook
Payment webhooks are ordinary ChainOS webhooks: X-Webhook-Signature, hex HMAC-SHA256
over the raw request body, verified against the bytes you received and never against a
re-serialised object. The full treatment, with code in four languages, is in
Webhooks → Verifying the signature.
Subscribe to at least payment_confirmed. The events are:
| Event | Fired when |
|---|---|
payment_created | A session exists and its address is derived |
payment_detected | A deposit is on chain, below the confirmation threshold |
payment_underpaid | Less arrived than was asked for. Not a failure — the difference is still owed |
payment_confirmed | The one to act on. Confirmed on chain at the required depth |
payment_completed | Invoiced and swept. Terminal |
payment_overpaid | More arrived than was asked for. surplus says how much |
payment_wrong_asset | A different token landed on the address. Recorded, not counted |
payment_expired | The countdown ran out. This does not mean the money is gone |
payment_late | Funds arrived after the countdown, and were credited |
payment_cancelled | Cancelled before anything arrived |
payment_reorged | A confirmed payment was undone by a chain reorganisation |
payment_link_created, payment_link_closed | Link lifecycle |
checkout_unavailable | Sessions are being refused on a chain. An operational alert, not a payment |
Compare amountPaid against your own total
payment_confirmed carries both figures, deliberately:
{
"event": "payment_confirmed",
"timestamp": "2026-09-15T14:22:07Z",
"data": {
"reference": "pay_7YQ2M4KDX",
"sessionId": "0b1f…",
"txid": "0x9c…",
"asset": "USDT",
"amountPaid": "49.99",
"amountExpected": "49.99",
"requestedAmount": null,
"amountWasClientAsserted": false,
"createdVia": "api",
"merchantReference": "order_44182",
"confirmations": 12
}
}
const { reference, amountPaid, asset, merchantReference } = evt.data;
const order = await orders.findByReference(merchantReference);
// Your database is the authority on what this order costs. Nothing that arrived
// over the wire is, including amountExpected.
if (asset !== order.asset || decimalCompare(amountPaid, order.total) < 0) {
return flagForReview(order, evt);
}
await fulfil(order);
amountWasClientAsserted is trueThe session was created by a browser holding a publishable key, which means the browser chose the amount. Origin locking and the per-session cap bound the upside; they do not stop a customer opening devtools and asking for 0.01 on a 100.00 order.
requestedAmount is what the browser asked for. amountPaid is what arrived. Neither is
what the order costs — only your database knows that. Compare against your own total or
you will ship goods for a cent.
Server-created sessions do not have this property, which is why they are the default throughout these docs.
Late payments are real payments
payment_expired means the countdown ran out, not that the money is gone.
Expiry and archival are two separate deadlines. expiresAt closes the checkout;
monitorUntil — thirty days later by default — is when we stop watching the address.
Anything that lands in between is credited, the session moves to expired_paid, and you
get payment_late.
This is not an edge case. A payer who broadcasts at 14:59:50 against a 15:00:00 deadline has sent real, irreversible money, and every chain has a mempool.
So:
- Do not release stock, cancel the order or refund on
payment_expired. Mark it unpaid and leave it recoverable. - Do handle
payment_late. It carries the samereferenceandmerchantReference, so it joins to the same order.
The expired checkout page tells the customer the same thing, in one sentence we keep in one place so the page, the webhook and this paragraph cannot drift apart:
Funds sent to this address are still credited. Quote the reference below to the merchant.
Partial payments
payment_underpaid is a state, not a failure. A sending exchange commonly deducts its
withdrawal fee from the amount rather than adding it, so a payer who typed the right
number arrives a little short through no fault of their own.
Two things to decide up front:
underpaymentToleranceBpson session creation — how far short a payment may arrive and still count. A few basis points covers the exchange-fee case.- What you do with the remainder. The address stays open and accepting until
expiresAt, so a payer can top up.amountRemainingon the event is what they still owe.
Wrong asset
payment_wrong_asset means a different token landed on the address — USDC where the
session asked for USDT, or the chain's native asset where a token was expected.
It is recorded and deliberately not counted. Counting it would need a price, and there is no rate oracle on this path by design. The funds are on an address only your Edge can spend from; recover them with a directed sweep from the Treasury screen.
The portal shows these rows on the payment's detail screen, struck through, beside the deposits that did count. It is the only place a wrong-asset deposit is visible at all.
Reorgs
completed is terminal, even on a reorg. A state machine that un-completes is one that
un-ships.
A reorg after confirmation sets reorgFlaggedAt, fires payment_reorged and leaves the
decision to you, because only you know whether the goods have left the building.
ChainOS does not currently detect a reorg on its own for payments — the ledger publishes
no reorg event, and asking a chain directly belongs behind a module the payments module
deliberately cannot see. payment_reorged fires where a reorg is observed through the
ordinary ingest path. Do not treat its absence as proof a confirmed payment is final at a
depth below your chain's threshold; that is what the threshold is for.
A checklist
- Configure a
payment_confirmedwebhook before you embed anything. Whatever is in your quickstart is what reaches production. - Verify the HMAC over raw bytes.
- Join on
merchantReference— your own order id, which you set at session creation. - Compare
amountPaidandassetagainst your own database. Always. - Treat
payment_expiredas unpaid-and-recoverable, never as cancelled. - Handle
payment_late. - Use
onSuccessfor the user interface and nothing else.