# Verify a webhook signature
URL: https://skedcast.com/developers/guides/verify-webhook-signature

Every SkedCast webhook delivery carries a signature header so you can confirm it came from SkedCast and wasn't changed. This page gives the scheme and a working verification function, the same logic the webhook signature tester on this site runs in your browser.

## The signature scheme
Each delivery carries an `X-SkedCast-Signature` header shaped `t=<unix-seconds>,v1=<hex>[,v1=<hex>…]`. The signed string is `"<t>.<rawBody>"`: the timestamp, a literal period and the exact raw request body. The algorithm is HMAC-SHA256, keyed with your endpoint's signing secret (shown once when you create the webhook).
During a 24-hour secret rotation the header holds one `v1` entry per valid secret. Accept the delivery if any entry matches.

## Node.js verification. Use the raw body, not JSON.parse(body) re-stringified.
```ts
import { createHmac, timingSafeEqual } from "node:crypto";

function verifySkedcastSignature(
  secret: string,
  rawBody: string | Buffer, // exact bytes of the request body
  header: string,        // the X-SkedCast-Signature header value
  toleranceSeconds = 300,
): boolean {
  // Header shape: t=<unix-seconds>,v1=<hex>[,v1=<hex>...]
  let timestamp: string | undefined;
  const candidates: string[] = [];
  for (const part of header.split(",")) {
    const separator = part.indexOf("=");
    if (separator === -1) continue;
    const key = part.slice(0, separator).trim();
    const value = part.slice(separator + 1).trim();
    if (key === "t") timestamp = value;
    else if (key === "v1") candidates.push(value);
  }
  if (timestamp === undefined || !/^\d+$/.test(timestamp) || candidates.length === 0) return false;

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > toleranceSeconds) return false;

  const expected = Buffer.from(
    createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex"),
    "utf8",
  );

  // During a secret rotation there is one v1 entry per valid secret: accept a match on ANY of them.
  // Every entry is compared, in constant time, so timing does not reveal which one matched.
  let matched = false;
  for (const candidate of candidates) {
    const provided = Buffer.from(candidate, "utf8");
    if (provided.length === expected.length && timingSafeEqual(provided, expected)) matched = true;
  }
  return matched;
}
```

## Two mistakes that break verification
Using the framework's already-parsed body instead of the raw bytes. Express's json() middleware, for example, replaces req.body with a parsed object by default, which can reorder keys or change whitespace. You need the untouched string, for example via express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }), which keeps the raw bytes as a Buffer (declare rawBody on Express's Request type). The function above accepts a string or a Buffer.
Comparing signatures with a plain === instead of a constant-time comparison. A naive comparison leaks timing information an attacker can use to guess the signature byte by byte. Always use something equivalent to Node's timingSafeEqual.

## Standard Webhooks headers
Every delivery also carries the Standard Webhooks headers (`webhook-id`, `webhook-timestamp`, `webhook-signature`) next to `X-SkedCast-Signature`, so you can use any Standard Webhooks library instead of hand-rolling the HMAC. Both are always sent. Verify one.

## FAQ
**Q: Can I test my verification code without waiting for a real event?**
A: Yes. POST /v1/webhooks/{id}/test sends a real, signed test delivery to your endpoint on demand. This site's webhook signature tester also lets you paste a secret, timestamp and body to compare your output against the reference calculation.

**Q: What's the replay-protection window?**
A: SkedCast recommends rejecting a delivery whose timestamp is more than about 300 seconds (5 minutes) from your server's clock. Tune it if your clocks drift, but don't skip the check.

**Q: Why does the header have more than one v1 entry?**
A: During a 24-hour secret rotation, SkedCast signs with every valid secret, one v1 entry each. Your receiver holds one of those secrets, so one entry matches. Accept the delivery if any entry matches.
