Skip to content
TryWebhook
New URL

Guide · Last reviewed

Debugging webhook 400 and 401 errors

A failed delivery gives you one number and no context. But each status code narrows the problem to a specific layer — and knowing which layer answered is most of the diagnosis.

Step by step

Diagnose a failing webhook delivery

  1. Read the exact status and response body from the delivery log

    Open the provider's delivery log — Stripe's Events tab, GitHub's Recent Deliveries, Shopify's notification log — and read the status code and the response body it recorded. A 401 with an empty body and a 401 with your own error message point at different layers.

  2. Determine whether your code ran at all

    Search your own logs for the delivery id from the request headers. If it is absent, something in front of your application answered: a proxy, a CDN, a load balancer, or the framework's routing. Fix that before touching the handler.

  3. Capture the same delivery on a URL with no logic

    Point the provider at a temporary capture URL and trigger the event again. Whatever arrives there is the unmodified request. If a header your handler needs is missing at the bin, the provider never sent it; if it is present at the bin but absent in your app, something between them removed it.

  4. Compare the captured request against what your handler received

    Diff three things: the raw body byte length, the exact header names, and the content type. Differences in any of them explain most 400 and 401 responses, and all three are visible side by side.

  5. Reproduce the request locally with cURL

    Copy the captured request as cURL and send it at your own endpoint. You now have a deterministic, repeatable failure you can attach a debugger to, instead of waiting for the provider to send another event.

  6. Fix, redeploy, and replay the original delivery

    Use the provider's resend button so the retry carries the original payload and signature. If the provider has no resend, replay the captured request from the bin — same bytes, same headers, same result.

First: which layer answered?

Before interpreting the status code, find out whether your handler ran. Every webhook request carries a unique delivery id — stripe-signature has a timestamp, GitHub sends X-GitHub-Delivery, Standard Webhooks providers send webhook-id. Search your application logs for it.

  • Present in your logs — your code produced the failure. The status code tells you which check rejected it.
  • Absent — something in front of your application answered: a reverse proxy, a CDN, a WAF, the framework’s router, or a body parser that threw before dispatch. Nothing you change in the handler will help.

This one question eliminates half the possible causes, and skipping it is how people spend an afternoon debugging a handler that never executed.

401 Unauthorized

Nearly always signature verification, and nearly always one of five things.

The body was re-serialised. Your framework parsed the JSON, and you computed the HMAC over JSON.stringify(req.body). Different bytes, different digest. See the signature guide for how to get the raw body in each framework — this is the most common cause by a wide margin.

The wrong secret. Signing secrets are per-endpoint, not per-account, and test and live modes have different ones. A URL you deleted and recreated has a new secret. So does a second endpoint you added for staging.

A missing header. If the signature header is absent, a verifier must reject. Check the captured request for the header name in the exact case the provider sends, and check whether a proxy is filtering unfamiliar headers.

The verifier threw. Node’s timingSafeEqual throws when the two buffers differ in length, which is exactly what happens when your digest is hex and the header is base64. An uncaught throw becomes a 500; a caught one that returns false becomes a 401 with no explanation.

Auth middleware ran first. A global authentication layer sees a request with no session cookie and rejects it before routing. Webhook routes need to be exempt, and the exemption has to cover the exact path the provider posts to.

If your logs show no entry at all, none of the above applies — an upstream layer is rejecting the request, most often a WAF rule or basic auth on a staging environment.

400 Bad Request

Your framework rejected the request before your logic ran. Read the response body from the delivery log; frameworks usually say why.

Content type. Slack sends application/x-www-form-urlencoded for slash commands and JSON for events. GitHub can send either depending on how the hook was configured. A JSON-only parser 400s on the form-encoded one.

Body size. Default parser limits are often 100 KB. A Shopify order with fifty line items, or a Stripe invoice with a long metadata block, can exceed that. The message usually mentions “entity too large” even though the status is 400 or 413.

Empty body. Some providers send a validation request with no body at all. A parser configured to require JSON rejects it.

CSRF protection. Rails, Django, and Laravel all reject unauthenticated POSTs from an unknown origin by default. Webhook routes must be excluded — skip_before_action :verify_authenticity_token, @csrf_exempt, or $except in VerifyCsrfToken.

403 Forbidden

Something with a security policy decided against the request. Cloudflare’s WAF, AWS WAF, ModSecurity, and most managed hosts have rules that fire on POSTs with JSON bodies from unfamiliar user agents.

The tell is a response body you did not write — a branded block page, or a Cloudflare ray id. Check the firewall event log for that timestamp, and allowlist the provider’s IP ranges or user agent. Several providers publish their egress ranges for exactly this reason.

404 Not Found

The URL exists in your head, not in your router. Three usual causes:

Method. The route is registered for POST and the provider sent GET — during URL validation, several do. Register both, and answer the GET with a 200.

Trailing slash. /webhooks/stripe and /webhooks/stripe/ are different URLs to most routers. Frameworks that redirect between them return a 301, which some providers do not follow, and which all of them count as a failure.

Deployment. The route exists on your branch and not in production. Check the deployed commit before checking anything else.

405 Method Not Allowed

The route matched but the method did not. Same as the first 404 case, except your router was more informative. Add a GET handler that returns 200, since providers use it as a reachability probe.

415 Unsupported Media Type

