Recipe-Inhalt ist auf Englisch. Englisches Original lesen →
← Alle Recipes
Phase 10 · Multi-Tenant SaaS·8 steps

Stripe webhooks, idempotent, signature-verified, fail-safe

HMAC-SHA256 signature verification, idempotency on event_id, the raw-body trap, the full subscription lifecycle, and the retry behavior you need to plan for.

8 steps0%
Du liest ohne Account. Mit Login speichern wir Step-Fortschritt + Notes.

Stripe webhooks, idempotent, signature-verified, fail-safe

Webhooks are how Stripe tells you payment events happened. Done right, they're the source of truth for subscription state. Done wrong, you have customers paying without access, or, worse, non-paying customers with full access. This recipe is the production-grade handler.

Schritt 1: Why this is harder than it looks

Stripe webhooks have three properties that conspire against naive implementations:

  1. At-least-once delivery. Stripe retries on any non-2xx response or timeout. The same event arrives 2-5 times.
  2. Out-of-order delivery. A subscription.updated event can land before the subscription.created event it logically follows.
  3. Signed but un-encrypted. Anyone can POST to your endpoint; signature verification is the only thing keeping bad actors out.

Your handler must be: idempotent, signature-verified, and tolerant of out-of-order events.

Schritt 2: Read the raw body, the most common bug

Stripe signs the raw bytes of the request body. If you app.use(express.json()) before your webhook route, Express parses the body and your raw bytes are gone. Signature verification fails on every request. Symptom: signature_mismatch 400 forever.

Pattern:

import express from 'express';
const app = express();

// Parse JSON globally
app.use(express.json());

// EXCEPT for the webhook route, raw-buffer parser, not JSON parser.
app.post('/api/billing/webhook',
  express.raw({ type: 'application/json' }),
  webhookHandler,
);

If you're not using Express, you must capture the raw body before any framework parses it. In a fetch-style handler:

const rawBody = await req.text(); // BEFORE you call req.json()
const signature = req.headers.get('stripe-signature');
// ... verify with rawBody ...
const event = JSON.parse(rawBody);

Schritt 3: HMAC-SHA256 signature verification

Default to the Stripe SDK, it has battle-tested verification built in:

import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

const event = stripe.webhooks.constructEvent(
  rawBody,
  signatureHeader!,
  process.env.STRIPE_WEBHOOK_SECRET!,
);

constructEvent does the same HMAC-SHA256 verification, the timing-safe compare, the timestamp tolerance check, and JSON parsing in one line. It also keeps up with new signature schemes when Stripe adds them.

The hand-rolled version below is for two cases: (1) you don't want the Stripe SDK as a dependency (Edge runtimes, Cloudflare Workers, minimal bundles), or (2) you're learning what the SDK does under the hood. Idempotency is a separate concern, verification only proves the request came from Stripe. Stopping duplicate processing requires the stripe_events table from Step 4.

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyStripeWebhook(
  rawBody: string,
  signatureHeader: string | undefined,
  secret: string,
): { type: string; id: string; data: { object: Record<string, unknown> } } | null {
  if (!signatureHeader) return null;

  // signatureHeader format: "t=1234567890,v1=abcdef...,v0=oldver..."
  const items = signatureHeader.split(',').reduce<Record<string, string>>((acc, part) => {
    const [k, v] = part.split('=');
    if (k && v) acc[k.trim()] = v.trim();
    return acc;
  }, {});

  const ts = items.t;
  const v1 = items.v1;
  if (!ts || !v1) return null;

  const signedPayload = `${ts}.${rawBody}`;
  const expected = createHmac('sha256', secret).update(signedPayload).digest('hex');

  // Timing-safe compare, prevents timing-attack signature forgery
  const a = Buffer.from(expected, 'utf-8');
  const b = Buffer.from(v1, 'utf-8');
  if (a.length !== b.length || !timingSafeEqual(a, b)) return null;

  // Optional: reject very old timestamps (replay-attack window)
  const tsMs = Number(ts) * 1000;
  if (Math.abs(Date.now() - tsMs) > 5 * 60_000) return null;

  try {
    return JSON.parse(rawBody);
  } catch {
    return null;
  }
}

