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 it, and let the retries recover the 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; retries recover alone.
  • 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 counts as a failed attempt, so Orbit retries on the standard cadence: up to 9 retries with exponential backoff over roughly 4.3 hours. That gives you a window to deploy the verifier fix — remaining retries succeed on their own, and you do not need a bulk replay for rows that are still retrying. If the window closes before the fix lands, the rows move to the DLQ; requeue or replay them once the endpoint verifies cleanly. 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 in-flight retries keep succeeding while you roll the new secret out.

See also