Your framework requires a content type it did not get. Providers that send application/x-www-form-urlencoded (Slack commands, Twilio, Mailgun) trip a JSON-only endpoint, and some send no Content-Type at all on validation requests.

Read the raw body and branch on the content type yourself rather than letting the framework decide for you. It is three lines and it removes the whole category.

429 Too Many Requests

Your rate limiter is counting webhook traffic. This is worse than it looks: a burst of events triggers 429s, the provider retries the rejected ones, and the retries arrive on top of the new events, which produces more 429s. It can take a long time to drain.

Webhook endpoints should be exempt from user-facing rate limits, or limited generously and keyed on the provider rather than the IP. If you need backpressure, accept the request, queue it, and return 200 — throwing work away is cheaper than making the provider retry it.

500 and 502

Your code threw, or your process died. Two patterns worth knowing:

The handler throws on an unfamiliar event type. Providers add event types, and a switch with no default that then dereferences a field it did not get throws on the new one. Ignore unknown types and return 200.

A cold start plus real work exceeds the platform limit. On serverless, the first request to a scaled-to-zero function pays initialisation cost. If the handler then makes three API calls, the platform kills it and the provider sees a 502. Acknowledge first.

Under load, a 502 from a proxy in front of a healthy application usually means connection exhaustion, not a bug in the handler.

Timeouts

The most misdiagnosed failure, because your handler is often working perfectly.

Providers wait between three and fifteen seconds for the complete response:

Provider Deadline
Slack 3 seconds
Discord (interactions) 3 seconds
Shopify 5 seconds
Paddle 5 seconds
Supabase auth hooks 5 seconds
GitHub 10 seconds
Stripe ~20 seconds

If your handler updates a database, calls a payment API, and sends an email before responding, you are one slow dependency away from a timeout — and the provider will retry, so the work happens twice.

The fix is structural, not an optimisation:

export async function POST(request) {
  const raw = await request.text();
  if (!verify(raw, request.headers)) return new Response(null, { status: 401 });

  await queue.send(raw);                       // durable, fast
  return new Response(null, { status: 200 });  // inside the deadline, always
}

On Cloudflare Workers use ctx.waitUntil; on Vercel use a queue or a background function; on a traditional server use whatever job runner you already have. What you must not do is treat the provider’s timeout as the budget for your business logic.

The 200 that did nothing

The delivery log says success and your application shows no sign of it. Three explanations:

Something else answered. A maintenance page, a service worker, a catch-all route returning 200 for unmatched paths, or a CDN serving a cached response for the URL.

The handler responded, then failed. Work after the response throws, and the catch logs nothing. Instrument the path after the return, not just before it.

Wrong environment. The provider is pointed at staging while you are watching production, or at an old preview deployment that still resolves. Check the configured URL character by character — this is more common than it sounds.

Reproducing on demand

Everything above is easier when you can send the failing request whenever you want, instead of waiting for the provider.

Capture one real delivery on a temporary URL, then copy it as cURL. You get the genuine headers, the genuine bytes, and a genuine signature — the part hand-written test payloads always get wrong, since a signature you make up cannot verify against the real secret.

curl -X POST https://your-app.example.com/webhooks/stripe \
  -H 'content-type: application/json' \
  -H 'stripe-signature: t=1750000000,v1=3f9d2a7c...' \
  --data-binary @captured-body.json

Use --data-binary, not -d. -d strips newlines, which changes the bytes and breaks the signature — turning a verification test into a test of the wrong thing.

Once that command reproduces the failure, you have a fast loop: change code, run the command, read the log. Then use the provider’s resend button to confirm the fix against a real delivery.

Answers

Common questions

Why is my webhook returning 401?+

Almost always signature verification. The usual causes are a re-serialised body, the wrong secret for that endpoint, a missing header, or a verifier that throws instead of returning false. If your logs show no entry for the delivery at all, the 401 came from something in front of your app instead.

Why is my webhook returning 400?+

Your framework rejected the request before your logic ran. Common causes: a content type your body parser does not handle, a form-encoded payload parsed as JSON, a body larger than the parser's limit, or CSRF protection applied to a route that should be exempt.

The provider says timeout but my handler works. Why?+

Because the whole response has to be sent inside the deadline, and most deadlines are three to fifteen seconds. If you do the real work before responding, one slow database call or one slow third-party API makes an otherwise correct handler look broken. Acknowledge first, process after.

Why do I get 404 on a URL that exists?+

Check the method and the trailing slash. Many webhook routes are defined for POST only, and a provider's GET validation probe then 404s. Frameworks that redirect between /path and /path/ also produce a 404 or a 301 that the provider counts as a failure.

The delivery log shows 200 but nothing happened in my app.+

Something answered on your behalf, or your handler swallowed an error after responding. Check for a service worker, a maintenance page, a framework catch-all route, and any try/catch around post-response work that logs nothing.

How do I test a fix without waiting for a real event?+

Capture one real delivery, copy it as cURL, and replay it at your endpoint as often as you like. That gives you the genuine headers and bytes — including a real signature, which is the part hand-written test payloads always get wrong.

Keep reading

Related guides

Try it on a real request

Reading about it only goes so far.

Create a URL, point a provider at it, and inspect the delivery — the real headers, the raw bytes, and a signature you can check in the browser.

No signup · No email · Ready in about a second