Stripe took the money, but nothing happened. Here's why.
The customer paid. Stripe shows the payment. But your app never unlocked their account, never sent the welcome email, never marked the order as paid. Nine times out of ten, the message Stripe sends your app after a payment (the webhook) either never arrived or was rejected.
The good news: Stripe keeps a record of every attempt, with the exact reason it failed.
Why the webhook matters at all
Many AI-built apps unlock access when the customer reaches the success page. That breaks in two ways. Anyone can type the success address without paying, and anyone who closes the tab too early pays without getting access. The webhook is Stripe telling your server directly, with a signature, that a payment really happened. It should be the only thing that unlocks anything.
Step one: look at what Stripe saw
In the Stripe Dashboard, open the webhooks section (under Developers or Workbench, depending on your account), click your endpoint and look at recent deliveries. First make sure you are looking at the right mode: test and live have separate endpoints.
| What you see | What it usually means |
|---|---|
| No attempts at all | The endpoint is not set up in this mode, or it is not subscribed to the event you need. |
| 401 | Your server refused the request before your code ran. On Supabase this is almost always JWT verification (see cause 4). |
| 400 with a signature error | Wrong signing secret, or the body was changed before it was checked. |
| 404 | Wrong address: a typo in the function name, or an old preview URL. |
| 500 | Your code crashed after receiving the event. Check your function logs. |
| Timed out | Your code does too much before replying. |
The eight usual causes
1. The webhook only exists in test mode
Everything worked while you tested, then real payments went nowhere. Test mode and live mode have separate webhook endpoints and separate signing secrets. Create the endpoint again in live mode and put its secret in your live settings.
2. The wrong signing secret
Each endpoint has its own secret starting with whsec_. The Stripe CLI prints a different one for local testing. If your live app uses the CLI secret, or the test secret, every signature check fails.
3. The body was parsed before the signature check
Stripe signs the exact bytes it sent. If your code turns the body into JSON first and then checks the signature, the check fails. Read the raw text, verify it, and only then use the event.
4. Supabase rejects Stripe with a 401
Supabase Edge Functions expect a logged-in user's token by default. Stripe does not have one, so Supabase answers 401 and your code never runs. Turn off JWT verification for the webhook function only, in supabase/config.toml:
[functions.stripe-webhook]
verify_jwt = false
Or deploy it with supabase functions deploy stripe-webhook --no-verify-jwt. This is safe only because the function checks Stripe's signature itself. Keep verification on for every function your users call.
5. Listening to the wrong event
For a one-off payment through Checkout, checkout.session.completed is the usual signal. For subscriptions you also want invoice.paid (each renewal) and customer.subscription.updated and customer.subscription.deleted (plan changes and cancellations). If the endpoint is not subscribed to an event, Stripe never sends it.
6. The webhook can't tell which user paid
The event arrives, but your code has no way to match it to an account. Pass your user's ID when you create the Checkout Session, in client_reference_id or metadata, and read it back in the webhook.
7. Too much work before replying
Stripe wants a quick success reply. Sending emails, generating files or calling slow APIs before you answer can time out, and Stripe will count it as a failure and retry. Save the event, reply, then do the slow part.
8. The same event processed twice
Retries mean an event can arrive more than once. Store each event's ID and skip ones you have already handled, so nobody is unlocked twice or gets two welcome emails.
A working Supabase Edge Function
This follows the pattern in Supabase's own Stripe example. The secret key and signing secret come from your function's secrets, never from the frontend.
import Stripe from "npm:stripe";
const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!);
const cryptoProvider = Stripe.createSubtleCryptoProvider();
Deno.serve(async (req) => {
const signature = req.headers.get("Stripe-Signature");
const body = await req.text(); // raw text, not req.json()
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature!,
Deno.env.get("STRIPE_WEBHOOK_SECRET")!,
undefined,
cryptoProvider,
);
} catch (err) {
return new Response(`Bad signature: ${(err as Error).message}`, { status: 400 });
}
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.client_reference_id;
// Mark userId as paid here, skipping event.id if you have seen it before.
}
return new Response(JSON.stringify({ received: true }), { status: 200 });
});
Test it before a customer does
- With the Stripe CLI, run
stripe listen --forward-to localhost:54321/functions/v1/stripe-webhookand, in another window,stripe trigger checkout.session.completed. - After a fix, resend the failed events from the Stripe Dashboard instead of waiting for the next retry.
- In live mode, Stripe keeps retrying a failed event for up to three days, so customers who paid during the outage can still be unlocked automatically once the endpoint works.
Customers who already paid and got nothing
Fix the webhook first, then resend the failed events. For anything older than the retry window, compare your list of paid Stripe sessions with your list of unlocked accounts and grant the missing ones by hand. Then email those customers. A short, honest note keeps most of them.
Quick answers
Why is my Stripe webhook returning 401 on Supabase?
Supabase Edge Functions expect a user login token by default, and Stripe does not send one, so the request is rejected before your code runs. Turn off JWT verification for the webhook function only, and verify Stripe's signature inside the function instead.
Why does Stripe say the webhook signature is invalid?
Either the signing secret is wrong (test and live endpoints have different secrets, and the Stripe CLI uses its own), or the request body was parsed or changed before verification. Verify against the raw body text.
Does Stripe retry failed webhooks?
Yes. In live mode Stripe keeps retrying a failed event for up to three days, with growing gaps between attempts. You can also resend an event by hand from the Stripe Dashboard once the problem is fixed.