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.

11 min read Updated 26 Sep 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 single lost event cannot cost a paying customer their access.

    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, Gemmein receives Stripe's webhooks, so there is no webhook handler for you to write. Gemmein records who holds what, and your app never writes subscription state: Stripe's webhooks write it, or a relay you configured. The paid webhook grants the plan's key, the engine keeps one subscription per customer, and events that arrive out of order resolve to the newest, so your app only reads the result.

  1. Paste the signing secret and tick the eight events

    On the dashboard's Payments page, name your plans, paste one Stripe signing secret and set how each paid plan is sold. On the Stripe webhook, tick all eight events: checkout.session.completed, invoice.paid, customer.subscription.updated, customer.subscription.deleted, charge.refunded, invoice_payment.paid, checkout.session.async_payment_succeeded and checkout.session.async_payment_failed.

  2. Send buyers to checkout through the SDK

    The upgrade button needs one call, and Gemmein sends the signed-in user to the right Stripe checkout with the buyer and plan wired in. If the call errors with plan_has_no_link, paste that plan's Payment Link in the dashboard.

    await g.subscriptions.checkout("pro")
  3. Lock paid data to the plan, not to the redirect

    Pick the plan by name in the "Unlocked by" row on the Collections page, and the engine refuses customers without it under all seven rules. Gate on the entitlement, never on the redirect coming back, and show your upgrade screen when a call returns entitlement_required.

    try {
      const { records } = await g.collection("lessons").list()
    } catch (err) {
      if (err.code === "entitlement_required") showUpgrade()
    }
  4. Read the plan for the interface

    To show or hide paid features, read the subscription Gemmein recorded. You get the same answer whether Stripe or a relay wrote it.

    const sub = await g.subscriptions.mine()
    if (sub?.plan === "pro") showPro()

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

← Back to the full report