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 twov1=candidates.
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
ASTALE_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-secretbut didn’t update your code, new deliveries signed with the new secret fail. - Masked preview copied in —
GET /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.
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 printsreq.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_digestis required.- Case-sensitive comparison against an uppercased hex digest, or a Buffer-length mismatch that throws inside
timingSafeEqual— compare Buffer lengths before calling it.
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 everyv1= 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
Mountexpress.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
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:- Rotate in Settings > Webhooks (dashboard) or via
POST /api/v1/webhooks/{id}/rotate-secret. The response returns the new cleartextwhsec_once. - 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.
- During the 7-day grace window, Orbit sends both signatures (
X-Orbit-SignatureandX-Orbit-Signature-Next, with bothv1values in the legacyX-Devotel-Signatureheader). A verifier that checks at least one header against either secret keeps verifying while you roll the new secret out.
X-Orbit-Signature first; if you need the previous one during rotation, check X-Orbit-Signature-Next too.
Continue
- Webhook Security — full protocol spec, worked examples in Node/Python/Go, security best practices
- Webhook Events — event catalog
- Event Payloads — envelope and per-event body shapes