Consume webhooks
A production-grade webhook handler — verify, dedupe, enqueue, 200.
A production webhook handler has four responsibilities, in order:
- Verify the signature before reading anything. See Verify signatures.
- Dedupe using
X-Webhook-ID. - Enqueue the work — do not run it inline.
- Return 2xx quickly so the platform records a successful delivery.
Sample handler — Node + Express#
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
const SECRET = process.env.CRYPTO_MERCHANT_WEBHOOK_SECRET;
const seen = new Set(); // replace with Redis SETNX / SQL unique in production
app.post(
"/webhooks/crypto-merchant",
express.raw({ type: "application/json" }),
(req, res) => {
// 1. verify
const sig = req.header("X-Webhook-Signature") ?? "";
const [algo, signature] = sig.split("=");
if (algo !== "sha256" || !signature) {
return res.status(400).send("bad signature header");
}
const expected = createHmac("sha256", SECRET).update(req.body).digest("hex");
const a = Buffer.from(signature, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).send("signature mismatch");
}
// 2. dedupe
const id = req.header("X-Webhook-ID");
if (!id) return res.status(400).send("missing X-Webhook-ID");
if (seen.has(id)) return res.status(200).send("ok (duplicate)");
seen.add(id);
// 3. enqueue
const payload = JSON.parse(req.body.toString("utf8"));
enqueueJob("crypto-merchant:webhook", payload);
// 4. 200
res.status(200).send("ok");
},
);
function enqueueJob(name, payload) {
/* Sidekiq / BullMQ / Celery / ... */
}
Why enqueue?#
Your webhook handler lives in the hot path of the platform's dispatch. If you do anything slow — DB writes to many rows, an outbound email, sending a fulfilment request to a warehouse — you will:
- Consume the platform's dispatch connection for seconds.
- Increase the chance of an HTTP-level timeout that marks the delivery
failed. - Force an operator to retry deliveries that would otherwise have been fine.
Acknowledge fast, do the work async, use your own retry loop for your own downstream.
What each event should do#
invoice.created— usually a no-op on your side. Sometimes useful to mark the order row as "awaiting payment".invoice.pending/invoice.confirming— update the order's status UI; do not fulfil.invoice.confirmed— fulfil the order.invoice.overpaid— fulfil; record the surplus for manual refund.invoice.underpaid— do not fulfil by default; contact the buyer.invoice.expired— cancel the order.invoice.late_payment— exception path; reconcile manually.
Common pitfalls#
- Using
express.json()before verifying — you lose the raw body and the signature check can never pass. Always parse the body as raw bytes for the webhook route. - Trusting event types without verifying — the headers are unauthenticated until signature verification succeeds.
- Fulfilling on
invoice.confirming— the confirmation count is still below target. Wait forconfirmed. - Ignoring
overpaid— treating it asconfirmedworks for fulfilment but leaves you blind to the surplus.