Fix Stripe payment status not updating in Supabase
Learn why your Stripe webhooks might fail to update user subscription statuses in your database and how to keep records in sync with payments.
A user types in their credit card, the payment goes through, and Stripe charges them. They are redirected back to your app, but their account still says they are on the free plan. They refresh the page, but nothing changes.
This happens when the communication between Stripe and your Supabase database breaks down. Stripe knows the payment succeeded, but your app never processed it.
Fixing this requires looking at how your app listens for Stripe events.
1 Updating the database from the browser
Check your frontend code. If you run a Supabase update query right after the user returns from the Stripe checkout page, you cannot trust the browser to update the payment status. A user can close the tab before the redirect happens, which means the code to update Supabase never runs. They get charged, but their account remains locked. A malicious user can also fake a visit to the success page to grant themselves premium access without paying.
You need a webhook to handle this. A webhook is an API route on your server that Stripe calls directly when a payment succeeds. In a Next.js app, this is usually an API route. For a Lovable or Bolt single page app, this is often a Supabase Edge Function.
When a user pays, Stripe calls your webhook in the background. Your webhook then updates Supabase.
2 The webhook is using the wrong Supabase key
Go to your Stripe dashboard. Click on Developers and then click on Webhooks. Find your endpoint and look at the failed events. If you see HTTP 500 or 401 errors, your server is rejecting Stripe's request.
When Stripe calls your webhook, there is no logged-in user making the request. If your webhook tries to update the database using the standard Supabase anonymous key, Row Level Security will block it.
Your webhook code must use the Supabase service role key to bypass Row Level Security.
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY
)
The service role key gives full admin access to your database. It must never reach the browser. Keep it entirely in your server environment variables. In Next.js, ensure the variable name does not start with NEXT_PUBLIC_. In a Vite app, do not prefix it with VITE_.
3 The webhook secret is missing or mismatched
Check your webhook server logs. If your code verifies the Stripe signature and throws an error about a mismatch, your server will reject the payload.
Every Stripe webhook endpoint has a unique signing secret. It usually starts with whsec_.
- Open the Stripe dashboard and go to your webhook endpoint settings.
- Reveal the signing secret and copy it.
- Add it to your server environment variables as STRIPE_WEBHOOK_SECRET.
- Restart your local server or trigger a redeploy for your hosted backend.
Make sure your code uses this exact secret to construct the Stripe event before doing any database updates.
const event = stripe.webhooks.constructEvent(
rawBody,
signatureHeader,
process.env.STRIPE_WEBHOOK_SECRET
);
If you test locally using the Stripe CLI, the CLI generates a different webhook secret for your local machine. You must use that local secret in your local environment file, and the live secret in your production environment variables.
4 Webhook retries and race conditions
A user upgrades, then cancels immediately. Your database shows them as upgraded, even though the cancellation event happened last.
Stripe sends multiple events like checkout.session.completed and customer.subscription.updated. Sometimes these arrive out of order. If your webhook fails or your server is slow, Stripe will retry sending the event for up to three days.
Your database updates must be idempotent. If the same webhook is processed twice, it should not break your data or create duplicate records.
To fix this, store the Stripe event ID in a dedicated Supabase table when you process it.
- Create a table in Supabase called stripe_events with a single text column for the event ID.
- Set the event ID column as the primary key.
- In your webhook code, before updating the user profile, attempt to insert the event ID into this table.
- If the database throws an error because the ID already exists, stop processing.
- Return a 200 success code to Stripe so it stops retrying.
This ensures you process a specific payment event once, even if Stripe sends it to your server multiple times.
Still stuck?
If your Stripe and Supabase setup is still dropping updates or randomly failing, this is the kind of fix I do.
Quick answers
Why is the Supabase service role key needed for Stripe webhooks?
Stripe webhooks run in the background without a logged-in user session. The service role key allows your server to bypass Row Level Security and update the user payment status.
How do I test Stripe webhooks locally?
You can use the Stripe CLI to forward webhook events to your local server. This provides a temporary webhook secret you can place in your local environment variables to verify your code updates Supabase.
What happens if my webhook server goes down?
Stripe will retry sending the webhook event for up to three days. Once your server is back online, it will receive the pending events and update Supabase.