All Docs
FeaturesMaking Tax DigitalUpdated March 11, 2026

How We Fixed Stripe Webhook Duplicate Processing (ERR-21)

How We Fixed Stripe Webhook Duplicate Processing (ERR-21)

Release: v1.0.400
Category: Data Integrity

Background

Stripe's webhook delivery system is designed to be reliable, but it operates under an at-least-once delivery guarantee. In rare network failure scenarios — such as a timeout between Stripe and your server — Stripe may re-deliver an event that your server already received and processed. Without a deduplication mechanism at the webhook entry point, each delivery of the same event would be treated as a new, independent operation.

In our platform, the Stripe webhook handler is responsible for receiving subscription lifecycle events (stripe/customer.subscription.created, stripe/customer.subscription.updated, stripe/customer.subscription.deleted, etc.) and dispatching them to Inngest for processing — for example, to synchronise a subscriber's plan status with their HMRC submission access level.

The Problem

Prior to v1.0.400, the webhook handler (src/app/api/webhooks/stripe/route.ts) correctly validated the Stripe-Signature header to ensure events were genuine, but it did not check whether a given event.id had already been processed.

This meant a duplicate Stripe delivery would:

  1. Pass signature validation ✅
  2. Call inngest.send() again for the same event ✅ (second time)
  3. Potentially trigger subscription sync twice ❌

While Inngest does support idempotency, relying solely on downstream deduplication — without configuration that enforces it — is fragile. The safer pattern is to enforce deduplication as early as possible in the processing chain.

The Fix

The simplest and most robust solution is to pass the Stripe event.id as the Inngest event's id field. Inngest uses this value as an idempotency key: if an event with the same id has already been enqueued or processed within Inngest's deduplication window, any subsequent send for that id is silently dropped.

// src/app/api/webhooks/stripe/route.ts

await inngest.send({
  id: event.id,  // Stripe event ID used as Inngest idempotency key
  name: `stripe/${event.type}`,
  data: event.data.object,
});

This change requires no additional database storage or TTL management, and it ties deduplication directly to the Stripe event's unique identifier — which Stripe guarantees is unique per event, globally.

Why Not the Database Approach?

An alternative approach (considered and rejected for now) would be to store processed event.id values in the database with a 24-hour TTL, and check for existence before calling inngest.send(). This would work, but it introduces additional complexity:

  • Requires a new database table or Redis key space.
  • Introduces a potential race condition unless the check and insert are atomic.
  • Adds latency to every webhook request.

The Inngest idempotency key approach achieves the same safety guarantee with less code and no new infrastructure dependencies.

Impact on Subscribers

For users of the MTD compliance platform, this fix ensures that subscription state changes — such as upgrading a plan, cancelling a subscription, or processing a renewal — are applied exactly once. This is particularly important because subscription tier changes directly affect which HMRC submission features a user can access (e.g. quarterly filing, annual summaries, multi-property support).

Files Changed

FileChange
src/app/api/webhooks/stripe/route.tsAdded id: event.id to inngest.send() calls to enforce idempotency

Related