← Webhook errors

Webhook 400

A 400 means some HTTP server answered Bad Request. Mercur’s guest inspector never returns 400; it always answers 204.

What this symptom means

HTTP 400 Bad Request is a response status. Someone’s server parsed the POST and refused it. The provider dashboard is showing that status from whatever URL you configured — your app, a tunnel, or a typo host.

It is not a Mercur guest-inspector status. In log mode (including the no-signup tester) the relay records the request and always responds 204 No Content. If the dashboard shows 400 while you believe you are on the inspector, you are not hitting that inspector URL, or you later pointed the provider at a handler that returns 400.

When an agent is connected in proxy mode, Mercur passes through the status your local process returns. A local res.status(400) is what Stripe or GitHub will display.

Common causes

  • Signature verification failed and your handler maps that to 400 (see signature verification failed)
  • JSON.parse on a non-JSON body, or Content-Type: application/json with an empty body
  • Schema validation (missing type, unknown event name, unexpected id shape)
  • Framework CSRF / origin middleware rejecting POSTs that have no browser cookie
  • Stripe CLI or a test clock posting to a route that only accepts Dashboard-signed payloads
  • You returned 400 on purpose for an event type you do not handle — providers still count that as a failed delivery

Verify the incoming request

  1. Copy the exact endpoint URL from the provider (Stripe Developers → Webhooks, GitHub Recent Deliveries, Telegram getWebhookInfo).
  2. Send curl -i to that URL with a tiny JSON body. Read the status from the relay, not from a guess.
  3. Open Mercur request history (inspector or Endpoint) and select the row: method, path, headers, body, and — once you are on a proxy Endpoint — the response status your app returned.

If history shows the request and the response column is 400, the handler ran. If history is empty, the 400 did not come from this Mercur URL.

Provider and framework notes

Stripe and GitHub treat any non-2xx as a failed delivery and will retry. A 400 you return on a signature mismatch therefore creates retries of the same event — that is a duplicate webhook problem layered on the 400.

Next.js App Router: a route.ts that only exports GET will 400/405 on POST depending on the runtime. Confirm the file handles POST.

Express: express.json() throwing on invalid JSON often becomes 400 from the default error handler before your route runs. That 400 is still “your server.”

Telegram setWebhook does not send a browser; if your stack requires a CSRF token on every POST, Telegram’s update will 400. Django: @csrf_exempt and ALLOWED_HOSTSDjango webhook localhost.

Debugging checklist

  1. Confirm the provider URL host and path match the Mercur URL you are watching.
  2. If you are on the guest inspector, expect 204, not 400. A 400 means a different origin handled the POST.
  3. Read the response body your handler returned (enable response capture on the Endpoint).
  4. If the body says signature / HMAC, follow the signature page.
  5. If the body says parse/validation, log the raw body from Mercur and fix the schema — do not “fix” it by returning 200 on garbage.

Confirm with the webhook inspector

Use the inspector to see whether the provider can POST at all. Guest mode: no account, 204, no localhost forward, idle TTL 1 day, capture cap 256 KB.

A successful inspector row with 204 plus a Dashboard 400 on a different URL means the 400 is your local (or production) handler after you switched URLs.

Inspect the request Connect localhost

Connect localhost

To see the 400 your code actually returns:

  1. Start an Endpoint in Log and Proxy toward 127.0.0.1:<port>.
  2. Connect the macOS app or npm CLI.
  3. Point the provider at that Endpoint.
  4. Trigger one event and read status + response body in console history.

Forwarding needs a connected agent — the macOS app or the npm package @mercur_dev/cli.

Replay the same event

After you change validation or signature handling, Retry the stored row so you do not wait on the provider. Requires proxy mode, a connected agent, and a complete captured payload — not the guest inspector.

Retry will hit the handler again and can return 400 a second time until the code is fixed. That is expected.

Related