22 Sept 2026 · 2 min read
Idempotent payment webhooks: fulfil an order exactly once
Payment gateways retry. Your fulfilment code must not. A signature check and one atomic state change are enough to make webhooks safe.
When I built an instant-delivery storefront, the scary part was never the checkout page. It was the webhook: the request the payment gateway sends after money moves. Gateways retry on timeouts, deliver out of order and occasionally send the same event twice. If your handler hands out a voucher every time it runs, a retry becomes a free product.
The fix is two small ideas: verify who is calling, then make the state change atomic.
1. Verify the signature before you trust anything
Read the raw body (not the parsed JSON, since re-serialising changes bytes) and compare an HMAC in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(req: Request) {
const raw = await req.text();
const given = Buffer.from(req.headers.get("x-signature") ?? "", "hex");
const expected = createHmac("sha256", process.env.WEBHOOK_SECRET!).update(raw).digest();
if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
return new Response("Unauthorized", { status: 401 });
}
const event = JSON.parse(raw);
// …handle event
return new Response("ok");
}timingSafeEqual throws when lengths differ, which is why the length check comes first.
2. Turn "paid" into a one-way door
Don't read the order, check its status, then write. Two retries arriving together will both read pending. Instead make the database do the check and the write as one operation:
const order = await orders.findOneAndUpdate(
{ paymentId: event.id, status: "pending" },
{ $set: { status: "paid", paidAt: new Date() } },
{ returnDocument: "after" },
);
if (!order) {
// Already processed (or unknown). Acknowledge so the gateway stops retrying.
return new Response("ok");
}
await fulfil(order); // runs at most once per paymentOnly one concurrent request can flip pending to paid; every other gets null. That single conditional update is your idempotency key.
3. Always answer 200 for duplicates
Returning an error for a duplicate makes the gateway retry harder. A duplicate is not a failure, it is a success you have already seen.
4. Do the slow work after the state change
Delivery (a Telegram message, an email, a key from stock) can fail. Because the order is already marked paid, store the delivery as its own step with a retry flag instead of re-running the whole handler:
- mark
paidatomically - enqueue or attempt delivery
- record
deliveredAtwhen it succeeds - let a small job retry anything
paidbut not delivered
The checklist
- Verify the signature on the raw body
- Make the status change a single conditional write
- Return success for duplicates
- Separate "money received" from "goods delivered"
Get those four right and retries stop being scary.