Skip to content

Stripe says paid but the order stays pending: fixing Stripe webhooks with Supabase

By Mohammad Shuja Uddin · 7 min read · Updated

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.

Supabase Edge Function (Deno): verify with the raw body
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.

Deploy the webhook function without JWT verification
supabase functions deploy stripe-webhook --no-verify-jwt

# or in supabase/config.toml
[functions.stripe-webhook]
verify_jwt = false

4. 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.

Send me your app link and what is broken.

You get back what is wrong, what I would fix first and a fixed price, within 24 hours. No call needed.