Troubleshooting guide

Stripe webhook not granting access after payment: how to fix it

The customer pays and Stripe shows the charge, but your app still shows the paywall. Access depends on a webhook that your server has to receive, verify and act on. Any one of those steps can fail in production without an obvious error. It is among the most frequent go-live failures in our data: 17 verified builder reports so far, across several stacks.

17 verified cases12 min read Updated 25 Oct 2026Last reviewed 11 Nov 2026How we collect cases

What you'll see

  • The payment shows as succeeded in the Stripe Dashboard, but the customer still sees the paywall or an unpaid plan.
  • The endpoint's Event deliveries tab lists failed attempts, and your logs show "No signatures found matching the expected signature for payload".
  • The webhook works with stripe listen on your machine, but in production it returns 400, 401, 500 or 503.
  • The first payment grants access, but a paid renewal invoice never extends it, so access lapses at the end of the period.
  • Some customers are granted access twice (duplicate orders or credits), or their access reverts to an earlier state after a subscription change.

Why it happens

Cause 1

The body was parsed before verification

Stripe signs the exact bytes it sends, and the signature check needs that unmodified UTF-8 body. If a framework has already parsed the body as JSON or re-encoded it, the check fails. The usual causes are express.json() mounted before the webhook route, and the Next.js Pages Router body parser left on.

Cause 2

The wrong signing secret is set in production

Every endpoint has its own whsec_ signing secret. The secret that stripe listen prints differs from the secret of an endpoint registered in the Dashboard, and a sandbox endpoint's secret differs from the live one. If you copied the value from local testing into production, every live event fails verification.

Cause 3

The live endpoint is missing or not subscribed to the right events

Webhook endpoints are registered separately for sandbox and live mode, so a working test setup registers nothing in live mode. Once registered, the endpoint only receives the event types you selected. If none of checkout.session.completed, checkout.session.async_payment_succeeded or invoice.paid is selected, nothing ever grants access.

Cause 4

The request never reaches your code

When production environment variables such as the API key or webhook secret are missing, the handler throws and returns 5xx. A platform layer in front of the function can also reject Stripe's request before your code runs. Supabase Edge Functions, for example, verify a JWT by default and return 401. In live mode Stripe retries a failed delivery with exponential backoff for up to three days, then stops.

Cause 5

The handler is not idempotent and assumes event order

Stripe can deliver the same event more than once, and it does not guarantee that events arrive in the order they were created. A handler that inserts a row per event grants access twice. A handler that applies events in arrival order can overwrite a newer subscription state with an older one.

How to fix it

  1. Verify against the raw request body

    In a Next.js App Router route handler, read the body with request.text() and pass that string to constructEvent. In Express, mount the webhook route before app.use(express.json()). On Supabase Edge Functions (Deno), use constructEventAsync with Stripe.createSubtleCryptoProvider().

    // app/api/stripe/webhook/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, never req.json() here
      const sig = req.headers.get('stripe-signature') ?? ''
      let event: Stripe.Event
      try {
        event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
      } catch {
        return new Response('Invalid signature', { status: 400 })
      }
      await handleOnce(event) // idempotent, see below
      return new Response('ok', { status: 200 })
    }
  2. Register the live endpoint and use its own secret

    In live mode, add the endpoint in the Dashboard under Workbench, Webhooks, pointing at your production HTTPS URL. Subscribe it to the events you act on, typically checkout.session.completed, checkout.session.async_payment_succeeded, invoice.paid, customer.subscription.updated and customer.subscription.deleted. Copy that endpoint's whsec_ secret into your production environment, then redeploy so the new value is picked up.

  3. Let Stripe's request reach the handler

    Make sure the production environment has both the secret key and the webhook secret, and exit at startup with a clear error if either is missing. On Supabase, turn off platform JWT verification for this one function and rely on the Stripe signature instead.

    # supabase/config.toml
    [functions.stripe-webhook]
    verify_jwt = false
  4. Make the handler idempotent by event id

    Record each processed event id in the same transaction as the grant, so a retry or a duplicate becomes a no-op. Keep the handler short and return 2xx before any slow work such as emails or accounting sync. Otherwise the delivery times out and Stripe retries it.

    create table stripe_events (
      id text primary key,  -- evt_...
      processed_at timestamptz not null default now()
    );
    
    -- in the same transaction as the grant:
    insert into stripe_events (id) values ($1)
    on conflict (id) do nothing
    returning id;  -- no row back = already handled, return 200
  5. Read current state instead of trusting event order

    When an event arrives, fetch the current Checkout Session or Subscription from the API and decide from that. For Checkout, grant when payment_status is not unpaid. For subscriptions, grant while status is active or trialing, and revoke on canceled or unpaid.

  6. Reconcile on sign-in or on a schedule

    Stripe recommends also triggering fulfillment from the success page, because webhooks can be delayed. Beyond that, check the customer's subscription on sign-in or on a schedule, so a lost event is corrected on the next check.

    const subs = await stripe.subscriptions.list({ customer: customerId, status: 'all', limit: 10 })
    const hasAccess = subs.data.some(s => s.status === 'active' || s.status === 'trialing')

