A customer emails you: "I paid, but I still can't log in." Stripe shows a successful payment. Your app shows nothing. No exception, no log line. The webhook that should have unlocked their account either never arrived or arrived and was handled wrong.
Below are the four most common causes, how to confirm each one, and a short fix for Node/Next.js and Supabase. Every claim links to the docs it comes from.
If you'd rather check first and read later, WebhookProof is a free tool that sends your webhook URL 3 harmless requests and tells you whether Stripe can reach it and whether it rejects unsigned events. (Not affiliated with Stripe, Inc.)
Before anything: read the delivery log
In the Stripe Dashboard, open your webhook endpoint and look at its event deliveries. Each attempt records the HTTP status your server returned, and that status usually points straight at one of the causes below:
| Status in the log | Most likely cause |
|---|---|
| 301 / 302 / 307 / 308 | #1 Redirect |
| 401 / 403 | #2 Auth wall |
| 200, but anyone can trigger it | #3 No signature verification |
| 400 "No signatures found matching the expected signature" | #4 Body was parsed before verification |
Stripe documents these delivery errors on its Receive events / webhooks page.
1. The endpoint redirects (www, https, trailing slash)
Stripe does not follow redirects on webhook deliveries. From the Stripe webhooks docs:
We consider redirect responses to webhook requests as failures. Set the webhook endpoint destination to the URL resolved by the redirect.
Common sources of a surprise redirect:
-
Apex vs www. Vercel lets you redirect between
example.comandwww.example.com(Vercel docs). If Stripe is pointed at the non-canonical host, every event bounces. -
Trailing slash. "By default Next.js will redirect URLs with trailing slashes to their counterpart without a trailing slash" (Next.js
trailingSlashdocs). So/api/webhooks/stripe/gets a redirect. WithtrailingSlash: trueit's the reverse. -
Redirect rules. In Next.js
redirects(),permanent: truereturns 308 andpermanent: falsereturns 307 (Next.js redirects docs). A broad rule can catch your webhook path. - http to https. Always register the https URL.
Confirm it:
curl -i -X POST https://example.com/api/webhooks/stripe
If the response is a 3xx with a location: header, that header is your real URL.
Fix: paste the exact final URL (host, scheme, slash and all) into the endpoint settings in the Dashboard, then resend a failed event from the Dashboard. Don't try to "fix" it by adding more redirects.
2. An auth wall is asking Stripe to log in
Stripe can't send a session cookie, a Supabase JWT or a Vercel login. If anything in front of your handler requires one, Stripe gets a 401/403 and your code never runs.
Supabase Edge Functions: JWT verification
Edge Functions check for a Supabase JWT by default. Supabase's own Stripe webhook example says: "Stripe verifies the request via its signature, so deploy with verify_jwt = false." The function configuration docs show how to do it for just that function:
# supabase/config.toml
[functions.stripe-webhook]
verify_jwt = false
That makes the function publicly callable, which is exactly why cause #3 (signature verification) is not optional.
Vercel Deployment Protection
If Deployment Protection covers the URL Stripe calls (often a preview or *.vercel.app URL), Stripe hits a login wall. The simplest fix is to point Stripe at your production domain. If you really need a protected deployment to receive webhooks, Vercel's Protection Bypass for Automation supports an x-vercel-protection-bypass query parameter, and the docs name Stripe as a case for it:
https://your-deployment.vercel.app/api/webhooks/stripe?x-vercel-protection-bypass=YOUR_SECRET
Your own middleware / proxy
Auth middleware that runs on every route will run on your webhook route too. Without a matcher, Next.js "Proxy runs on every request" (Next.js proxy docs; middleware has been renamed to proxy, same idea). Exclude the webhook path:
// proxy.ts (or middleware.ts on older Next.js)
export const config = {
matcher: ['/((?!api/webhooks/stripe|_next/static|_next/image|favicon.ico).*)'],
};
Same idea in Express: register the webhook route before your auth middleware, or skip that path in it.
3. No signature verification: anyone can fake a payment
This one doesn't show up as a failure at all, which is what makes it dangerous. If your handler does something like this:
// Don't do this
const event = await req.json();
if (event.type === 'checkout.session.completed') grantAccess(event.data.object);
then anyone who knows the URL can POST a hand-written checkout.session.completed and unlock your product. Webhook paths are predictable (/api/webhooks/stripe is in half the tutorials out there), so treat the URL as public.
Stripe signs every event with the endpoint's secret in the Stripe-Signature header and tells you to verify it with the official library (Stripe docs: verify events). The signed payload includes a timestamp, and "our libraries have a default tolerance of 5 minutes between the timestamp and the current time," which limits replays (Stripe webhooks docs).
Two extra guards worth adding:
-
Re-check with the API before granting access. Stripe's fulfillment guide retrieves the Checkout Session and checks
payment_statusisn'tunpaid. A forged event can't fake the API's answer. - Make fulfillment idempotent. The same guide warns your fulfill function "might be called multiple times, possibly concurrently, for the same Checkout Session."
async function fulfill(sessionId: string) {
const session = await stripe.checkout.sessions.retrieve(sessionId);
if (session.payment_status === 'unpaid') return;
// Use a unique constraint on session.id so a second call is a no-op
await db.grantAccessOnce(session.id, session.customer_details?.email);
}
Also watch the secret itself: "if you use the same endpoint for both test and live API keys, the secret is different for each one" (Stripe webhooks docs). Shipping to production with the test whsec_ (or the stripe listen secret) makes every live event fail verification.
Confirm it: send an unsigned request to your own endpoint.
curl -i -X POST -H 'content-type: application/json' -d '{}' \
https://example.com/api/webhooks/stripe
A correctly protected endpoint answers 400. A 200 means it accepts unsigned events.
4. Your framework parsed the body before you verified it
From the Stripe webhooks docs:
Stripe requires the raw body of the request to perform signature verification. If you're using a framework, make sure it doesn't manipulate the raw body. Any manipulation to the raw body of the request causes the verification to fail.
Parsing JSON and stringifying it again changes whitespace and key order, so the signature no longer matches. The Node library then throws "No signatures found matching the expected signature for payload." The tempting "fix" is deleting the check, which takes you straight back to cause #3.
Next.js App Router
Read the body as text and pass that exact string in. Don't call req.json() first. Route handlers give you a standard Request:
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text(); // raw body
const signature = req.headers.get('stripe-signature') ?? '';
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response('Invalid signature', { status: 400 });
}
if (
event.type === 'checkout.session.completed' ||
event.type === 'checkout.session.async_payment_succeeded'
) {
await fulfill((event.data.object as Stripe.Checkout.Session).id);
}
return new Response(null, { status: 200 });
}
Those two event types are the ones Stripe's fulfillment guide handles for Checkout. The second one covers delayed payment methods.
Next.js Pages Router
The API routes docs list webhook signature checks as the reason to turn off the body parser:
// pages/api/webhooks/stripe.js
export const config = { api: { bodyParser: false } };
async function rawBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
return Buffer.concat(chunks);
}
export default async function handler(req, res) {
const buf = await rawBody(req);
try {
const event = stripe.webhooks.constructEvent(buf, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET);
// ...handle event
res.status(200).end();
} catch {
res.status(400).send('Invalid signature');
}
}
Express
Give the webhook route express.raw() and register it before any global express.json():
app.post('/api/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET);
} catch {
return res.status(400).send('Invalid signature');
}
// ...handle event
res.sendStatus(200);
});
app.use(express.json()); // everything else
Supabase Edge Functions (Deno)
Deno needs the async verifier and a Web Crypto provider, as in Supabase's Stripe example:
import Stripe from 'npm:stripe';
const stripe = new Stripe(Deno.env.get('STRIPE_API_KEY')!);
const cryptoProvider = Stripe.createSubtleCryptoProvider();
Deno.serve(async (req) => {
const signature = req.headers.get('Stripe-Signature') ?? '';
const body = await req.text(); // raw body
let event;
try {
event = await stripe.webhooks.constructEventAsync(
body, signature, Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!, undefined, cryptoProvider,
);
} catch (err) {
return new Response((err as Error).message, { status: 400 });
}
// ...handle event
return new Response(JSON.stringify({ received: true }), { status: 200 });
});
The synchronous constructEvent doesn't work there, and neither does parsing JSON first.
One more thing: don't rely on the success page
Even with a perfect webhook, fulfilling only on the redirect page will eventually miss someone. Stripe says: "You can't rely on triggering fulfillment only from your checkout landing page, because it's not guaranteed customers visit that page" (Stripe fulfillment docs). Call the same idempotent fulfill() from both the webhook and the success page.
And once it's fixed, you don't have to wait for retries. In live mode Stripe retries failed deliveries for up to three days (Stripe webhooks docs), and you can resend a failed event from the Dashboard right away. For the customers who already paid, run fulfill() for their Checkout Session IDs.
Quick checklist
- [ ] The URL in Stripe is the final URL: no 3xx from
curl -i -X POST - [ ] No JWT, login, deployment protection or auth middleware in front of the route
- [ ] An unsigned POST gets a 400, not a 200
- [ ] Verification uses the raw body and the right
whsec_for the mode - [ ] Fulfillment re-checks the session via the API and is safe to run twice
If you want a second opinion on the first three, the free check at webhookproof.com runs them against your URL with 3 harmless requests and shows the results. WebhookProof is an independent tool, not affiliated with Stripe, Inc.
Top comments (0)