Three properties baked in:

  • timingSafeEqual, === would leak signature bytes via response time. timingSafeEqual is constant-time.
  • Signature scope is ${ts}.${rawBody}. Stripe's exact format. Any deviation makes verification fail.
  • 5-minute timestamp window, protects against an attacker replaying an intercepted webhook hours later.

Schritt 4: Idempotency on event_id

Stripe retries. The same event arrives 2-5 times. Your handler must process it exactly once.

CREATE TABLE stripe_events (
  event_id     TEXT PRIMARY KEY,           -- Stripe's evt_xxx ID
  type         TEXT NOT NULL,
  payload      JSONB NOT NULL,
  received_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

The handler:

export async function applyStripeEvent(event: { type: string; id: string; data: any }) {
  // Insert event_id; if it already exists, the event has been processed before.
  const r = await db.query(
    `INSERT INTO stripe_events (event_id, type, payload)
     VALUES ($1, $2, $3::jsonb)
     ON CONFLICT (event_id) DO NOTHING
     RETURNING event_id`,
    [event.id, event.type, JSON.stringify(event)],
  );

  if (r.rows.length === 0) {
    logger.info('stripe event already processed', { eventId: event.id, type: event.type });
    return { applied: false, reason: 'already_processed' };
  }

  // Now do the actual work, knowing this is the first (and only) time.
  return await routeEvent(event);
}

ON CONFLICT (event_id) DO NOTHING RETURNING is atomic, even concurrent webhooks for the same event hit it once.

Schritt 5: The events to handle

For a basic SaaS subscription, these four matter:

async function routeEvent(event: { type: string; data: { object: any } }) {
  const obj = event.data.object;

  if (event.type === 'checkout.session.completed') {
    // First payment, flip the tenant to 'pro'
    const tenantId = obj.client_reference_id ?? obj.metadata?.tenantId;
    if (!tenantId) return { applied: false, reason: 'no_tenant_id' };
    await db.query(
      `UPDATE tenants
       SET plan = 'pro',
           stripe_customer_id = $2,
           stripe_subscription_id = $3,
           subscription_status = 'active',
           plan_updated_at = NOW()
       WHERE id = $1`,
      [tenantId, obj.customer, obj.subscription],
    );
    logger.info('tenant upgraded to pro', { tenantId, eventId: event.id });
    return { applied: true };
  }

  if (event.type === 'customer.subscription.updated' || event.type === 'customer.subscription.created') {
    // Status change (active → past_due, trialing → active, etc.)
    await db.query(
      `UPDATE tenants
       SET subscription_status = $2,
           subscription_renews_at = to_timestamp($3::bigint),
           plan_updated_at = NOW()
       WHERE stripe_subscription_id = $1`,
      [obj.id, obj.status, obj.current_period_end],
    );
    return { applied: true };
  }

  if (event.type === 'customer.subscription.deleted') {
    // Cancel, drop tenant back to free
    await db.query(
      `UPDATE tenants
       SET plan = 'free',
           subscription_status = 'canceled',
           plan_updated_at = NOW()
       WHERE stripe_subscription_id = $1`,
      [obj.id],
    );
    logger.info('tenant downgraded to free', { subscriptionId: obj.id, eventId: event.id });
    return { applied: true };
  }

  if (event.type === 'invoice.payment_failed') {
    // Card declined, mark for follow-up email, but don't downgrade yet.
    // Stripe's Smart Retries will retry the charge automatically (default
    // schedule is configurable in Dashboard → Billing → Subscriptions →
    // "Manage failed payments", typically a handful of attempts spread over
    // 1-3 weeks). Only downgrade after `customer.subscription.deleted` lands.
    await db.query(
      `UPDATE tenants
       SET subscription_status = 'past_due'
       WHERE stripe_subscription_id = $1`,
      [obj.subscription],
    );
    return { applied: true };
  }

  // Unknown event type, log and accept (idempotency table holds it)
  return { applied: true, reason: 'unhandled_type' };
}

Note three patterns:

  • client_reference_id ?? metadata.tenantId. Stripe puts your tenantId in two places depending on the event. Check both.
  • past_duecanceled, give Stripe's smart retries a few days to recover the payment before downgrading. Stripe's defaults retry 4 times over 4 weeks.
  • Unknown types accepted, store the payload in stripe_events, return 200, deal with it later. Better than 500-ing and triggering retries.

Schritt 6: The endpoint

app.post('/api/billing/webhook',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const rawBody = req.body.toString('utf-8');
    const signature = req.header('stripe-signature');

    const event = verifyStripeWebhook(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET!);
    if (!event) {
      logger.warn('stripe webhook signature invalid', { signature, ip: req.ip });
      return res.status(400).send('signature mismatch');
    }

    try {
      const result = await applyStripeEvent(event);
      logger.info('stripe webhook applied', { eventId: event.id, type: event.type, ...result });
      return res.status(200).json(result);
    } catch (err) {
      // Return 500 → Stripe retries → idempotency table prevents duplicate processing
      logger.error('stripe webhook handler threw', { eventId: event.id, err: String(err) });
      return res.status(500).send('handler error');
    }
  },
);

