Dhaka--:--
All notes

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.

  • Payments
  • Next.js
  • MongoDB

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 payment

Only 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 paid atomically
  • enqueue or attempt delivery
  • record deliveredAt when it succeeds
  • let a small job retry anything paid but 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.