DEV Community

Cover image for Customer paid but didn't get access? 4 reasons your Stripe webhook is silently failing
WebhookProof
WebhookProof

Posted on

Customer paid but didn't get access? 4 reasons your Stripe webhook is silently failing

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.com and www.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 trailingSlash docs). So /api/webhooks/stripe/ gets a redirect. With trailingSlash: true it's the reverse.
  • Redirect rules. In Next.js redirects(), permanent: true returns 308 and permanent: false returns 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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).*)'],
};
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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_status isn't unpaid. 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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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)