Two non-obvious things:

  • Return 200 even for unknown event types. Otherwise Stripe retries forever. The idempotency table records you got it; you can investigate later.
  • Return 500 on real errors. Stripe retries with exponential backoff (up to 3 days). Your idempotency table stops duplicates from creating bad state.

Schritt 7: Test with Stripe CLI

Local testing without exposing your dev box:

# Install: brew install stripe/stripe-cli/stripe (or download for your OS)
stripe login
stripe listen --forward-to localhost:3000/api/billing/webhook

# In another terminal, trigger fake events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted

stripe listen prints a webhook signing secret on startup, use that as STRIPE_WEBHOOK_SECRET for local tests. Production secret comes from stripe webhooks in the dashboard.

Client-Check · auf Deinem Rechner ausführen
(grep -RnE "[\x22\x27\x60]/health[\x22\x27\x60]" src/saas/server.ts src/server.ts src/http.ts 2>/dev/null | head -3) || echo "no /health route in source"
Erwartet: A `/health` route literal appears in src/saas/server.ts or src/server.ts.
Falls hängen geblieben: Add a simple `GET /health` returning `{ status: "ok", version: "..." }` so Docker / Cloud Run can probe liveness.

Schritt 8: Verify

Run academy_validate_step. The validator hits /health on your server. Once your server is responding, your webhook endpoint is reachable. Stripe will tell you in the dashboard if signature verification is failing, check Developers → Webhooks → [your endpoint] → Recent deliveries.

Common traps

  • express.json() applied globally, kills signature verification. Covered in Step 2.
  • === for signature comparison, leaks bytes via timing. Use timingSafeEqual.
  • Returning 500 for "unknown event type". Stripe retries it forever; your endpoint becomes a magnet for noise. Always 200 + log.
  • Synchronous heavy work in the handler. Stripe's timeout is short. Acknowledge fast, do heavy work async.
  • Trusting success_url for entitlement, covered in 10.1. Webhooks are the source of truth.
  • No idempotency table, same event applies 5 times, your tenant has 5 subscriptions. Always dedupe on event_id.
  • Using the same webhook secret in test and prod, they're different secrets in Stripe. Use sk_test_ + test webhook secret for development, sk_live_ + production webhook secret for production.

What good looks like

A 60-line handler that verifies signature, dedupes on event_id, dispatches to four event-type branches, returns 200 for unknown types, returns 500 for real errors. Webhooks fire and your tenants.plan column tracks Stripe's reality within seconds. You sleep at night.

If you ever see "tenant paid but doesn't have access", check three things in order: (1) did the webhook deliver (Stripe dashboard), (2) is the signature verifying (your logs), (3) is the event in stripe_events (your DB). The bug is always in one of those three.

Magic-link mail. Brevo SMTP, ssm-deploy ritual, 5-phase prod