Short answer
If Stripe shows a successful payment but your app still says pending, your webhook is not reaching your database. Open Stripe → Developers → Webhooks, find the event, and read the response code: a 400 means the signature check failed, a 401 usually means a Supabase Edge Function rejected the call, and a 200 with no update means the database write itself failed.
Why the success page is not enough
Many AI-built apps mark an order as paid when the customer lands on the success page. If the customer closes the tab, loses connection or uses a payment method that settles later, that page never loads and the order stays pending. The webhook from Stripe is the only reliable signal, so it should be the only thing that marks an order as paid.
1. No webhook endpoint in live mode
Stripe keeps test mode and live mode separate. An endpoint you created while testing does not exist in live mode, and each mode has its own signing secret. After going live, add the endpoint again in live mode and put the live signing secret (it starts with whsec_) in your production environment.
2. The signature check fails (response 400)
Stripe signs the exact bytes it sends. If your code parses the body as JSON before checking the signature, the check fails. Read the raw text first and verify that.
const body = await req.text(); // raw body, not req.json()
const signature = req.headers.get('stripe-signature')!;
const event = await stripe.webhooks.constructEventAsync(
body,
signature,
Deno.env.get('STRIPE_WEBHOOK_SECRET')!
);3. The Edge Function rejects Stripe (response 401)
Supabase Edge Functions check for a Supabase user token by default. Stripe does not send one, so every webhook is refused before your code runs. Turn off JWT verification for the webhook function only, and rely on the Stripe signature instead.
supabase functions deploy stripe-webhook --no-verify-jwt
# or in supabase/config.toml
[functions.stripe-webhook]
verify_jwt = false4. Row-level security blocks the update (200, but nothing changes)
If the webhook uses the public anon key, row-level security treats it as an anonymous visitor, and the update to the orders table is silently blocked. On the server only, create the Supabase client with the service role key for webhook writes. Never put the service role key in frontend code.
5. Listening for the wrong event
- For Stripe Checkout, handle checkout.session.completed and read your order id from the session metadata or client_reference_id.
- For payment methods that settle later, also handle checkout.session.async_payment_succeeded, and only mark the order paid when payment_status is paid.
- For subscriptions, keep your plan in sync from customer.subscription.updated and customer.subscription.deleted, not from the success page.
6. Duplicate or out-of-order events
Stripe retries events and does not promise their order. Store each event id and skip ones you have already processed, and make the update safe to run twice (set status to paid rather than adding to a balance).
A quick test
- Run stripe listen --forward-to <your webhook URL> and stripe trigger checkout.session.completed against a test account.
- Confirm the event shows 200 in the Stripe dashboard and the order row changes in Supabase.
- Repeat once in live mode with a small real payment before launch.