DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our Stripe webhook writes its idempotency marker inside the transaction it is protecting

Every webhook provider retries on a non-2xx response, and most of them will
occasionally deliver the same event twice for no reason at all. So any handler
with a side effect needs to answer "have I already done this?". We have three
handlers that need to answer it and they answer it three different ways, which
sounds like drift and is actually the design.

One: a table, written in the same transaction as the work

The Stripe handler grants paid entitlements. The marker lives in Postgres, in a
table with three columns:

export const stripeWebhookEventsTable = pgTable(
  'stripe_webhook_events',
  {
    id: text().primaryKey(),   // the Stripe event id, naturally unique
    type: text().notNull(),
    processed_at: timestamp().notNull().defaultNow(),
  },
  (table) => [index('stripe_webhook_events_processed_at_idx').on(table.processed_at)]
);
Enter fullscreen mode Exit fullscreen mode

There is no "processed" boolean and no status column. The row's existence is the
fact. The primary key is the event id Stripe already generated, so the
uniqueness is not something we maintain.

The part that took a production incident to learn is where the insert goes. For
checkout.session.completed it is inside the same transaction as the grant:

await db.transaction(async (tx) => {
  await tx.insert(stripeWebhookEventsTable).values({ id: event.id, type: event.type });
  await grantEntitlements(session, tx);
});
Enter fullscreen mode Exit fullscreen mode

If you write the marker first and commit it, then grant, then the grant throws,
you have told yourself you handled an event you did not handle. Stripe retries,
the retry hits the marker, the retry is acknowledged as a duplicate, and the
customer has paid for nothing. The failure mode is silent and permanent and the
only trace is a 500 in the logs next to a 200.

Inside one transaction, a failed grant takes the marker down with it. The retry
arrives to a clean state and runs the grant properly. A genuine duplicate
delivery, where the first attempt did commit, trips the primary key instead:

function isWebhookEventDuplicate(err: unknown): boolean {
  const e = err as { code?: string; constraint?: string } | null;
  return e?.code === '23505' || e?.constraint === 'stripe_webhook_events_pkey';
}
Enter fullscreen mode Exit fullscreen mode

SQLSTATE 23505 is unique_violation. Catching it by code, with the constraint
name as a second opinion, is how a duplicate becomes a 200 rather than an error
page in a dashboard.

This only matters because some of the grants are not idempotent on their own. A
credit pack balance is an increment. Running an increment twice is a bug, running
it zero times is a worse bug, and the transaction is the only thing that makes
"exactly once" true for both.

Two: the same table, but the marker goes first

For every other Stripe event type the insert happens on its own, before the
switch, and a duplicate returns immediately:

try {
  await db.insert(stripeWebhookEventsTable).values({ id: event.id, type: event.type });
} catch (insertError) {
  if (isWebhookEventDuplicate(insertError)) {
    return Response.json({ received: true, skipped: true });
  }
  throw insertError;
}
Enter fullscreen mode Exit fullscreen mode

Same table, opposite ordering, because the work behind those events (reversing
affiliate commissions on a refund, clawing back an unspent referral reward,
recording a card fingerprint) is a set of status transitions that are safe to
re-run and not safe to run twice as increments. The marker here is cheap
protection against a storm of duplicates, not a correctness guarantee.

Three: a Redis key with a TTL, failing open

The inbound email handler is a different problem. It verifies a signature, fetches
the raw message, parses arbitrary MIME, and forwards it. Its side effect is
sending an email, and nobody will ever audit which inbound message ids we saw six
months ago.

So the claim is a Redis SET with NX:

const claimed = await redis.set(key, Date.now().toString(), { nx: true, ex: ttlSeconds });
if (claimed === null) {
  // the key already existed: duplicate delivery
}
Enter fullscreen mode Exit fullscreen mode

NX means "set only if absent", and a null reply means it was present. The key
is namespaced per provider so two providers cannot collide on an id, and the TTL
is 24 hours because the retry schedule tapers off well inside that. A table would
have worked and would also have grown forever for the benefit of nobody.

Two deliberate choices sit on top of it.

It fails open. If Redis is not configured or the call throws, the handler
reports first delivery and carries on, with a loud log line. Forwarding a
duplicate support email is a mild annoyance. Dropping a real one because a cache
was down is a customer who thinks we ignored them.

And it releases the claim when handling fails:

try {
  await handleEmailReceived(data);
} catch (error) {
  await releaseWebhookEvent('resend', svixId);
  return apiError('Webhook processing failed', 500);
}
Enter fullscreen mode Exit fullscreen mode

Without that release, the claim outlives the failure, the provider's retry is
read as a duplicate, and the message is lost. It is the same bug as writing the
Stripe marker outside the transaction, in a system with no transactions to reach
for.

One more small thing: events that are verified but inert are acknowledged before
any claim is taken. Only the event type with a side effect spends a Redis key.

What is deliberately not guarded

Two operations in the Stripe handler run on every delivery, including retries,
and are not gated by the marker at all: settling a school discount reservation,
and accruing an affiliate commission. Both are already idempotent on their own
terms. The first is a one-way status transition. The second has a unique index on
the checkout session id, so a second attempt raises 23505 and is swallowed.

Putting them behind the marker would have made them less reliable, because a
handler that failed halfway through after committing the marker would never come
back to them.

The rule, finally

Ask what the side effect is, not what the provider is.

  • Granting something a customer paid for: a durable row, written in the same transaction as the grant.
  • A status transition that is safe to repeat: a marker for cost, not correctness.
  • An outbound message nobody will audit: a TTL key that fails open.

The bundle on our pricing page is what pushed this from theory to production.
Open https://cogniprep.app/pricing and expand "Is there a bundle if I want scores
too?" in the FAQ. That product is one checkout session carrying two separate
entitlements, which means the grant function cannot early-return after the first
branch it matches, and both grants have to land or neither does. One
transaction, one marker, one retry that still works.

Top comments (1)

Collapse
 
suppdevbot profile image
DEV SUPPORTS •

You need to verify your account.

Enter fullscreen mode Exit fullscreen mode

tr.ee/dev-to