Payment Webhook Handlers That Survive Retries, Duplicates and Forgeries
A webhook handler that works in a demo can lose money in production. Six rules for handling payment notifications, with code, and how to test each one without a tunnel.
contents (8)
- A handler that works in a demo
- Rule 1: verify the exact bytes, and fail closed
- Rule 2: a notice you have seen before changes nothing
- Rule 3: check the amount and currency against the order
- Rule 4: store facts, derive the status
- Rule 5: never answer 500 to input anyone can send
- Rule 6: test what the provider will really send
- The checklist
A payment webhook is a request from your payment provider saying “this charge succeeded” or “this refund went through”. Your server reads it, finds the order and updates it. It is one of the first integrations most product teams build, and one of the easiest to get subtly wrong.
The examples here use Paystack and Flutterwave, but the problems are the same with Stripe or any other provider. They come from building a test shop for paylocal, an open-source tool I wrote for sending signed webhooks to a local server.
A handler that works in a demo
Here is the kind of handler that passes a quick manual test:
const expected = createHmac("sha512", secretKey).update(raw).digest("hex");const sent = req.headers["x-paystack-signature"] as string;if (!timingSafeEqual(Buffer.from(sent), Buffer.from(expected))) return reply(401);
const payload = JSON.parse(raw);const order = orders.get(payload.data.reference);if (payload.event === "charge.success") { order.status = "paid"; order.timesShipped += 1;}if (payload.event === "refund.processed") order.status = "refunded";return reply(200);It checks the signature and uses a constant-time comparison. It looks careful. It has four bugs that lose money:
- A request with no signature crashes it. A missing header is
undefined, and bothBuffer.from(undefined)andtimingSafeEqualon buffers of different lengths throw. The handler answers 500 instead of 401. Worse, a real event that hits the same fault gets retried by the provider over and over. - A repeated notice ships the goods twice. Providers deliver at least once, so the same
charge.successcan arrive twice. - It believes the amount in the notice without checking it against the order.
- The last notice wins. Notices can arrive out of order, so a late “paid” can undo a refund that was already processed.
Rule 1: verify the exact bytes, and fail closed
Paystack signs the raw request body with HMAC-SHA512 using your secret key. The check has to run on the body exactly as it arrived, before anything parses it. Parse the JSON first and re-serialise it, and the bytes can change, so a genuine signature stops matching.
import { createHmac, timingSafeEqual } from "node:crypto";
/** Constant-time comparison that copes with a missing or short value without throwing. */function same(a: string, b: string): boolean { const x = Buffer.from(a); const y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y);}
export function fromPaystack(rawBody: string, headers: Headers, secretKey: string): boolean { const sent = header(headers, "x-paystack-signature"); if (sent === "") return false; const expected = createHmac("sha512", secretKey).update(rawBody).digest("hex"); return same(sent, expected);}The length check before timingSafeEqual is the difference between a clean 401 and a crash.
In Express this usually means reading the webhook route with express.raw() instead of express.json(), so you have the original bytes.
Flutterwave v3 works differently: it does not sign the body. It sends back a secret hash you set in its dashboard, in a verif-hash header. That proves the sender knows the hash, but says nothing about whether the body was changed. So for Flutterwave, also confirm the payment with its verify-transaction API before you trust it.
Rule 2: a notice you have seen before changes nothing
Record every transaction ID you process. If it is already recorded, acknowledge the notice and do nothing else:
const added = shop.recordPayment(order, { provider, transactionId, amount });if (!added) { return ok("already recorded"); // 200, so the provider stops resending it}In a database, this is a unique constraint on the provider’s transaction ID, and an insert that does nothing on conflict. The constraint is what makes it safe when two copies arrive at the same moment, not just one after the other.
Rule 3: check the amount and currency against the order
Never mark an order paid because a notice says “success”. Check that the amount and currency match what the order expects:
if (currency !== order.currency) { shop.recordProblem(order, `Payment in ${currency}, order is in ${order.currency}`); return ok("currency does not match the order, not marked paid");}if (amount !== order.total) { shop.recordProblem(order, `Paid ${amount}, order total is ${order.total}`); return ok("amount does not match the order, not marked paid");}Watch the units. Paystack amounts are in kobo, the smallest unit, while Flutterwave amounts are in naira. Mixing them up is a factor-of-100 bug.
Rule 4: store facts, derive the status
This is the rule that fixes out-of-order delivery, and it is the one most handlers miss.
Instead of each notice overwriting a status field, each notice adds a fact: a payment, a refund, a dispute. The status is calculated from all the facts every time:
status(order: Order): Status { if (order.disputes.some((d) => d.open)) return "On hold"; const paid = this.paid(order); // sum of recorded payments if (paid < order.total) return "Awaiting payment"; const refunded = this.refunded(order); // sum of recorded refunds if (refunded >= paid) return "Refunded"; if (refunded > 0) return "Part refunded"; return "Paid";}Now the order of arrival does not matter. A refund that arrives before its payment, a payment that arrives twice, a late “paid” after a refund: the same set of facts always gives the same status. Duplicates and reordering stop being special cases.
Rule 5: never answer 500 to input anyone can send
A 500 tells the provider “try again later”. That is right for a real outage, like your database being down. It is wrong for a forged request, a malformed body or an event you do not handle, because those will never succeed.
So: 401 for a bad signature, 400 for a body that is not valid JSON, and 200 for anything valid that you have no use for. Acknowledge it, and the provider stops resending.
Rule 6: test what the provider will really send
You cannot test any of this with a happy-path test that sends one perfect event. You need forged requests, duplicates and events in the wrong order.
paylocal’s verify command sends one valid event and three forgeries (no signature, the wrong secret, and a body changed after signing) and checks your endpoint accepts the first and refuses the rest with a 4xx:
npx @omoyolab/paylocal verify paystack --to http://localhost:3000/webhooks/paystackIt exits with code 1 if the handler accepts a forgery or crashes, so it works as one line in CI. Against the demo handler above, the request with no signature produces a 500, and verify fails it.
For duplicates and ordering, scenario sends a linked run of notices about one transaction, with the same reference throughout:
# a payment, then refund pending, processing and processedpaylocal scenario paystack refund --to $URL --reference ORD-1042 --reverse # last to firstpaylocal scenario paystack payment --to $URL --reference ORD-1042 --twice # each notice twiceThe same fixtures work in a test suite, so you do not have to hand-roll HMACs:
import { scenario } from "@omoyolab/paylocal";
test("a refund that arrives before the payment still ends as refunded", async () => { const [charge, , , processed] = scenario("paystack", "refund", { reference: "ORD-1042" });
await processed.send(url, secret); await charge.send(url, secret);
expect(await orders.get("ORD-1042")).toMatchObject({ status: "refunded" });});The checklist
- Verify the signature on the raw bytes, and return 401, not 500, when it is missing or wrong.
- Make every notice idempotent, with a unique constraint on the provider’s transaction ID.
- Check the amount and currency against the order before marking it paid.
- Store facts and derive the status, so order of arrival does not matter.
- Return 4xx for bad input and 200 for events you ignore. Keep 5xx for real outages.
- Test with forgeries, duplicates and reordered events, not just one happy request.
None of these rules is complicated. Together they are the difference between a handler that works when you demo it and one that holds up when real payments arrive.