Check it's fixed

  • Locally, run stripe listen --forward-to localhost:3000/api/stripe/webhook with the secret it prints, then stripe trigger checkout.session.completed, and confirm the handler logs a verified event.
  • After a real live purchase, open the live endpoint's Event deliveries tab and confirm a 2xx response for checkout.session.completed and, for subscriptions, invoice.paid.
  • Resend the same event twice with stripe events resend <event_id> --webhook-endpoint=<endpoint_id> (or Resend in the Dashboard), and confirm access is granted exactly once.
  • Delete a test customer's access row by hand, sign in as them, and confirm reconciliation restores it from Stripe's subscription state.

Fix it with Gemmein

On Gemmein, connecting a Stripe restricted key registers one webhook endpoint in your Stripe account with the events it needs, and Gemmein holds that endpoint's signing secret. The built-in Stripe path grants access and writes receipts, and subscription state is written by Stripe's webhooks (or, for a plan sold via a relay, by the relay configured for it), never by the app.

  1. Connect Stripe with a restricted key in each environment

    Paste an rk_test_ key on Development's Payments page and an rk_live_ key on Live's, and Gemmein registers one webhook endpoint with the eleven events the webhook needs. Going live does not copy Development's signing secret: Live makes its own once an rk_live_ key is connected there, and until then Live's checkout answers payments_not_configured (going live is still allowed).

  2. Send buyers to checkout through the SDK

    Call g.subscriptions.checkout on the upgrade button, and Gemmein sends the signed-in user to the right Stripe checkout with the buyer and plan wired in. Do not build checkout URLs, sessions or Payment-Link redirects yourself.

    await g.subscriptions.checkout("pro")
  3. Gate features on the subscription Gemmein records

    Read the plan with g.subscriptions.mine() and gate on it. Gemmein keeps exactly one subscription per customer, and events arriving out of order resolve to the newest.

    const sub = await g.subscriptions.mine()
    if (sub?.plan === "pro") {
      // show the paid feature
    }
  4. Check the Payments page, Overview and Errors

    Gemmein checks the webhook every 6 hours, and a webhook that has been deleted, disabled, pointed elsewhere or is missing events is named with "Repair webhook"; each problem shows on the Payments page, Overview and Errors and emails the owner once. A payment on a link this environment's key did not make is a Problem, never a grant.

Questions

Why does my webhook work with stripe listen but fail in production?

The secret that stripe listen prints only verifies events forwarded by the CLI. Production needs a live endpoint registered in the Dashboard, subscribed to your events, with its own whsec_ secret set in the production environment.


Can I just grant access on the success page instead?

Not on its own. Stripe notes that customers are not guaranteed to reach that page, for example if their connection drops after paying. Call the same idempotent fulfillment function from both the webhook and the success page.


How long does Stripe keep retrying a failed webhook?

In live mode, up to three days with exponential backoff; in a sandbox, three times over a few hours. After that you can resend manually from the Dashboard for up to 15 days, or with the Stripe CLI for up to 30 days.


Can I use the event's created timestamp to order events?

No. created has one-second resolution, and Stripe does not guarantee delivery order. Track event ids to skip duplicates, and fetch the current object from the API when order matters.


Sources

Last reviewed 11 Nov 2026. What changed: No changes: steps re-checked against 7 official page(s). Checked against: docs.stripe.com, docs.stripe.com, docs.stripe.com, docs.stripe.com, nextjs.org, supabase.com, supabase.com. How we check this

← Back to the full report