# Webhooks
Source: https://nordvec.com/docs/guides/webhooks#verifying-deliveries

## Verifying deliveries [#verifying-deliveries]

Every delivery is signed following the
[Standard Webhooks specification](https://www.standardwebhooks.com/). Always
verify the signature before trusting a request: your endpoint URL is
reachable by anyone on the internet.

Each request carries three headers:

| Header | Value |
| ------ | ----- |
| `webhook-id` | Message id. Identical on every retry, resend and replay of the same event; deduplicate on it. |
| `webhook-timestamp` | Unix timestamp in seconds at send time. |
| `webhook-signature` | One or more signatures, space separated, each in the form `v1,{base64}`. |

To verify:

1. Reconstruct the signed content as `{webhook-id}.{webhook-timestamp}.{raw body}`.
   Use the raw request body bytes exactly as received, before any JSON parsing.
2. Compute HMAC-SHA256 over that string. The key is the **base64-decoded
   portion of the secret after the `whsec_` prefix** (the Standard Webhooks
   key derivation, which is what the reference libraries implement).
3. Base64-encode the HMAC and compare it against each `v1,` signature in the
   header using a constant-time comparison. The delivery is authentic if any
   one matches.
4. Reject deliveries whose `webhook-timestamp` is more than **5 minutes** from
   your current time, in either direction. This bounds replay of captured
   requests.

The header can carry more than one signature: while a secret rotation grace
window is open, deliveries are signed with both the new and the previous
secret (`v1,NEW_SIG v1,OLD_SIG`), so verification keeps succeeding no matter
which secret your service has rolled to.

```js
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

/**
 * @param {string} secret - The whsec_ signing secret from endpoint creation.
 * @param {Record<string, string>} headers - Lowercased request headers.
 * @param {string} rawBody - The raw request body, unparsed.
 */
export function verifyWebhook(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatureHeader = headers["webhook-signature"];
  if (!id || !timestamp || !signatureHeader) return false;

  const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(skew) || skew > TOLERANCE_SECONDS) return false;

  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest();

  return signatureHeader.split(" ").some((entry) => {
    const comma = entry.indexOf(",");
    if (comma === -1 || entry.slice(0, comma) !== "v1") return false;
    const received = Buffer.from(entry.slice(comma + 1), "base64");
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}
```

Because the format follows the Standard Webhooks specification, open-source
verification libraries for other languages work as-is; pass them the secret
and the three headers.

### Rotating the signing secret [#rotating-the-signing-secret]

Rotate an endpoint's secret from workspace settings with the rotate button on
its card. The new secret is shown once, when the rotation completes. The
dialog asks how long the current secret stays valid: no grace period,
15 minutes, 1 hour, 6 hours or 24 hours (the default).

- **With a grace period**, deliveries are signed with both the new and the
  current secret until the period ends, so receivers keep verifying while you
  roll the new secret out to every service. Use this for routine rotation.
- **With no grace period**, the current secret stops verifying at once: the
  next delivery carries only the new signature. Use this when a secret may
  have leaked, and deploy the new secret to your receivers straight away,
  because deliveries fail verification until you do.

An endpoint keeps one previous secret. While a grace period from the last
rotation is still open, the only rotation accepted is one with no grace
period, and it revokes both old secrets at once: the current one and the one
still in its grace period. This is what makes a leak recoverable even right
after a routine rotation. A rotation with a grace period is refused until the
open one has ended; the dialog shows when that is and offers only the
immediate rotation until then. Over the API, `GET /webhooks` returns the end
of the open grace period as `previousSecretExpiresAt` (null when none is
open), and `POST /webhooks/rotate-secret` takes the window as `gracePeriodMs`:
`0`, `900000`, `3600000`, `21600000` or `86400000`, defaulting to 24 hours. Each
rotation is recorded in the audit log with its grace period and whether it
revoked a secret that was still in its grace period; the secrets themselves
are never logged.