> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Troubleshooting: webhook signature verification failures

> Diagnose and fix failed webhook signature verification — raw-body parsing, key rotation headers, clock skew, wrong secrets, mutated bodies, and timing-safe comparison.

# 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](/webhooks/security).

## Symptom-to-Code Mapping

| Error code                  | HTTP | What it means                                                                                                                |
| --------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------- |
| `INVALID_SIGNATURE`         | 401  | The computed HMAC matched none of the `v1` candidates in the signature header.                                               |
| `WEBHOOK_SIGNATURE_INVALID` | 401  | Same class as `INVALID_SIGNATURE` — failed HMAC comparison, reported from the delivery-log side.                             |
| `STALE_WEBHOOK`             | 401  | The `t=` timestamp is missing or falls outside the 5-minute replay window — a clock-skew/replay guard, not an HMAC mismatch. |

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](#minimal-verifiers) 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 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](/webhooks/security#signing-secret).

**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](#minimal-verifiers).

## 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)

```javascript theme={null}
import crypto from 'node:crypto';

function parseSignatureHeader(header) {
  const parts = (header ?? '').split(',').map((p) => p.trim());
  let t = null;
  const v1s = [];
  for (const part of parts) {
    const eq = part.indexOf('=');
    if (eq < 0) continue;
    const key = part.slice(0, eq);
    const value = part.slice(eq + 1);
    if (key === 't') t = value;
    else if (key === 'v1') v1s.push(value);
  }
  return { t, v1s };
}

export function verifySignature(rawBody, header, secret) {
  const { t, v1s } = parseSignatureHeader(header);
  if (!t || v1s.length === 0) return false;

  const ageSec = Math.floor(Date.now() / 1000) - Number(t);
  if (!Number.isFinite(ageSec) || ageSec < 0 || ageSec > 5 * 60) return false;

  const expected = Buffer.from(
    crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`, 'utf-8').digest('hex'),
    'hex',
  );
  return v1s.some((candidate) => {
    const buf = Buffer.from(candidate, 'hex');
    return buf.length === expected.length && crypto.timingSafeEqual(buf, expected);
  });
}
```

### 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:

```javascript theme={null}
import express from 'express';

const app = express();

app.post(
  '/webhooks/orbit',
  express.raw({ type: '*/*' }),
  (req, res) => {
    const header = req.headers['x-orbit-signature'] ?? req.headers['x-devotel-signature'];
    const rawBody = req.body.toString('utf-8');
    if (!verifySignature(rawBody, header, process.env.ORBIT_WEBHOOK_SECRET)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }
    const event = JSON.parse(rawBody);
    // Process the event...
    res.status(200).json({ received: true });
  },
);

app.use(express.json());
```

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

```javascript theme={null}
export const config = { api: { bodyParser: false } };

export default async function handler(req, res) {
  const rawBody = await new Promise((resolve, reject) => {
    const chunks = [];
    req.on('data', (c) => chunks.push(c));
    req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
    req.on('error', reject);
  });
  const header = req.headers['x-orbit-signature'] ?? req.headers['x-devotel-signature'];
  if (!verifySignature(rawBody, header, process.env.ORBIT_WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  res.status(200).json({ received: true });
}
```

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

* [Webhook Security](/webhooks/security) — full protocol spec, worked examples in Node/Python/Go, security best practices
* [Webhook Events](/webhooks/events) — event catalog
* [Event Payloads](/webhooks/event-payloads) — envelope and per-event body shapes
