Tillping
Menu

Fix guides

Stripe webhook not firing

Short answer: First find out if Stripe even tried to send the event. Open your endpoint in Stripe and look at its list of sent events. If the event is not there, the problem is in your Stripe settings. Most often there is no endpoint in live mode, the event type is not picked, or the endpoint is turned off. If the event is there but marked as failed, Stripe did send it, and your server said no.

Signs you have this problem

  • A real payment went through, but your app did not unlock anything.
  • Your server logs show nothing at all for the webhook route.
  • It all worked in test mode, then stopped when you went live.
  • It works with stripe listen on your computer, but not on your live site.

Step 1: Did Stripe try to send it?

In Stripe, open Workbench, go to the Webhooks tab, and pick your endpoint. Then open the Event deliveries tab. Each event shows as Delivered, Pending or Failed. Click one to see the status code your server sent back.

  • The event is not in the list. Stripe never tried to send it to this endpoint. Go to Step 2.
  • The event is there, marked Failed or Pending. Stripe sent it, and your server did not answer with a 2xx. Go to Step 3.
  • It says Delivered with a 200, but nothing changed. Your route answered OK but did not do the work. Check your logs and the code that runs for that event type.

Step 2: Stripe never sent it

Cause 1: There is no endpoint in live mode

Sandbox (test) and live mode are separate. An endpoint you made while testing does not get live events. Your live site needs its own live endpoint, with its own whsec_ secret.

  • Switch Stripe to live mode, open Webhooks, and check that an endpoint exists for your real address.
  • Put the live whsec_ secret on your host, not only in your .env file. On Vercel, a change only applies to new deploys, so deploy again after you add it.

Cause 2: The event type is not picked

An endpoint only gets the event types you picked for it. Say your code waits for checkout.session.completed. If the endpoint only sends payment_intent.succeeded, your code never runs. Compare the endpoint's event list with the event types in your code. You can edit the list in Workbench.

Cause 3: The endpoint is turned off

An endpoint can be enabled or disabled. Stripe does not send events made while it is turned off. It also won't try to send them later on its own. Turn it back on, then catch up on what you missed (see Step 4).

Cause 4: You only used stripe listen

stripe listen works without any endpoint in your Stripe settings. It sends events to your computer while it runs. It also uses a sandbox unless you add --live. So "it works locally" does not mean your live site has an endpoint.

Cause 5: You use Connect and picked the wrong kind

This one is only for Connect platforms. When you make an endpoint, you pick Events on your account or Events on connected accounts. Events for things on your sellers' own accounts, like direct charges, only go to a connected-accounts endpoint.

Check all of this in one go

This small script reads your endpoints and tells you what is wrong. It only reads. It changes nothing. Run it in your own terminal, and never put the key in your code.

// check-stripe-webhooks.ts
// Run it in your own terminal. It only reads your settings. It changes nothing.
//   STRIPE_SECRET_KEY=sk_live_... npx tsx check-stripe-webhooks.ts https://yourapp.com/api/webhooks/stripe
// Never put the key in your code or commit it.
import Stripe from 'stripe'

// The event types your webhook code waits for. Copy them from your code.
const EVENTS_MY_CODE_NEEDS = ['checkout.session.completed']

// Same address except for www or a slash at the end? That causes a redirect.
function looksAlike(a: string, b: string): boolean {
  const clean = (u: string) => u.toLowerCase().replace('://www.', '://').replace(/\/+$/, '')
  return clean(a) === clean(b)
}

