+1 (726) 227-3745

Webhooks That Never Double-Charge: Idempotent Stripe Handling with Express 5, MongoDB 8 and a Transactional Outbox

Every MEAN app we are called into that takes money has the same two bugs. A customer gets charged once and provisioned twice, because Stripe retried a webhook and the handler was not idempotent. Or a customer gets charged once and provisioned never, because the handler wrote to MongoDB, then crashed before it sent the confirmation email, and nothing in the system remembers that the email is still owed.

Both bugs come from the same mistake: treating a webhook as a request/response call instead of an at-least-once message. This tutorial fixes that properly in an Express 5 + Mongoose 8 + MongoDB 8 application on Node 24, with a Angular 22 client that reflects payment state without polling a lie. Stripe is the example, but the pattern is identical for GitHub, Twilio, Shopify, Slack or any provider that retries.

The three rules

  1. Verify the signature on the raw body. A webhook endpoint is public. If you do not verify, anyone can provision themselves a subscription with curl.
  2. Record the event before you act on it, in one atomic write. A unique index on the provider event id turns "did I already handle this?" into a database constraint rather than a race.
  3. Never do external side effects inside the webhook handler. Write intent to MongoDB, return 200 fast, and let a worker do the slow, retryable work. That is the transactional outbox.

Prerequisites

  • Node.js 24 LTS, Express 5, Mongoose 8, MongoDB 8.0 as a replica set (Atlas, or docker run -d -p 27017:27017 mongo:8.0 --replSet rs0 plus rs.initiate()). Transactions and change streams both need a replica set.
  • npm install express@5 mongoose@8 stripe zod
  • The Stripe CLI for local testing: stripe listen --forward-to localhost:3000/api/webhooks/stripe.

Step 1: the raw body trap

express.json() parses and discards the raw bytes. Stripe's signature is computed over those exact bytes, so a re-serialized object will not verify — key order and whitespace differ. Mount a raw parser on the webhook path before the global JSON parser:

import express from 'express';

export const app = express();

// Raw body ONLY for the webhook route.
app.use('/api/webhooks/stripe', express.raw({ type: 'application/json', limit: '1mb' }));

// Normal JSON parsing for everything else.
app.use(express.json());

Order matters. If express.json() runs first, req.body is an object and constructEvent will throw No signatures found matching the expected signature, which is the single most-Googled Stripe error for a reason.

Step 2: models

Three collections: the event ledger, the outbox, and your domain data (here, subscriptions).

import { Schema, model } from 'mongoose';

const webhookEventSchema = new Schema({
  provider:   { type: String, required: true },          // 'stripe'
  eventId:    { type: String, required: true },          // evt_...
  type:       { type: String, required: true },          // checkout.session.completed
  payload:    { type: Schema.Types.Mixed, required: true },
  status:     { type: String, enum: ['received', 'processed', 'failed'], default: 'received', index: true },
  attempts:   { type: Number, default: 0 },
  lastError:  String,
  processedAt: Date,
}, { timestamps: true });

// The whole idempotency guarantee lives in this one line.
webhookEventSchema.index({ provider: 1, eventId: 1 }, { unique: true });

const outboxSchema = new Schema({
  topic:    { type: String, required: true },            // 'email.receipt'
  payload:  { type: Schema.Types.Mixed, required: true },
  status:   { type: String, enum: ['pending', 'sent', 'dead'], default: 'pending' },
  attempts: { type: Number, default: 0 },
  runAt:    { type: Date, default: () => new Date(), index: true },
  lockedBy: String,
  lockedAt: Date,
  lastError: String,
}, { timestamps: true });

outboxSchema.index({ status: 1, runAt: 1 });

const subscriptionSchema = new Schema({
  userId:   { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
  stripeCustomerId: String,
  stripeSubscriptionId: { type: String, index: true },
  plan:     String,
  status:   { type: String, default: 'inactive' },        // active | past_due | canceled
  currentPeriodEnd: Date,
  // Monotonic guard against out-of-order delivery (see step 5).
  lastEventCreated: { type: Number, default: 0 },
}, { timestamps: true });

export const WebhookEvent = model('WebhookEvent', webhookEventSchema);
export const Outbox = model('Outbox', outboxSchema);
export const Subscription = model('Subscription', subscriptionSchema);

Step 3: the endpoint — verify, record, return

The handler does almost nothing. That is the design goal: Stripe expects a 2xx within seconds, and a handler that only writes one document is very hard to make slow.

import Stripe from 'stripe';
import { WebhookEvent } from './models.js';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET;

app.post('/api/webhooks/stripe', async (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, req.get('stripe-signature'), WEBHOOK_SECRET);
  } catch (err) {
    // 400 tells Stripe not to retry: a bad signature will never become good.
    return res.status(400).json({ error: `signature verification failed: ${err.message}` });
  }

  try {
    await WebhookEvent.create({
      provider: 'stripe',
      eventId: event.id,
      type: event.type,
      payload: event,
    });
  } catch (err) {
    if (err.code === 11000) {
      // Duplicate delivery. We have already got this one. Ack and move on.
      return res.status(200).json({ received: true, duplicate: true });
    }
    throw err; // real DB failure -> 500 -> Stripe retries, which is what we want
  }

  res.status(200).json({ received: true });
});

