Incident pattern

Webhook returns 200 but nothing happens

The sender's dashboard (Stripe, Typeform, GitHub…) shows every delivery succeeding with a 200 — but the order isn't created, the record isn't updated, the notification never goes out.

  • Bubble
  • Supabase
  • Any stack
  • Webhooks

Root cause, in plain English

The sender only sees your HTTP status code, and a 200 proves something answered — not that your handler ran. Single-page apps make this vicious: an SPA fallback returns the index page with a 200 for any unknown path, so a webhook URL that drifted after a refactor or domain change looks perfectly healthy while hitting nothing. Handlers that swallow exceptions and still return success, or filter events into a silent no-op branch, produce the same lie.

How to fix it

  1. curl an obviously fake path on your domain (e.g. /definitely-not-a-route). If that also returns 200, you have an SPA fallback — compare your webhook URL against the real route table character-by-character.

  2. Inspect the response body the sender logs, not just the status: HTML where you expect JSON means the fallback page answered, not your handler.

  3. Add an entry log line as the handler's first statement and an exit line as its last. Deliveries that produce neither never reached the handler; deliveries with entry-but-no-exit died inside it.

  4. Check the handler's filtering: event-type conditions, signature checks, and try/catch blocks that return 200 on failure all convert real errors into silent successes. Return a 4xx/5xx when the work didn't happen, so the sender retries.

  5. Replay a real event from the sender's dashboard after fixing, and verify the side effect (row created, email sent) — not the status code.

Go deeper: copy the open-source health-check recipe.

How Nightlamp detects this automatically

  • API canary
  • Keyword check

An api_canary posts a synthetic event to your webhook endpoint and asserts the response shape your real handler produces — an SPA fallback or error-swallowing handler returns the wrong body and trips the alert even though the status is 200. An http_keyword check distinguishes handler output from fallback HTML, catching URL drift the day it happens.

Catch this before your customers do

Nightlamp runs these checks continuously against your live app and sends a plain-English diagnosis — not a wall of logs — the moment this pattern shows up.

Frequently asked questions

Why does the sender keep marking failed work as delivered?
Webhook contracts are status-code-only: 2xx means "done, don't retry." The sender cannot see inside your app. If your endpoint returns 200 while the work failed, the sender's view is technically correct and you've also forfeited its retry mechanism.
Should my handler do the work before or after responding?
Acknowledge fast, work async — most senders time out in seconds and retry, causing duplicates. But "accepted" must mean durably queued: respond 200 only after the event is stored somewhere a worker is guaranteed to process, and make processing idempotent.
How do I recover the events that were silently dropped?
Most senders keep an event log you can replay from the dashboard or API (Stripe retains events for roughly 30 days). Determine when the breakage started from your entry/exit logs, then replay everything since — idempotency keys prevent double-processing.

Newsletter

Get new incident patterns as we publish them

One email when new failure patterns, fixes, and monitoring recipes for no-code and AI-built apps land. No fluff, unsubscribe any time.

Double opt-in. One-click unsubscribe. No spam, ever.