Obiora Igboanusi
Back to writing
June 12, 20262 min read

Idempotent Webhooks: How I Stopped Double-Charging Customers Before It Ever Happened

  • Payments
  • Node.js
  • Webhooks
  • PostgreSQL

Payment providers make one promise you can absolutely rely on: they will send you the same webhook more than once. Retries on timeouts, at-least-once delivery guarantees, manual replays from a dashboard — duplication isn't an edge case, it's the contract.

If your handler isn't idempotent, every duplicate is a potential double-charge, a double-provisioned subscription, or a corrupted ledger. When I integrated Lemon Squeezy billing for a SaaS platform with 8,000+ users, this was the first problem I designed for.

Step 1: Verify the signature before anything else

Never process a webhook you can't authenticate. Lemon Squeezy signs payloads with HMAC-SHA256:

import crypto from "node:crypto";

function verifySignature(rawBody: string, signature: string, secret: string) {
  const digest = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(digest, "hex"),
    Buffer.from(signature, "hex")
  );
}

Two details people get wrong:

  1. Use the raw request body, not the parsed JSON. Re-serializing the body changes whitespace and key order, and the signature won't match.
  2. Use timingSafeEqual, not ===. String comparison leaks timing information an attacker can exploit.

Step 2: Record the event ID inside a transaction

Every webhook event has a unique ID. Store it with a unique constraint, and let the database — not application logic — decide whether you've seen it before:

CREATE TABLE webhook_events (
  id            TEXT PRIMARY KEY,   -- provider's event ID
  event_type    TEXT NOT NULL,
  processed_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
await db.transaction(async (tx) => {
  const inserted = await tx.query(
    `INSERT INTO webhook_events (id, event_type)
     VALUES ($1, $2)
     ON CONFLICT (id) DO NOTHING
     RETURNING id`,
    [event.id, event.type]
  );

  if (inserted.rowCount === 0) {
    return; // duplicate — already processed, exit quietly
  }

  await applyBusinessLogic(tx, event);
});

The critical property: the event-ID insert and the business logic commit atomically. If processing fails halfway, the transaction rolls back, the event ID is never recorded, and the provider's retry gets a clean second attempt.

Step 3: Return 200 fast, defer heavy work

Webhook endpoints should acknowledge quickly — providers time out slow handlers and retry, which creates more duplicates. Anything heavy goes to a BullMQ queue:

app.post("/webhooks/billing", async (req, res) => {
  if (!verifySignature(req.rawBody, req.headers["x-signature"], secret)) {
    return res.status(401).end();
  }

  await billingQueue.add("process-event", req.body, {
    jobId: req.body.meta.event_id, // BullMQ dedupes on jobId too
  });

  res.status(200).end();
});

Note the jobId — BullMQ ignores jobs with an ID it has already seen, giving you a second layer of deduplication at the queue level.

The payoff

With this pattern in production, duplicate webhooks became a non-event. The logs show them arriving; the ledger shows them ignored. No double charges, no support tickets, no 2 a.m. incident calls.

Idempotency isn't extra polish on a billing system — it is the billing system.