DEV Community

Geminate Solutions
Geminate Solutions

Posted on

Idempotent Webhooks in Node.js and Postgres: No Double Processing

A webhook handler is idempotent when processing the same event twice has the same effect as processing it once. In Node.js and Postgres you need three pieces. First, verify the signature on the raw body. Second, record each event ID under a unique constraint. Third, apply the side effects inside the same transaction as that record.

Providers like Stripe deliver at least once, not exactly once. A timeout on your side, a deploy in the middle of a request, or a network problem on theirs means the same event arrives again. Sometimes it comes minutes later. Sometimes it arrives in parallel with the first copy. Events also arrive out of order: customer.subscription.updated can land before the customer.subscription.created that came before it.

If your handler grants credits, sends emails or changes account status, both problems become real bugs. Users get double credits or duplicate receipts, and a cancelled user can get reactivated.

Here is a build that holds up.

The schema

You need two tables. One is the dedupe ledger. The other holds the state you actually care about.

CREATE TABLE webhook_events (
  event_id     text PRIMARY KEY,
  event_type   text NOT NULL,
  received_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE subscriptions (
  id                   text PRIMARY KEY,
  customer_id          text NOT NULL,
  status               text NOT NULL,
  provider_updated_at  bigint NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

The primary key on event_id does the dedupe work. Do not replace it with a "check if it exists, then insert" query in application code. Two concurrent deliveries can both pass the check before either one inserts. Postgres enforces the constraint itself, so there is no race.

provider_updated_at stores the provider's event timestamp (Stripe sends created as Unix seconds). Step 3 uses it to reject stale events.

Step 1: Verify the signature on the raw body

The signature is a hash of the exact bytes the provider sent. If express.json() parses the body first, those bytes are gone and verification fails. Mount the raw parser on the webhook route only, before any global JSON parser.

const express = require('express');
const Stripe = require('stripe');
const knex = require('knex')({ client: 'pg', connection: process.env.DATABASE_URL });

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();

app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  handleStripeWebhook
);

app.use(express.json()); // every other route
Enter fullscreen mode Exit fullscreen mode

Then verify:

function verify(req) {
  const signature = req.headers['stripe-signature'];
  return stripe.webhooks.constructEvent(
    req.body, // a Buffer, because of express.raw
    signature,
    process.env.STRIPE_WEBHOOK_SECRET
  );
}
Enter fullscreen mode Exit fullscreen mode

constructEvent also checks the timestamp in the signature header against a tolerance window (five minutes by default). This stops an attacker from replaying an old request they captured. Other providers use the same pattern: an HMAC over the raw body plus a timestamp, compared with a constant-time function such as crypto.timingSafeEqual.

Step 2: Claim the event and process it in one transaction

This is the core of the build.

async function handleStripeWebhook(req, res) {
  let event;
  try {
    event = verify(req);
  } catch (err) {
    return res.status(400).send('Invalid signature');
  }

  try {
    await knex.transaction(async (trx) => {
      const claimed = await trx('webhook_events')
        .insert({ event_id: event.id, event_type: event.type })
        .onConflict('event_id')
        .ignore()
        .returning('event_id');

      if (claimed.length === 0) return; // already processed

      await applyEvent(trx, event);
    });
    res.status(200).send('ok');
  } catch (err) {
    console.error('webhook failed', event.id, err);
    res.status(500).send('retry');
  }
}
Enter fullscreen mode Exit fullscreen mode

Here is what happens in each failure case:

  • A duplicate arrives after success. The insert hits the conflict and returns zero rows. The handler returns 200 without touching anything.
  • The handler crashes halfway. The transaction rolls back, including the webhook_events row. The handler returns 500, the provider retries, and the next attempt starts clean.
  • Two copies arrive at the same moment. The second insert waits on the unique index until the first transaction finishes. If the first commits, the second sees a conflict and skips. If the first rolls back, the second claims the event and processes it. You don't have to write any locks yourself.

That last case is why the ledger insert and the business writes must share one transaction. Suppose you record the event in one transaction and process it in another. A crash between the two leaves the event marked as done even though it never ran.

Step 3: Handle out-of-order events

Deduplication does not fix ordering. Two different events, each delivered once, can still arrive in the wrong sequence. The fix is to apply each state change only if it is newer than what you already have.

async function applyEvent(trx, event) {
  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated':
    case 'customer.subscription.deleted': {
      const sub = event.data.object;
      await trx('subscriptions')
        .insert({
          id: sub.id,
          customer_id: sub.customer,
          status: sub.status,
          provider_updated_at: event.created,
        })
        .onConflict('id')
        .merge()
        .where('subscriptions.provider_updated_at', '<', event.created);
      break;
    }
    default:
      // Unknown types are recorded and acknowledged, not retried forever.
      break;
  }
}
Enter fullscreen mode Exit fullscreen mode

