Skip to main content

Troubleshooting: Signature Verification Failures

Use this page when your endpoint rejects deliveries from Orbit even though the signing secret looks right, or when a verifier that worked before a secret rotation start fails. Each section maps a symptom to an error code, then walks through the failure classes that produce it. For the full protocol spec — header formats, the signed string, and worked examples in Node, Python, and Go — see Webhook Security.

Symptom-to-Code Mapping

A STALE_WEBHOOK check happens before the HMAC comparison, so timestamp failures surface even when the signature itself is correct.

1. The framework consumed the raw body before verification

The signed string is <t>.<raw_body> — the exact bytes Orbit POSTed. If a body parser (Express express.json(), Next.js bodyParser, a global Fastify content parser) deserialized the payload first, the bytes you re-serialize for verification will differ from what Orbit signed: object key order, whitespace, and Unicode escaping all change. Fix: preserve the raw request body and pass it to the verifier. The copy-paste examples below show an Express raw route and a Next.js route with the body parser disabled. In Express this means mounting express.raw() on the webhook route before express.json().

2. Verifying against the wrong header during key rotation

During a rotation grace window Orbit sends:
  • X-Orbit-Signature — signed with your current secret.
  • X-Orbit-Signature-Next — signed with your previous secret (present only during the window).
  • X-Devotel-Signature — legacy combined header carrying two v1= candidates.
A verifier that holds only the current secret fails once it reads X-Orbit-Signature-Next, and a verifier that reads X-Devotel-Signature fails if it checks only the first v1 candidate. Fix: prefer the canonical X-Orbit-Signature header (fall back to X-Devotel-Signature). During a grace window, try every v1 candidate and accept if any matches. The examples below parse all candidates.

3. Server clock skew beyond the 5-minute window

A STALE_WEBHOOK (401) rejection means the t= timestamp fell outside the replay window before the HMAC check ran. Timeouts, retries, and queued redeliveries are fine — this fires only when your server’s clock is wrong. Fix: sync your clock with NTP (chrony or systemd-timesyncd). If it recurs after sync, check that your verifier computes ageSec in UTC seconds and doesn’t double-apply a timezone offset.

4. Wrong secret

Three common shapes:
  • Test vs. live — a whsec_ from a sandbox endpoint can’t verify live deliveries, and vice versa.
  • Previous secret after rotation — if you rotated in Settings > Webhooks or via POST /api/v1/webhooks/{id}/rotate-secret but didn’t update your code, new deliveries signed with the new secret fail.
  • Masked preview copied inGET /api/v1/webhooks/{id} returns a masked preview (whsec_********...<last4>) for identification only. Pasting that into your verifier guarantees failure, as described in Webhook Security.
Fix: retrieve the cleartext secret from your secrets manager (it is returned once at creation or rotation) and redeploy. If the cleartext is lost, rotate again and store the new value.

5. Middleware mutated the body

Compression, structured-logging middleware, or a proxy gatekeeper that re-parses and re-serializes JSON will change the raw bytes. A logging middleware that prints req.body after the JSON parser runs is a common culprit. Fix: verify using req.rawBody (Express) or request.text() (fetch/Next.js) before any body parsing, and preserve a raw body for webhook routes — as in the examples below.

6. Unsafe or case-sensitive comparison

Two comparators that silently never match:
  • expected === candidate — a plain string equality is fine functionally but leaks timing; crypto.timingSafeEqual / hmac.compare_digest is required.
  • Case-sensitive comparison against an uppercased hex digest, or a Buffer-length mismatch that throws inside timingSafeEqual — compare Buffer lengths before calling it.
Fix: use crypto.timingSafeEqual (Node) or hmac.compare_digest (Python) with a length check, and compare hex in a consistent case (Orbit emits lowercase).

Minimal Verifiers (copy-paste)

These handle all signature headers, parse every v1= candidate, and accept if any candidate matches — including the rotation-grace shape of X-Devotel-Signature.

Node (raw body already available)

Express: raw body for the webhook route only

Mount express.raw before express.json so the webhook route keeps the raw bytes while the rest of the app uses the JSON parser:

Next.js (Pages Router): disable the body parser

In the App Router, await request.text() gives you the raw body without any parser.

Rotation and the Grace Window

The safest place to rotate and to confirm both secrets:
  1. Rotate in Settings > Webhooks (dashboard) or via POST /api/v1/webhooks/{id}/rotate-secret. The response returns the new cleartext whsec_ once.
  2. Deploy the new secret to your verifier. Until you do, deliveries signed with the new secret fail — so rotate while your verifier still accepts the previous secret.
  3. During the 7-day grace window, Orbit sends both signatures (X-Orbit-Signature and X-Orbit-Signature-Next, with both v1 values in the legacy X-Devotel-Signature header). A verifier that checks at least one header against either secret keeps verifying while you roll the new secret out.
If you deliberately run only one secret, verify X-Orbit-Signature first; if you need the previous one during rotation, check X-Orbit-Signature-Next too.

Continue