Webhook Not Firing — Provider 200, Endpoint Silent

Provider says webhook delivered, your endpoint never sees it.

Stripe dashboard says “Delivery succeeded,” your server logs show nothing. GitHub’s “Recent Deliveries” is full of red Xs. Shopify keeps retrying. When webhook-dependent business logic breaks (grant access after payment, deploy on PR merge), broken webhooks = broken product.

Triage core: check the provider side first — did they say it was delivered, what status did they get back. Provider dashboards almost always have full delivery logs. Then verify your endpoint is actually reachable.

Common causes

Ordered by hit rate, highest first.

1. Endpoint returns non-2xx, provider retries exhausted

Provider sees 4xx/5xx and retries with exponential back-off a few times (Stripe retries up to 3 days), then gives up. What you see is “the last failure.”

How to spot it: Provider dashboard shows “failed delivery” with an HTTP status.

2. Webhook URL typo

✅ https://api.yourdomain.com/webhooks/stripe
❌ https://api.yourdomain.com/webhook/stripe   # missing s
❌ https://yourdomain.com/webhooks/stripe       # missing api subdomain

How to spot it: Copy URL from dashboard into browser or curl — does it land?

3. Endpoint only reachable internally (localhost / private network)

You configured http://localhost:3000 in dev, but webhooks come from the public internet — never reaches your laptop.

How to spot it: URL contains localhost / 127.0.0.1 / private IP (10.x, 192.168.x).

4. Endpoint slow, judged timeout

Webhook providers have a response timeout (Stripe 30s, many providers 5-10s). If your handler synchronously runs LLM / email / long query, it times out → retry.

How to spot it: Endpoint processing time > 5s and provider reports timeout.

5. Signature verification blocking

Provider sends a signed header (Stripe’s stripe-signature, GitHub’s x-hub-signature-256); your endpoint rejects with 401 on verification failure.

How to spot it: 401/403 with body containing “signature mismatch.”

6. Firewall / CDN blocks unusual user-agent

Cloudflare / WAF sees Stripe/1.0 non-browser UA and rejects as bot.

How to spot it: Cloudflare logs show blocks for provider IP / UA.

7. Handler throws unhandled exception → server 500

Code bug crashes handler → framework returns 500 → provider retries.

How to spot it: Server log shows stack trace.

Shortest path to fix

Step 1: Read provider delivery logs

Stripe:    Developers → Webhooks → click endpoint → "Recent events"
GitHub:    Repo Settings → Webhooks → click → "Recent Deliveries"
Shopify:   Settings → Notifications → Webhooks → view attempts
Linear:    Settings → API → Webhooks → Deliveries
Slack:     api.slack.com/apps → Event Subscriptions → Recent

Each attempt shows:

  • Timestamp
  • HTTP status code (200 / 404 / 500 / …)
  • Response body (first few KB)
  • Request body (payload)

Status → symptom mapping:

StatusMeans
200-299Delivered fine, your endpoint received → internal handler issue
404URL wrong or route not deployed
401/403Signature / auth rejected
500Endpoint threw exception
TimeoutEndpoint too slow
No attemptsWebhook config disabled / event not subscribed

Step 2: curl test endpoint reachability

# From a public network (mobile hotspot / server), not corp wifi
curl -i -X POST 'https://api.yourdomain.com/webhooks/stripe' \
  -H 'Content-Type: application/json' \
  -d '{"test": true}'

# Should return 2xx or a specific 4xx within 5s

curl fails → endpoint reachability issue. curl works → provider config issue.

Step 3: Dev endpoints via ngrok

# Start server
npm run dev   # http://localhost:3000

# Another terminal
ngrok http 3000
# Forwarding https://abc123.ngrok.io -> http://localhost:3000

# Put https://abc123.ngrok.io/webhooks/stripe into provider config
# Webhooks now hit your local code

Or use Stripe CLI:

stripe listen --forward-to localhost:3000/webhooks/stripe
# Simulates delivery to local

Step 4: Refactor handler to “ack now, process async”

// ❌ Synchronous (slow → timeout)
app.post('/webhooks/stripe', async (req, res) => {
  await processEvent(req.body);  // 30 seconds
  res.json({ received: true });
});

// ✅ Immediate ack + async processing
app.post('/webhooks/stripe', async (req, res) => {
  // 1. Verify signature
  const valid = verifySignature(req.headers['stripe-signature'], req.rawBody);
  if (!valid) return res.status(401).end();

  // 2. Enqueue for background processing
  await enqueue({ task: 'process-stripe-event', payload: req.body });

  // 3. 200 immediately (< 1 second)
  res.json({ received: true });
});

Provider sees success. You process at leisure.

Step 5: Signature verification done right

// Stripe example
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;

app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),  // need raw body to verify
  async (req, res) => {
    let event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        req.headers['stripe-signature']!,
        endpointSecret
      );
    } catch (err) {
      console.error('Signature verify failed:', err);
      return res.status(400).send(`Webhook Error`);
    }

    // Process event ...
    res.json({ received: true });
  }
);

Note: must use the raw body (don’t JSON-parse before verifying), and endpointSecret must match the value in the provider dashboard.

Step 6: CDN / WAF allow rule

Cloudflare:
  WAF → Custom Rules → Skip:
    URI Path equals /webhooks/stripe
    AND IP in [Stripe IP range]

Stripe / GitHub / Shopify publish their IP ranges — allowlist them.

Step 7: Endpoint should log every webhook

First line of every handler:

app.post('/webhooks/stripe', async (req, res) => {
  console.log('webhook received:', {
    event_type: req.body.type,
    id: req.body.id,
    timestamp: Date.now(),
  });
  // ...
});

No log + provider says delivered → never reached your server. Check CDN / routing.

Prevention

  • When configuring a webhook, first validate URL reachability with webhook.site
  • Handler template is always “ack immediately + process async”; over 2s isn’t safe
  • Verify signature before enqueueing, both before the response; don’t return 200 first then verify (lets attackers enqueue arbitrary payloads)
  • Make handlers idempotent (dedup by event_id) so provider retries don’t double-process
  • Add per-endpoint metrics: delivery success rate, processing time, 429 / 500 counts
  • Use ngrok or the provider’s own CLI (Stripe CLI / GitHub webhook tester) for dev
  • Webhook secret in provider dashboard must match prod env; rotate together
  • For multiple services on the same secret, use one endpoint with internal routing (reduces config sprawl)

Tags: #Backend #Debug #Troubleshooting