Your Stripe checkout succeeds, the money lands, and your subscriptions table is still empty. This is the single most common failure in a Supabase + Stripe SaaS build, and it is almost never Stripe's fault. In practice it is one of four things: the webhook signature check is failing silently, the Edge Function never receives the raw request body, row-level security is blocking the insert because you used the anon key, or the write is happening somewhere other than the webhook. Here is how to tell them apart in about ten minutes.
First: read the Stripe dashboard before you read your code
Go to Developers → Webhooks → your endpoint and look at the recent deliveries. The response code tells you which half of the problem you have:
- 400 — signature verification failed. Wrong webhook secret, or the body was parsed before verification. Your handler never ran.
- 401 / 403 — Supabase rejected the request before your code ran (see JWT verification below).
- 500 — your handler ran and threw. Usually the database write.
- 200, but no row — the handler ran, returned OK, and swallowed the error. This is the nasty one, and it is nearly always RLS.
A 200 with no row means the failure is in your own code path. Do not start rotating keys until you have looked at this screen.
Cause 1: signature verification in an Edge Function
Stripe signs the exact bytes it sent. If anything reads or re-serializes the body first, the computed signature will not match and constructEvent throws. In a Deno Edge Function you must read the body as text, and you must use the async variant — the synchronous one relies on Node crypto that is not available in that runtime.
import Stripe from "https://esm.sh/stripe@18?target=deno";
const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, {
httpClient: Stripe.createFetchHttpClient(),
});
const cryptoProvider = Stripe.createSubtleCryptoProvider();
Deno.serve(async (req) => {
const signature = req.headers.get("stripe-signature")!;
const body = await req.text(); // raw text, never req.json()
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature,
Deno.env.get("STRIPE_WEBHOOK_SECRET")!,
undefined,
cryptoProvider,
);
} catch (err) {
console.error("signature failed", err);
return new Response("bad signature", { status: 400 });
}
// ... handle event
return new Response("ok", { status: 200 });
});Two more things bite people here. The webhook secret from the Stripe CLI (whsec_… printed by stripe listen) is different from the one on your deployed endpoint — using the local one in production is a guaranteed 400. And Supabase Edge Functions verify a Supabase JWT by default, which Stripe does not send; deploy the webhook with --no-verify-jwt (or the equivalent verify_jwt = false in config.toml) or every delivery dies at the gateway before your code runs.
Cause 2: RLS is blocking the insert (the 200-with-no-row case)
Supabase row-level security denies everything by default once enabled. That is the correct behaviour and you should not turn it off. But a webhook has no logged-in user — auth.uid() is null — so any policy written as auth.uid() = user_id will reject the insert. If you are using the Supabase client with the anon key inside your Edge Function, your webhook is an anonymous request.
The fix is not a permissive insert policy. Never write using (true) on a subscriptions table — that lets any browser with your public anon key grant itself a plan. Instead, have the webhook use the service-role key, which bypasses RLS by design and is only ever readable server-side:
const admin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, // server-only, bypasses RLS
{ auth: { persistSession: false } },
);
const { error } = await admin.from("subscriptions").upsert({
user_id: userId,
stripe_customer_id: customerId,
stripe_subscription_id: subscriptionId,
status: "active",
}, { onConflict: "stripe_subscription_id" });
if (error) {
console.error("db write failed", error);
return new Response("db error", { status: 500 }); // let Stripe retry
}Note the if (error). The Supabase JS client does not throw on a failed write — it returns { data, error }. Ignore the error object and your handler returns 200, Stripe marks the delivery successful and never retries, and your table stays empty. That is exactly how a payment gets taken with no access granted.
Then keep RLS strict for readers: select policy of auth.uid() = user_id, and no client-side insert or update policy on that table at all. The only writer is the webhook.
Cause 3: you have no user id to write against
checkout.session.completed tells you about a Stripe customer, not about your user. If you did not put your own id on the session, the webhook cannot know whose row to write, and people paper over this by matching on email — which breaks the moment someone pays with a different address than they signed up with.
Set metadata when you create the session, server-side, from a verified session — never from a value the browser posted:
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
client_reference_id: user.id,
metadata: { user_id: user.id },
subscription_data: { metadata: { user_id: user.id } },
success_url: `${origin}/app?checkout=success`,
});The subscription_data.metadata line matters more than it looks: renewals and cancellations arrive as customer.subscription.* events months later, with no checkout session attached. Without metadata on the subscription object itself, those events reach you with no way back to a user.
Cause 4: you are granting access somewhere other than the webhook
If your success page writes the subscription row, your table will be wrong on a regular basis — the user closes the tab, the redirect fails, or someone simply visits /success directly and grants themselves a plan. The webhook is the only source of truth, because it is the only signal that is signed, retried, and independent of the browser.
Two rules that eliminate most of this class of bug for good: money is written server-side only, and the handler must be idempotent. Stripe retries on any non-2xx and can deliver the same event twice even on success, so store processed event.ids and skip duplicates, and use upsert keyed on the subscription id rather than insert. Return 500 only when a retry could actually help — an unknown price id or a missing user should return 200 with a log line, because retrying a mapping mistake forever just fills your logs.
The ten-minute debugging order
- Stripe dashboard → webhook deliveries. Note the status code and the response body.
- Run
stripe listen --forward-to localhost:54321/functions/v1/stripe-webhookand replay withstripe trigger checkout.session.completed. If local works and production does not, it is the secret or JWT verification, not your logic. - Check the Edge Function logs in the Supabase dashboard for the actual error string. If you see nothing at all, your function was never invoked — that is a gateway or URL problem.
- Log the
errorobject from every Supabase write. A message likenew row violates row-level security policyis your answer in one line. - Confirm the session carried
metadata.user_idby opening the event payload in the dashboard.
Why this keeps happening
None of these are hard problems individually. They are hard because they fail quietly, in production, after money has moved — and because the pieces (raw body handling, signature verification, service-role writes, strict RLS, idempotency, metadata threading) all have to be right at the same time before a single subscription saves correctly. Most tutorials show the happy path and stop, which is why the same thread gets posted to r/SaaS every week.
Or start from a stack where it is already wired
Weekendstack takes the same discipline and ships it done, on Next.js + Firebase + Stripe: the webhook reads the raw body and verifies the signature, every event id is recorded so duplicates are ignored, and entitlements are recomputed from a purchase ledger rather than toggled — so refunds and out-of-order events converge instead of corrupting access. Access is never granted from the browser, the success redirect, or a client callback. The Firestore rules are the equivalent of strict RLS: clients cannot write the collections that decide who paid.
If you would rather spend your weekend on your actual product than on webhook forensics, see Weekendstack. Related reading: Supabase vs Firebase for a SaaS MVP and the SaaS security checklist.