Weekendstack

Blog

Stripe Subscription Not Saving to Supabase? Fix the Webhook, RLS, and Metadata (2026)

August 18, 2026 · 9 min read

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:

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

  1. Stripe dashboard → webhook deliveries. Note the status code and the response body.
  2. Run stripe listen --forward-to localhost:54321/functions/v1/stripe-webhook and replay with stripe trigger checkout.session.completed. If local works and production does not, it is the secret or JWT verification, not your logic.
  3. 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.
  4. Log the error object from every Supabase write. A message like new row violates row-level security policy is your answer in one line.
  5. Confirm the session carried metadata.user_id by 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.

Frequently asked

Why is my new Stripe subscription not saving to the database in Supabase?
Almost always one of four causes: the webhook signature check fails so your handler never runs (400 in the Stripe dashboard), Supabase's JWT verification rejects the request before your code runs (401), row-level security blocks the insert because you used the anon key instead of the service-role key (200 with no row), or you never attached your user id to the checkout session so the handler has nothing to write against. Check the webhook delivery status codes in the Stripe dashboard first — they tell you which half of the problem you have.
How do I verify a Stripe webhook in a Supabase Edge Function?
Read the body with await req.text() — never req.json(), because Stripe signs the exact bytes it sent. Then call stripe.webhooks.constructEventAsync() with Stripe.createSubtleCryptoProvider(), since the synchronous variant needs Node crypto that Deno does not provide. Use the webhook secret from your deployed endpoint, not the whsec_ value printed by the Stripe CLI.
Does Supabase RLS block my Stripe webhook writes?
Yes, if the webhook uses the anon key. RLS denies everything by default and a webhook has no logged-in user, so auth.uid() is null and any policy of the form auth.uid() = user_id rejects the insert. Use the service-role key inside the Edge Function — it bypasses RLS by design and is server-only. Do not add a permissive insert policy; that would let any browser holding your public anon key grant itself a plan.
Why does my webhook return 200 but write nothing?
The Supabase JS client does not throw on a failed write — it returns { data, error }. If you ignore the error object, the handler returns 200, Stripe marks the delivery successful and never retries, and the row is silently never created. Always check error and return a 500 for retryable failures.
Do I need verify_jwt = false on a Stripe webhook function?
Yes. Supabase Edge Functions verify a Supabase JWT by default and Stripe does not send one, so deliveries are rejected at the gateway before your code runs. Deploy with --no-verify-jwt, or set verify_jwt = false in config.toml. The Stripe signature check is what authenticates the request instead.
Should the success page grant the subscription instead?
No. The user can close the tab, the redirect can fail, and anyone can visit the success URL directly. The webhook is the only signal that is signed, retried, and independent of the browser, so it must be the only writer of entitlements. Make it idempotent by recording processed event ids and upserting on the Stripe subscription id.

Build yours this weekend

Weekendstack is the production-ready codebase plus the prompt pack that makes your AI coding agent ship real features instead of guessing.

See pricing