Skip to main content

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_confirmed webhook and compared amountPaid against 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:

EventFired when
payment_createdA session exists and its address is derived
payment_detectedA deposit is on chain, below the confirmation threshold
payment_underpaidLess arrived than was asked for. Not a failure — the difference is still owed
payment_confirmedThe one to act on. Confirmed on chain at the required depth
payment_completedInvoiced and swept. Terminal
payment_overpaidMore arrived than was asked for. surplus says how much
payment_wrong_assetA different token landed on the address. Recorded, not counted
payment_expiredThe countdown ran out. This does not mean the money is gone
payment_lateFunds arrived after the countdown, and were credited
payment_cancelledCancelled before anything arrived
payment_reorgedA confirmed payment was undone by a chain reorganisation
payment_link_created, payment_link_closedLink lifecycle
checkout_unavailableSessions 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);
When amountWasClientAsserted is true

The 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 same reference and merchantReference, 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:

  • underpaymentToleranceBps on 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. amountRemaining on 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.

What is not built

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

  1. Configure a payment_confirmed webhook before you embed anything. Whatever is in your quickstart is what reaches production.
  2. Verify the HMAC over raw bytes.
  3. Join on merchantReference — your own order id, which you set at session creation.
  4. Compare amountPaid and asset against your own database. Always.
  5. Treat payment_expired as unpaid-and-recoverable, never as cancelled.
  6. Handle payment_late.
  7. Use onSuccess for the user interface and nothing else.