Stripe webhooks
How do I stop a Stripe webhook from processing the same event twice?
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.

This is one item on the Stripe webhook checklist for AI-built apps.
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
- 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.
- 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.
- 03Return 200 for a repeat too. A non-2xx reply makes Stripe retry an event you already handled.
- 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.
- 05Test it: resend one event to your endpoint and confirm the grant happened once.
stripe events resend evt_... --webhook-endpoint=we_...
Related
Related guides
Sources
Official docs behind this checklist
Browse all launch guides · Read resources · How to make and check your next change
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.