Webhooks
Verify the signature, de-duplicate on event.id, and fulfil on confirmed or overpaid. This is the part that has to be right.
Every delivery carries a Chromia-Pay-Signature header:
Chromia-Pay-Signature: t=1786710545,v1=1f8b3c…v1 is HMAC-SHA256(secret, "<t>.<raw body>") in hex, keyed with that endpoint's signing secret.
Verify it before you do anything else.
A consumer that is actually correct
import express from "express"
import { ChromiaPay, isPaid } from "@chromia-pay/node"
const cpay = new ChromiaPay(process.env.CPAY_SECRET_KEY!)
const app = express()
// express.raw, NOT express.json — the signature covers the exact bytes we sent.
app.post("/webhooks/chromia-pay", express.raw({ type: "*/*" }), async (req, res) => {
let event
try {
event = cpay.webhooks.constructEvent(
req.body, // raw Buffer
req.header("chromia-pay-signature"),
process.env.CPAY_WEBHOOK_SECRET!,
)
} catch {
// Bad signature, or a timestamp outside the 5-minute window. Not from us; do not act on it.
return res.status(400).end()
}
// Deliveries can repeat. Skip anything already applied, and keep fulfilment idempotent.
if (await alreadyHandled(event.id)) return res.status(200).end()
if (isPaid(event.data.payment)) {
await fulfil(event.data.payment.metadata.order_id) // confirmed or overpaid
}
// 2xx promptly, then do the slow work. A timeout here becomes a retry.
res.status(200).end()
})Two details that bite
The body must be the raw bytes
A body that has been JSON-parsed and re-serialized will not match the signature — key order and
whitespace both change it. Mount this route before any body parser. In a Next.js route handler or
anything on the Fetch API, use await req.text() and pass that string; never await req.json()
followed by a re-stringify.
Deliveries repeat
A delivery whose 2xx we did not see is retried on a backoff. De-duplicate on event.id and make
fulfilment idempotent. An endpoint that keeps failing is eventually disabled. Dashboard → Developers →
Deliveries lists every event we tried to send, and opening one shows each attempt with its status code
and how long the call took, plus the exact body we posted.
Verifying without the SDK
import { createHmac, timingSafeEqual } from "node:crypto"
function verify(rawBody: string, header: string, secret: string): boolean {
const parts = new Map(header.split(",").map((p) => p.split("=") as [string, string]))
const t = Number(parts.get("t"))
const given = parts.get("v1") ?? ""
// Reject a stale timestamp first: this is what stops a captured delivery being replayed later.
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")
// Constant time: a plain === leaks how much of the signature was right.
return given.length === expected.length &&
timingSafeEqual(Buffer.from(given), Buffer.from(expected))
}Events
| Event | Meaning | Ship the order? |
|---|---|---|
payment.pending | Funds seen on chain, not final yet | No |
payment.confirmed | Paid in full, final | Yes |
payment.overpaid | Paid more than due, final | Yes |
payment.underpaid | Expired holding part of the money | No — needs a human |
payment.expired | The link ran out and nothing arrived | No |
payment.canceled | Called off before it was paid | No |
Subscribing to confirmed and forgetting overpaid
This is a real and common bug. A customer who rounds their transfer up never gets their order, and
nothing in your logs looks like an error. Handle both, or use isPaid(payment).
Choosing which events an endpoint receives
An endpoint receives everything by default, including event types added in future. Narrow it from the Events button beside the endpoint in the dashboard, or through the API:
await cpay.webhookEndpoints.update(endpointId, {
enabledEvents: ["payment.confirmed", "payment.overpaid"],
})Changing the subscription does not change the signing secret, so nothing needs redeploying. Passing
null goes back to receiving everything.
Underpaid is a person's job
The payment expired holding part of the money — usually an exchange withdrawal that deducted its own fee. The funds are in your wallet and the payer is identified on the deposit, so it is resolvable; it is just not resolvable by code. The dashboard's Reconcile page is where that happens, and it can attach a deposit to the payment it was meant for.