Three deliberate choices here. A verification failure returns 400 so Stripe stops retrying garbage. A duplicate returns 200 so Stripe stops retrying something we already own. A database outage returns 500 (via the Express 5 error middleware, which catches the rejected promise with no wrapper needed) so Stripe does retry — Stripe backs off over roughly three days, which is usually longer than your incident.

Note what is not here: no email, no provisioning, no third-party call. The event is durable; everything else happens next.

Step 4: process the ledger in a worker

A separate process (or a setInterval in a single-instance app) claims unprocessed events one at a time with findOneAndUpdate, which is atomic across replicas.

import mongoose from 'mongoose';
import { WebhookEvent } from './models.js';
import { handlers } from './handlers.js';

const MAX_ATTEMPTS = 5;

async function claimNext() {
  return WebhookEvent.findOneAndUpdate(
    { status: 'received', attempts: { $lt: MAX_ATTEMPTS } },
    { $inc: { attempts: 1 } },
    { sort: { createdAt: 1 }, new: true },
  );
}

export async function processOnce() {
  const doc = await claimNext();
  if (!doc) return false;

  const handler = handlers[doc.type];
  if (!handler) {
    await WebhookEvent.updateOne({ _id: doc._id }, { status: 'processed', processedAt: new Date(), lastError: 'no handler' });
    return true;
  }

  const session = await mongoose.startSession();
  try {
    await session.withTransaction(async () => {
      await handler(doc.payload, session);
      await WebhookEvent.updateOne(
        { _id: doc._id },
        { status: 'processed', processedAt: new Date() },
        { session },
      );
    });
  } catch (err) {
    const dead = doc.attempts >= MAX_ATTEMPTS;
    await WebhookEvent.updateOne({ _id: doc._id }, {
      status: dead ? 'failed' : 'received',
      lastError: String(err?.message ?? err),
    });
    if (dead) console.error('webhook dead-lettered', doc.eventId, err);
  } finally {
    await session.endSession();
  }
  return true;
}

The transaction is the point. The domain write, the outbox write and the "processed" flag either all land or none do. A crash halfway through leaves the event as received with a bumped attempt count, and the next poll picks it up.

Step 5: an idempotent handler that survives out-of-order events

Stripe does not guarantee ordering. A customer.subscription.updated for a cancellation can arrive before the updated that activated it. Guard with the event's own created timestamp:

import { Subscription, Outbox } from './models.js';

export const handlers = {
  'checkout.session.completed': async (event, session) => {
    const s = event.data.object;
    if (s.payment_status !== 'paid') return;

    const res = await Subscription.updateOne(
      { userId: s.client_reference_id, lastEventCreated: { $lt: event.created } },
      {
        $set: {
          stripeCustomerId: s.customer,
          stripeSubscriptionId: s.subscription,
          plan: s.metadata?.plan ?? 'pro',
          status: 'active',
          lastEventCreated: event.created,
        },
      },
      { upsert: true, session },
    );

    // Only enqueue the receipt if this event actually advanced state.
    if (res.modifiedCount || res.upsertedCount) {
      await Outbox.create([{
        topic: 'email.receipt',
        payload: { userId: s.client_reference_id, amount: s.amount_total, currency: s.currency },
      }], { session });
    }
  },

  'customer.subscription.updated': async (event, session) => {
    const sub = event.data.object;
    await Subscription.updateOne(
      { stripeSubscriptionId: sub.id, lastEventCreated: { $lt: event.created } },
      { $set: {
          status: sub.status,
          currentPeriodEnd: new Date(sub.current_period_end * 1000),
          lastEventCreated: event.created,
      } },
      { session },
    );
  },

  'customer.subscription.deleted': async (event, session) => {
    const sub = event.data.object;
    await Subscription.updateOne(
      { stripeSubscriptionId: sub.id, lastEventCreated: { $lt: event.created } },
      { $set: { status: 'canceled', lastEventCreated: event.created } },
      { session },
    );
  },
};

lastEventCreated: { $lt: event.created } makes a stale event a no-op instead of a regression. Combined with the unique index on eventId, replaying the entire history in any order converges on the same final state — which is exactly what you want when you inevitably click "Resend" in the Stripe dashboard after a bad deploy.

Step 6: draining the outbox

The outbox rows are the side effects: emails, Slack pings, calls to your CRM. A second worker claims them with a lease so two instances never send the same email.

import { Outbox } from './models.js';
import { send } from './effects.js';

const WORKER_ID = `${process.env.HOSTNAME ?? 'local'}:${process.pid}`;
const LEASE_MS = 60_000;

