All guidesStripe and webhooks
On this page

Stripe webhooks

Stripe

How do I stop a Stripe webhook from processing the same event twice?

By Ohad Krispin, founder of VibeRaven · LinkedIn

Record each event ID you process and skip any ID you have seen before. Stripe says an endpoint might occasionally receive the same event more than once, and it retries any delivery that did not get a 2xx response in time, so the same evt_ ID can arrive twice. In a Supabase app, give the event ID a primary key and do the insert and the grant in one database function: a repeat delivery inserts nothing and grants nothing, even when two copies arrive at once.

PGlite output: one checkout.session.completed event delivered twice. A handler with no event table grants credits twice, balance 200; the event-table handler skips the repeat, balance 100.
PGlite output: one checkout.session.completed event delivered twice. A handler with no event table grants credits twice, balance 200; the event-table handler skips the repeat, balance 100.

Why does Stripe send the same webhook event twice?

Mostly retries. In live mode Stripe retries a failed or timed-out delivery for up to three days with exponential backoff, so a handler that granted access and then timed out gets the event again. Resending an event by hand does not cancel those automatic retries. Stripe also notes that sometimes two separate Event objects describe one change; to catch those, compare the ID of data.object together with event.type.

Stripe webhook idempotency in Supabase

One table keyed by event ID and one function that inserts the ID and grants in the same statement. Postgres runs each statement in a transaction, so the grant and the record succeed or fail together, and on conflict do nothing makes the second insert a no-op. Call it from the verified webhook with supabase.rpc and your secret key. We ran the same pattern on PGlite on 2026-10-23; the output is above.

create table public.stripe_events (
  id text primary key,
  type text not null,
  processed_at timestamptz not null default now()
);
alter table public.stripe_events enable row level security;

create function public.fulfill_checkout_once(p_event_id text, p_type text, p_user uuid)
returns boolean language plpgsql as $$
begin
  insert into public.stripe_events (id, type) values (p_event_id, p_type)
  on conflict (id) do nothing;
  if not found then
    return false; -- already processed
  end if;
  update public.profiles set plan = 'pro' where id = p_user;
  return true;
end;
$$;

revoke execute on function public.fulfill_checkout_once(text, text, uuid) from public, anon, authenticated;

Should I use event.created to order or deduplicate events?

No. Stripe does not guarantee delivery order, and created is in seconds, so distinct events can share a timestamp. Track event IDs for duplicates, and when order matters, retrieve the current object from the API instead of trusting the event you happened to receive last.

Is this the same as a Stripe idempotency key?

No. An Idempotency-Key header makes your own requests to Stripe safe to retry, for example creating a refund from inside the handler. It does nothing for incoming webhooks. If your handler calls Stripe, deriving the key from the event ID keeps a retried event from repeating that call.

Checklist

Make the handler safe to run twice

  1. 01Create the event table and the function above in a migration. RLS on with no policy keeps the table away from browser keys, and the revoke stops anon and signed-in users from calling the function through the API.
  2. 02In the webhook route, verify the signature first, then call the function with event.id, event.type and the user from your own records or the session's client_reference_id.
  3. 03Return 200 for a repeat too. A non-2xx reply makes Stripe retry an event you already handled.
  4. 04For Checkout, Stripe's fulfillment guide asks the same of your fulfill function per Checkout Session ID, because the success page may call it as well. Key on the session ID there.
  5. 05
    Test it: resend one event to your endpoint and confirm the grant happened once.
    stripe events resend evt_... --webhook-endpoint=we_...

Related

Sources

Official docs behind this checklist

FAQ

FAQ for builders before launch

Can I just check if the row exists before inserting?

Not safely. Two deliveries that arrive together can both see no row and both grant. A primary key with on conflict do nothing lets only one insert win.

Where should slow work go?

After the event is recorded and the response is sent, for example in a queue. Stripe asks for a quick 2xx before complex logic that could time out.