Chromia Pay

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

webhooks.ts
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

EventMeaningShip the order?
payment.pendingFunds seen on chain, not final yetNo
payment.confirmedPaid in full, finalYes
payment.overpaidPaid more than due, finalYes
payment.underpaidExpired holding part of the moneyNo — needs a human
payment.expiredThe link ran out and nothing arrivedNo
payment.canceledCalled off before it was paidNo

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.

On this page