export async function drainOnce() {
  const now = new Date();
  const job = await Outbox.findOneAndUpdate(
    {
      status: 'pending',
      runAt: { $lte: now },
      $or: [{ lockedAt: null }, { lockedAt: { $lt: new Date(now - LEASE_MS) } }],
    },
    { $set: { lockedBy: WORKER_ID, lockedAt: now }, $inc: { attempts: 1 } },
    { sort: { runAt: 1 }, new: true },
  );
  if (!job) return false;

  try {
    await send(job.topic, job.payload);
    await Outbox.updateOne({ _id: job._id }, { status: 'sent', lockedBy: null, lockedAt: null });
  } catch (err) {
    const backoffMs = Math.min(2 ** job.attempts * 1000, 15 * 60_000);
    await Outbox.updateOne({ _id: job._id }, {
      status: job.attempts >= 8 ? 'dead' : 'pending',
      runAt: new Date(Date.now() + backoffMs),
      lockedBy: null,
      lockedAt: null,
      lastError: String(err?.message ?? err),
    });
  }
  return true;
}

If you already run BullMQ — see background jobs in a MEAN app — the outbox collection becomes a thin, transactional handoff: the worker reads a pending row and enqueues it to Redis. You still need the MongoDB row, because Redis cannot join your MongoDB transaction.

Run both loops with a simple pacer, and make shutdown clean so a rolling deploy does not orphan a lease:

let running = true;
process.on('SIGTERM', () => { running = false; });

async function loop(step, idleMs = 500) {
  while (running) {
    const did = await step();
    if (!did) await new Promise(r => setTimeout(r, idleMs));
  }
}

await Promise.all([loop(processOnce), loop(drainOnce)]);

Step 7: showing truth in Angular 22

The client must never treat "Stripe redirected me back" as "I am subscribed" — at that moment the webhook may not have landed. Poll your own API, or push with change streams. A small signal-based service, using resource() for the initial read:

import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';

type Sub = { status: 'inactive' | 'active' | 'past_due' | 'canceled'; plan?: string };

@Injectable({ providedIn: 'root' })
export class Billing {
  private http = inject(HttpClient);
  readonly sub = signal<Sub>({ status: 'inactive' });
  readonly isActive = computed(() => this.sub().status === 'active');
  readonly pending = signal(false);

  /** Called on the Stripe success-redirect page. */
  async confirm(timeoutMs = 20_000) {
    this.pending.set(true);
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
      const s = await firstValueFrom(this.http.get<Sub>('/api/me/subscription'));
      this.sub.set(s);
      if (s.status === 'active') break;
      await new Promise(r => setTimeout(r, 1000));
    }
    this.pending.set(false);
  }
}

In the template, three honest states instead of two:

@if (billing.isActive()) {
  <p>Your {{ billing.sub().plan }} plan is active.</p>
} @else if (billing.pending()) {
  <p>Payment received — activating your account…</p>
} @else {
  <p>We have not confirmed your payment yet. It usually takes a few seconds; we will email you.</p>
}

For an instant update instead of a poll, stream the subscription document with a MongoDB 8 change stream over SSE, as in real-time MEAN without WebSockets.

Testing it

The tests that matter are the nasty ones:

import test from 'node:test';
import assert from 'node:assert/strict';

test('duplicate delivery provisions once', async () => {
  const event = stripeFixture('checkout.session.completed');
  await postWebhook(event);
  await postWebhook(event);              // Stripe retry
  await processOnce(); await processOnce();
  assert.equal(await Subscription.countDocuments({ userId: event.data.object.client_reference_id }), 1);
  assert.equal(await Outbox.countDocuments({ topic: 'email.receipt' }), 1);
});

test('stale event does not resurrect a canceled subscription', async () => {
  await handle(cancelEvent({ created: 2000 }));
  await handle(activateEvent({ created: 1000 }));  // arrives late
  assert.equal((await Subscription.findOne({})).status, 'canceled');
});

test('crash mid-transaction leaves the event replayable', async () => { /* force throw in handler */ });

Run them against mongodb-memory-server started as a replica set so transactions work, with Node 24's built-in runner — no Jest required, as covered in Node 24 type stripping and the native test runner.

Operational checklist

  • Alert on WebhookEvent documents with status: 'failed' and on Outbox rows with status: 'dead'. These are the queues nobody watches until a customer complains.
  • Alert on ledger lag: the age of the oldest status: 'received' document. Rising lag means the worker is down while the endpoint is happily returning 200.
  • TTL-expire processed events after 60–90 days ({ processedAt: 1 }, { expireAfterSeconds: 7776000 }), but keep failed ones. Payloads contain personal data, so include them in your retention policy; if you store card metadata, look at queryable encryption.
  • Register one webhook endpoint per environment with its own secret, and never point staging at production data.
  • Reconcile nightly: list Stripe subscriptions changed in the last 24 hours and compare against MongoDB. Webhooks can be lost for good if your endpoint is down past the retry window; reconciliation is the safety net that catches it.

The takeaway

A webhook endpoint is a durable ingestion point, not a business process. Verify, insert with a unique index, return 200. Do the work in a worker inside a transaction, with the side effects in an outbox. Guard every state write with the event's own timestamp. Those four moves eliminate double-provisioning, lost receipts and out-of-order regressions — the entire class of bug.

If payments in your MEAN app are already misbehaving in production, or you want this pattern retrofitted without a rewrite, our MEAN Stack consulting and API development teams do this work regularly. Get in touch with the provider, the symptom and roughly how often it happens, and we will tell you what we would change first.