The same upsert in plain SQL:

INSERT INTO subscriptions (id, customer_id, status, provider_updated_at)
VALUES ('sub_123', 'cus_456', 'canceled', 1760000000)
ON CONFLICT (id) DO UPDATE
SET status = EXCLUDED.status,
    customer_id = EXCLUDED.customer_id,
    provider_updated_at = EXCLUDED.provider_updated_at
WHERE subscriptions.provider_updated_at < EXCLUDED.provider_updated_at;
Enter fullscreen mode Exit fullscreen mode

When an older event finds a newer row, it updates nothing.

There is one limitation. Stripe's created field only has one-second precision. If two events share the same second, they tie and the second one is dropped. When that matters, treat the event as a signal only and fetch the current object inside the handler with stripe.subscriptions.retrieve(sub.id). The API always returns the latest state, so order no longer matters. The cost is an extra network call per event and more exposure to API rate limits.

A simple decision rule:

  • Snapshot state (subscription status, plan, seat count): use the timestamp guard or fetch fresh state from the API.
  • Additive effects (credits granted per paid invoice): rely on the event ID dedupe. Also key the effect on the invoice ID with its own unique constraint, so a second event about the same invoice cannot grant credits twice.

Step 4: Keep slow and external work out of the transaction

Postgres can't roll back anything that happens outside the database. Say you send a welcome email inside the transaction and the commit then fails. The retry sends a second email.

Use an outbox instead. Inside the same transaction, write a row such as ('send_receipt', invoice_id) to an outbox table that has a unique constraint on that pair. A separate worker reads the outbox, makes the external call, and marks the row done. The webhook stays fast, and each external effect happens once per committed event.

Speed matters here. Providers give each delivery a short timeout and retry when the handler doesn't respond in time. A handler that calls three APIs inline will time out under load and create the very duplicates you are defending against.

Test it before production

  • Run stripe listen --forward-to localhost:3000/webhooks/stripe, then stripe trigger invoice.paid.
  • Resend the same event with stripe events resend evt_123 and confirm nothing changes the second time.
  • Send the same payload twice in parallel (a small script with Promise.all is enough). Confirm you get one row in webhook_events and one side effect.
  • Throw an error inside applyEvent. After the 500, confirm the event row is absent so the retry can succeed.
  • Deliver an updated event, then an older created event, and confirm the status does not go backwards.

Checklist

  • Raw body on the webhook route, with the signature verified before any database write.
  • Event ID protected by a primary key or unique constraint, never by an existence check in app code.
  • Ledger insert and business writes in one transaction.
  • Snapshot state guarded by the provider timestamp, or fetched fresh from the API.
  • External calls moved to an outbox worker.
  • 2xx for duplicates and unknown event types, 5xx only for failures you want retried.
  • A retention job that deletes webhook_events rows only after they are well past the provider's retry window. Stripe retries for up to three days, so keeping 30 days is a safe default.

Missing idempotency is a common payment bug when Geminate Solutions takes AI-generated apps to production. These handlers usually work in the demo and then fail on the first retry storm.

If your payments started as a Lovable prototype, the Lovable Stripe payments guide covers the rest of the setup.

Top comments (0)