async function main() {
  const key = process.env.STRIPE_SECRET_KEY
  const myUrl = process.argv[2]
  if (!key || !myUrl) {
    console.error('Usage: STRIPE_SECRET_KEY=sk_... npx tsx check-stripe-webhooks.ts <your webhook URL>')
    process.exit(1)
  }

  const stripe = new Stripe(key)
  const mode = key.includes('_live_') ? 'live' : 'sandbox (test)'
  console.log(`Checking ${mode} mode for ${myUrl}`)

  const endpoints: Stripe.WebhookEndpoint[] = []
  for await (const ep of stripe.webhookEndpoints.list({ limit: 100 })) endpoints.push(ep)

  const exact = endpoints.filter((ep) => ep.url === myUrl)
  if (exact.length === 0) {
    console.log(`PROBLEM: no ${mode} endpoint uses this exact URL.`)
    for (const ep of endpoints) {
      const hint = looksAlike(ep.url, myUrl) ? '  <- almost the same (www or slash). Use one exact URL.' : ''
      console.log(`  found: ${ep.url}${hint}`)
    }
    if (endpoints.length === 0) console.log(`  There are no ${mode} endpoints at all.`)
    process.exitCode = 1
    return
  }

  for (const ep of exact) {
    console.log(`\nEndpoint ${ep.id}`)
    let ok = true
    if (ep.status !== 'enabled') {
      console.log('  PROBLEM: it is turned off (disabled). Turn it on in Workbench.')
      ok = false
    }
    if (!ep.enabled_events.includes('*')) {
      const missing = EVENTS_MY_CODE_NEEDS.filter((e) => !ep.enabled_events.includes(e))
      if (missing.length > 0) {
        console.log(`  PROBLEM: not sending ${missing.join(', ')}. It sends: ${ep.enabled_events.join(', ')}`)
        ok = false
      }
    }
    if (ok) console.log('  OK: turned on, and it sends the events your code needs.')
    else process.exitCode = 1
  }
  if (exact.length > 1) console.log('\nNOTE: more than one endpoint uses this URL. Each one sends its own copy of each event.')
}

main().catch((err) => {
  console.error('Could not read your endpoints:', err.message)
  process.exit(1)
})

Run it once with your live key and once with your sandbox key. Each key only sees the endpoints of its own mode. A * in the event list means "all events", except a few that you must pick by name. You need the stripe and tsx packages (npm i stripe tsx).

Have the Stripe CLI? stripe webhook_endpoints list --live shows the same data with no script.

Step 3: Stripe sent it, and your server said no

Look at the status code on the failed try. Each one points to a different fix:

What Stripe shows What it means Guide
404 The route is not at that address https://tillping.online/fix/stripe-webhook-404-nextjs-app-router
301, 302, 307, 308 Your site sent Stripe somewhere else https://tillping.online/fix/webhook-redirect-307-308
401 from Supabase The function wants a login token https://tillping.online/fix/supabase-edge-function-stripe-webhook-401
403 A firewall or bot check is in the way https://tillping.online/fix/stripe-webhook-blocked-by-cloudflare-403
400 Often your own signature check failed https://tillping.online/fix/stripe-webhook-signature-verification-failed
500 Your code crashed. Read your logs https://tillping.online/fix/stripe-webhook-works-locally-fails-in-production

Step 4: Catch up on missed payments

Fixing the setup does not bring back events that were never sent. Here is how to catch up:

  • Resend from Stripe. In Event deliveries, open a failed event and send it again.
  • Ask Stripe for the list. The List Events API can return only the events that failed to reach at least one of your endpoints (delivery_success=false). It goes back 30 days at most.
  • Watch for repeats. In live mode, Stripe keeps retrying a failed event for up to three days, even after you resend it by hand. Skip any event ID you already handled. See https://tillping.online/fix/stripe-webhook-duplicate-events.

Check your endpoint with a free scan

Paste your webhook address into the free Tillping scan: https://tillping.online/#scan, and pick Stripe. No account is needed. Only scan an address you own or are allowed to test.

The scan covers the "Stripe sent it, and your server said no" side. It sends a few safe test requests, with no real payment. One has a fake Stripe-Signature. It warns you if the address returns a 404, does not accept POST (405), redirects, crashes (5xx), or is slow. It also flags a Supabase login check, a Vercel login wall, a Cloudflare challenge and a bad certificate. And it warns you if your endpoint says OK to a fake signature or to no signature.

What it can't do: the scan can't see inside your Stripe account. It can't tell you if a live endpoint exists, which events are picked, or if the endpoint is turned off. Use Step 1 and the script above for that.

Sources

Check your webhook now

Paste your webhook address to see what it answers. Free, no account, no keys.

Check my webhook

Get an email when Deep Scans open

One email to confirm, then one when we launch. Leave any time.

We send one email to confirm. You can leave at any time.