TillpingPre-launch
Menu

Fix guides

Your Stripe webhook ran twice

Short answer: Stripe can send the same event more than once. Its docs say so plainly. Your code has to notice a repeat and skip it. Save each event.id in a table with a unique rule. If the ID is already there, reply 200 and do nothing.

Signs you have this problem

  • One payment gave a customer two orders, two emails or double credits.
  • Your logs show the same evt_... ID more than once.
  • Stripe's Event deliveries tab shows a failed try and then a later try for the same event.

Why it happens

  1. Retries. If your endpoint doesn't send back a 2xx fast, Stripe tries again. In live mode it keeps trying for up to three days. If your code did the work but replied late or crashed at the end, the retry does the work a second time.
  2. Plain repeats. Stripe's docs say an endpoint "might occasionally receive the same event more than once," even with no failure.
  3. Two events for one thing. Sometimes Stripe makes two separate events about the same object. They have different event IDs. Stripe says to spot these by the object's ID (data.object.id) plus event.type.
  4. Manual resends. Resending an event from the Dashboard or the CLI doesn't stop Stripe's own retries. So you can get it again later.

Step 1: Make a table for events you've handled

Run this in your database (for Supabase, the SQL editor):

create table if not exists processed_stripe_events (
  event_id text primary key,
  received_at timestamptz not null default now()
);

The primary key is the unique rule. The database will refuse a second row with the same ID, even if two copies arrive at the same moment.

Step 2: Check the ID before you do any work

Do this right after the signature check. Here is a Next.js App Router example:

import Stripe from "stripe";
import { createClient } from "@supabase/supabase-js";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const db = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);

export async function POST(req: Request) {
  const body = await req.text(); // raw body for the signature check
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      req.headers.get("stripe-signature")!,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return new Response("Bad signature", { status: 400 });
  }

  // Try to save the ID. A repeat breaks the unique rule (Postgres code 23505).
  const { error } = await db.from("processed_stripe_events").insert({ event_id: event.id });
  if (error?.code === "23505") {
    return new Response("Already handled", { status: 200 });
  }
  if (error) {
    return new Response("DB error", { status: 500 }); // Stripe will retry
  }

  try {
    await handleEvent(event); // your order, email or credit logic
  } catch {
    // Remove the ID so Stripe's retry can try again.
    await db.from("processed_stripe_events").delete().eq("event_id", event.id);
    return new Response("Handler failed", { status: 500 });
  }

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

Keep the service role key on the server only. Never put it in front-end code.

Step 3: Make the work itself safe to repeat

The event table stops the same event. It doesn't stop two different events about the same thing (reason 3 above). So make the work safe too:

  • Give orders a unique rule on the Stripe object ID, such as the Checkout Session ID. A second insert then fails instead of making a second order.
  • Set values instead of adding to them. "Set plan to pro" is safe to run twice. "Add 100 credits" is not, unless it's tied to a unique ID.

Step 4: Reply fast

Stripe's docs say to return a 2xx quickly, before any slow work. A slow reply can count as a failure, and a failure means a retry. If your handler sends emails or calls other APIs, save the event first, reply 200, and do the slow part in a background job or queue.

Step 5: Don't trust the order

Stripe doesn't promise events arrive in order. Don't use the created time to decide if you've seen an event. Use the event ID, as above. If you need the latest state, fetch the object from the Stripe API.

Check it with a free scan

Paste your webhook address into the free Tillping scan: https://tillping.online/#scan. No account is needed. Only scan an address you own or are allowed to test.

It sends a few safe test requests (not real payment events). It can't tell whether your code skips repeats, because it never sends real signed events. It can show problems that cause retries: a reply that is slow (3 seconds or more) or times out (about 8 seconds), a server error on a test request, a redirect, or a login wall in front of your code.

Sources

Run the free check

Paste your webhook address and see what your endpoint answers. No account. No keys.

Check my webhook

Join the early-bird list

Tillping launches soon. Join the early-bird list and we will email you when it's live.

We send one email to confirm. You can leave at any time.