auditmyapp

Notes / Stripe webhooks

Stripe webhooks firing twice: how to make your handler safe to run again

Stripe will sometimes send you the same event more than once, and not always in the order things happened. Here is how to build a handler that doesn't care.

A while back I shipped a webhook handler that processed the same Stripe event twice. The work it was supposed to do once happened twice, and nothing in the code looked wrong. It did exactly what it was told, two times.

This is one of the most common payment bugs in AI-built apps, because the happy path works perfectly in testing. It only appears under real traffic, when a response is a little slow or a network blip triggers a retry.

Why it happens

Stripe promises to deliver every event, but not to deliver it only once. If your endpoint is slow to answer, returns an error or times out, Stripe tries again. The same event can arrive more than once, and events can arrive in a different order from the one they happened in.

There is a second version of the bug that catches people who think they have already fixed it. Stripe sends several different events that all mean roughly "this was paid", for example checkout.session.completed and payment_intent.succeeded. A handler that fulfils the order on both will ship it twice, even though it never saw any single event twice.

Four rules for a handler that holds up

1. Verify the signature, on the raw body

Your webhook URL is public. Without a signature check, anyone can send it a fake "payment succeeded" event. The check has to run on the request body exactly as it arrived, before anything parses it as JSON. Parse first and the bytes change, the check fails, and the tempting fix is to switch the check off. That fix is a serious vulnerability.

2. Record the event ID before doing anything

Keep a table with the event ID as its primary key. Insert first. If the insert fails because the ID already exists, you have handled this event before, so stop there.

3. Don't trust the order

Treat an event as "something changed" rather than "here is the truth". Before acting, fetch the current state of the object from Stripe and act on that.

4. Make the action itself idempotent

This is the rule that catches the two-event-types bug. Marking an order as paid should only succeed if it isn't paid already, so doing it a second time changes nothing, whichever event triggered it.

What it looks like on Supabase

First, the table for event IDs. Run this in the SQL Editor:

create table public.stripe_events (
  id text primary key,
  type text not null,
  received_at timestamptz not null default now()
);

alter table public.stripe_events enable row level security;
-- No policies on purpose: only your server, using the secret key, touches this table.

Then the Edge Function. The parts that matter are commented:

import Stripe from 'npm:stripe';
import { createClient } from 'npm:@supabase/supabase-js@2';

const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!);
const cryptoProvider = Stripe.createSubtleCryptoProvider();

// A secret key you stored yourself with: supabase secrets set SERVER_SECRET_KEY=...
const supabase = createClient(
  Deno.env.get('SUPABASE_URL')!,
  Deno.env.get('SERVER_SECRET_KEY')!,
);

Deno.serve(async (req) => {
  const signature = req.headers.get('stripe-signature') ?? '';
  const body = await req.text(); // raw body: never parse it before verifying

  let event: Stripe.Event;
  try {
    event = await stripe.webhooks.constructEventAsync(
      body, signature, Deno.env.get('STRIPE_WEBHOOK_SECRET')!, undefined, cryptoProvider,
    );
  } catch {
    return new Response('Bad signature', { status: 400 });
  }

  // Record the event first. A duplicate ID means we've already handled it.
  const { error: seen } = await supabase
    .from('stripe_events')
    .insert({ id: event.id, type: event.type });
  if (seen?.code === '23505') return new Response('Already handled', { status: 200 });
  if (seen) return new Response('Try again', { status: 500 });

  try {
    if (event.type === 'payment_intent.succeeded') {
      const sent = event.data.object as Stripe.PaymentIntent;
      const pi = await stripe.paymentIntents.retrieve(sent.id); // current state, not the event's copy
      const orderId = pi.metadata.order_id;

      if (pi.status === 'succeeded' && orderId) {
        // Only moves an order from pending to paid. A second call changes nothing.
        const { error } = await supabase
          .from('orders')
          .update({ status: 'paid', payment_intent_id: pi.id })
          .eq('id', orderId)
          .eq('status', 'pending');
        if (error) throw error;
      }
    }
  } catch {
    // Forget this event so Stripe's retry gets a clean second attempt.
    await supabase.from('stripe_events').delete().eq('id', event.id);
    return new Response('Try again', { status: 500 });
  }

  return new Response('ok', { status: 200 });
});

Two details that trip people up:

Test it before you trust it

  1. In your Stripe sandbox, add your function's URL as a webhook endpoint and copy its signing secret into STRIPE_WEBHOOK_SECRET.
  2. Send a test event with the Stripe CLI: stripe trigger payment_intent.succeeded
  3. In the Stripe dashboard, open that event and resend it to your endpoint.

The second delivery should come back as "Already handled", and your orders table should show one change, not two.

Payment code is where "it worked when I tested it" means the least. Duplicate and out-of-order events are exactly the cases worth checking before your customers find them.