Skip to content
Book a call

3 min readEngineering

Stripe webhooks to Supabase: a sync that survives retries

Keep Supabase in step with Stripe: verify every webhook, handle the same event twice, cope with events out of order, and never trust the browser.

To keep Supabase in step with Stripe, let only signed Stripe webhooks change payment data, never the browser. Verify each event's signature, record its ID so the same event is processed once, and write the result with the service role key on the server. Stripe retries failed deliveries for days and doesn't promise their order, so your handler has to be safe to run twice and in any order.

Why the success page isn't enough

Many AI-built apps mark an order paid when the customer lands on the success page. That page can be opened without paying, closed before it loads, or reached twice. The webhook is the only reliable signal, because Stripe sends it from its own servers and signs it.

Five rules for the handler

  1. Verify the signature on the raw bodyUse the webhook signing secret and the exact bytes Stripe sent. Parsing the JSON first breaks the check.
  2. Record the event IDInsert event.id into a table with a unique constraint before acting. If the insert fails because it already exists, return 200 and stop.
  3. Don't rely on ordercustomer.subscription.updated can arrive before created. Read the current object from Stripe, or compare timestamps, and write the latest state rather than applying changes one by one.
  4. Answer fastReturn 200 within a few seconds and move slow work, like emails, to a queue. Slow handlers time out and get retried.
  5. Keep the service key on the serverThe webhook writes with Supabase's service role key, which must never reach the browser. Row-level security then lets each user read only their own rows and write none of them.

A Supabase Edge Function that checks the signature

Edge Functions run on Deno, which needs the async version of Stripe's check and the Web Crypto provider:

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

const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!);
const crypto = Stripe.createSubtleCryptoProvider();
const db = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);

Deno.serve(async (req) => {
  const body = await req.text();
  let event: Stripe.Event;
  try {
    event = await stripe.webhooks.constructEventAsync(
      body, req.headers.get("stripe-signature")!, Deno.env.get("STRIPE_WEBHOOK_SECRET")!, undefined, crypto,
    );
  } catch {
    return new Response("Bad signature", { status: 400 });
  }
  // Seen before? stripe_events.id is a primary key.
  const { error } = await db.from("stripe_events").insert({ id: event.id, type: event.type });
  if (error?.code === "23505") return new Response("Already handled");
  if (error) return new Response("Try again", { status: 500 });

  try {
    // ...update orders or subscriptions from the latest Stripe object here
  } catch {
    // Forget the event so Stripe's retry runs it again.
    await db.from("stripe_events").delete().eq("id", event.id);
    return new Response("Try again", { status: 500 });
  }
  return new Response("ok");
});

Deploy it without JWT verification (supabase functions deploy stripe-webhook --no-verify-jwt), because Stripe doesn't send a Supabase token; the signature check is what protects it. In Next.js the same rules apply in a route handler: read await req.text(), not req.json().

Which events to listen for

  • checkout.session.completed for one-off payments and the start of a subscription.
  • invoice.paid and invoice.payment_failed for renewals.
  • customer.subscription.updated and customer.subscription.deleted for plan changes and cancellations.
  • charge.refunded if refunds should change access.

Test with the Stripe CLI (stripe listen and stripe trigger), and resend the same event on purpose to check nothing happens twice.

Payments are one of the first things I check in an AI-built app, and part of every MVP I build. See Next.js and Supabase MVP development or fixing an AI-built app.

Questions people ask

Why does my Stripe webhook signature check fail?
Usually because the body was parsed or changed before the check, or the wrong signing secret is used (each endpoint and the Stripe CLI have their own). Verify against the raw request text with the secret for that endpoint.
How do I stop Stripe webhooks running twice?
Store each event ID in a table with a unique constraint and skip events you've already stored. Stripe retries deliveries, so duplicates are normal and your handler must expect them.
Should the webhook go in a Supabase Edge Function or a Next.js route?
Either works. Use whichever already holds your server code. What matters is the raw-body signature check, the event ID record and keeping the service role key on the server.
Can I mark an order paid from the success page?
Show a thank-you message there, but don't change payment data from it. Only the signed webhook should mark an order paid.

Reply within a day

Got an app, or an idea?

Send me the link, or a few lines about what you want to build. I'll reply within a day with honest next steps, even if I'm not the right fit.

Free 30-minute call · No commitment · Reply within a day

Pick a time on my Cal.com page. Calls are on Google Meet. Or email me.

  1. Step 1

    You send a link or an idea

    A few lines is enough.

  2. Step 2

    I reply within a day

    With questions or a first take.

  3. Step 3

    A free 30-minute call

    Then a written quote.