> ## Documentation Index
> Fetch the complete documentation index at: https://developers.vantageclaw.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook security

> HMAC signature verification, replay-window defense, and reference implementations.

Every webhook delivery is signed with HMAC-SHA256 using the per-subscription secret returned at create time. **Always verify the signature before trusting the body.** An unverified webhook handler is a public unauthenticated endpoint that an attacker can spoof at will.

## Signature headers

Two headers accompany every delivery:

| Header           | Value                                                             |
| ---------------- | ----------------------------------------------------------------- |
| `X-VC-Signature` | `sha256=<hex>` — HMAC-SHA256 digest.                              |
| `X-VC-Timestamp` | Unix timestamp in **milliseconds** at which the event was signed. |

The signature is computed over the byte string:

```
<timestamp>.<body>
```

Where `<timestamp>` is the literal value of the `X-VC-Timestamp` header (digits only), the separator is a single literal dot character, and `<body>` is the **exact bytes** of the request body as received over the wire — no re-serialization, no whitespace adjustment.

The HMAC key is the subscription secret returned by `POST /api/v1/partner/webhooks` (the value starting with `whsec_`).

## Verification algorithm

1. Read the raw body bytes **before** any JSON parsing or middleware that might reformat them.
2. Read the `X-VC-Signature` and `X-VC-Timestamp` headers. Reject if either is missing.
3. Strip the `sha256=` prefix from the signature header; reject if not present.
4. Parse the timestamp as an integer (milliseconds since epoch). Reject if non-numeric.
5. Reject if `abs(now_ms - timestamp_ms) > 300_000` (5 minute replay window).
6. Compute `HMAC-SHA256(secret, f"{timestamp}." + body_bytes).hexdigest()`.
7. **Constant-time compare** the computed digest against the received digest. Reject on mismatch.

Only if all seven checks pass should you trust the body and process the event.

## Reference implementations

### Python

```python theme={null}
import hashlib
import hmac
import time

REPLAY_WINDOW_MS = 5 * 60 * 1000  # 5 minutes


def verify_webhook(
    *,
    secret: str,
    body: bytes,
    signature_header: str,
    timestamp_header: str,
    now_ms: int | None = None,
) -> bool:
    if not signature_header.startswith("sha256="):
        return False
    received = signature_header[len("sha256="):]

    try:
        ts_ms = int(timestamp_header)
    except (TypeError, ValueError):
        return False

    current_ms = now_ms if now_ms is not None else int(time.time() * 1000)
    if abs(current_ms - ts_ms) > REPLAY_WINDOW_MS:
        return False

    expected = hmac.new(
        secret.encode("utf-8"),
        msg=f"{ts_ms}.".encode("utf-8") + body,
        digestmod=hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(received, expected)
```

### Node.js

```javascript theme={null}
const crypto = require("crypto");

const REPLAY_WINDOW_MS = 5 * 60 * 1000;

function verifyWebhook({ secret, body, signatureHeader, timestampHeader, nowMs }) {
  if (!signatureHeader || !signatureHeader.startsWith("sha256=")) {
    return false;
  }
  const received = signatureHeader.slice("sha256=".length);

  const tsMs = parseInt(timestampHeader, 10);
  if (Number.isNaN(tsMs)) {
    return false;
  }

  const currentMs = nowMs ?? Date.now();
  if (Math.abs(currentMs - tsMs) > REPLAY_WINDOW_MS) {
    return false;
  }

  const signed = Buffer.concat([Buffer.from(`${tsMs}.`, "utf8"), body]);
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signed)
    .digest("hex");

  const receivedBuf = Buffer.from(received, "hex");
  const expectedBuf = Buffer.from(expected, "hex");
  if (receivedBuf.length !== expectedBuf.length) {
    return false;
  }
  return crypto.timingSafeEqual(receivedBuf, expectedBuf);
}
```

<Warning>
  In Node.js, your web framework must give you the **raw body bytes**, not a parsed JSON object. With Express, use `express.raw({ type: 'application/json' })` for the webhook route specifically. With Next.js API routes, read `req` as a stream and reconstruct the buffer. JSON middleware that has already parsed and re-serialized the body will produce mismatched signatures.
</Warning>

## What the checks defend against

| Check                  | Defends against                                                                                      |
| ---------------------- | ---------------------------------------------------------------------------------------------------- |
| `sha256=` prefix       | Algorithm-confusion attacks (forcing HMAC-MD5 or no algorithm).                                      |
| Timestamp parseable    | Malformed-header crash / type confusion.                                                             |
| Timestamp within 5 min | **Replay attacks** — an attacker who captures a valid delivery cannot replay it later.               |
| HMAC compare           | **Forgery** — without the secret, an attacker cannot produce a valid signature.                      |
| Constant-time compare  | **Timing oracle** — an attacker probing one byte at a time learns nothing about the expected digest. |

## Secret storage

Store the subscription secret as you would any high-value credential:

* **Environment variable or secret manager.** Never commit it to a code repository.
* **One per subscription.** If you have multiple subscription URLs (e.g., staging + production), each has its own independent secret.
* **Rotate by deletion + recreation.** v1 has no in-place secret rotation. To rotate: create a second subscription with the new URL/secret, verify traffic flows, delete the old one.

## What to do if verification fails

Failed verification is **always a hard reject** — return a non-2xx status from your handler (recommended: `401 Unauthorized` so failures are obvious in monitoring). Do not log the body, do not enqueue for retry on your side, do not "process the event but flag it." A forged or replayed webhook should be discarded with the same finality as an unauthenticated API call.

Persistent verification failures on legitimate traffic almost always trace to one of three causes:

1. **Wrong secret.** The handler is configured with a different secret than the subscription it's receiving. Re-check against `GET /api/v1/partner/webhooks` to confirm the subscription ID matches.
2. **Body mutation.** A middleware layer parsed and re-serialized the JSON before your handler saw it. Reroute the raw bytes.
3. **Clock skew.** Your server's clock drifted more than 5 minutes from UTC. Run `ntpd` or `chronyd`.
