Skip to main content

Troubleshooting: webhook signature verification fails (HMAC mismatch)

Your endpoint answers deliveries with a 401 and the delivery log shows WEBHOOK_SIGNATURE_INVALID. That means Orbit delivered and your verifier rejected it — the HMAC your server computed matched none of the v1 candidates in the signature header. This page walks the full recovery loop: confirm the mismatch, isolate the cause, fix the verifier, re-enable the endpoint, and replay the dead-lettered backlog. For the array-of-codes view, see webhook lifecycle error codes; for every HMAC failure class in depth, see signature verification failures.

1. Reproduce the mismatch locally

Recreate the exact comparison Orbit expects before you change anything. A delivery carries two headers that drive verification:
  • X-Orbit-Signature — t=<unix_seconds>,v1=<hex hmac> (integrating since before the rename? fall back to the legacy X-Devotel-Signature header).
  • X-Orbit-Webhook-Id — the delivery id; log it so you can match the failed attempt to a dashboard row later.
Pull one failed delivery (GET /api/v1/webhooks/{id}/deliveries/{deliveryId}), copy the raw payload into a file, and recompute:
Compare the digest to every v1= candidate in the captured header. If none match, the failure is real and one of the causes in the next section applies. Any one match means your runtime code — not the secret — is dropping the raw body or a candidate header.

2. The three causes, in order of frequency

Wrong secret after rotation. Test and live secrets do not cross-verify, and a rotation in Developer → Webhooks (or POST /api/v1/webhooks/{id}/rotate-secret) invalidates the old value the moment your deploys pick up. The masked preview (whsec_********...<last4>) identifies the secret — pasting it into your verifier guarantees a mismatch. If the cleartext is lost, rotate again and store the new value; it is returned exactly once. Payload re-serialization. The signed string is <t>.<raw_body> — the exact bytes Orbit POSTed. If a body parser (Express express.json(), a global Fastify content parser, Next.js bodyParser) ran first, key order, whitespace, and Unicode escaping change and the recomputed HMAC can never match. Verify against raw bytes (express.raw(), request.text()) before any parsing. Middleware drops the raw body (or the header). Logging proxies, compression middleware, or a framework that buffers only parsed JSON leave your verifier with nothing to sign — or a proxy may strip the X-Orbit-Signature header itself. Capture req.rawBody (Express) or the undecoded request stream and check the headers your handler actually saw. The verifier shape that handles all three — multi-candidate parsing, raw body, timing-safe compare — is copied in signature verification failures.

3. Debug in the dashboard delivery log

Open Developer → Webhooks → Delivery log and find the failing row. The row is redacted — secrets and signature values never appear — but it shows the response status your endpoint returned (401 here), the delivery id in X-Orbit-Webhook-Id, and the attempt timestamp. Drill into the row to compare the exact payload against what your endpoint logged; a byte-level diff over the body usually exposes the re-serialization within a glance. If the dashboard shows 401 on every attempt while your own logs show 200, a proxy or framework middleware is rewriting the response — confirm the status from the receiver side before touching the verifier.

4. WEBHOOK_SIGNATURE_INVALID vs WEBHOOK_DELIVERY_FAILED

Both land in the delivery log as failed attempts, but they are different gates:
  • WEBHOOK_SIGNATURE_INVALID — your endpoint answered and rejected the HMAC (any 4xx, usually 401). Fix the verifier, then re-enable the endpoint and replay the dead-lettered rows — a 401 is proven-dead: the delivery skips retries, lands in the dead-letter queue, and the endpoint is disabled at once.
  • WEBHOOK_DELIVERY_FAILED — the attempt never reached a working receiver (404, 5xx, timeout, connection refused). Fix routing/uptime; the signature is not involved.
Read the status your endpoint returned from the delivery row: 4xx points to the signature path, everything else to the delivery path. The full code map is in webhook lifecycle error codes.

5. Retry semantics while you fix

A 401 is a proven-dead response, so Orbit does not retry it: the delivery moves straight to the dead-letter queue and the endpoint is disabled at once, and Orbit notifies the org admin — the same contract documented on Webhooks overview. Nothing keeps retrying in the background while you deploy the verifier fix, so recovery is a deliberate loop, not a waiting window: fix the verifier, re-enable the endpoint from Developer → Webhooks, then put the dead-lettered rows back with requeue or replay. Requeue, replay, and bulk replay-range tooling are covered in failed webhook deliveries, retries, DLQ, and replay. During a rotation grace window, accept every v1 candidate from X-Orbit-Signature (and X-Orbit-Signature-Next for the previous secret) so requeued deliveries verify against whichever secret signed them while you roll the new secret out.

See also