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

# Webhook Security

> Verify the HMAC-SHA256 signature on every VitaRelay webhook delivery.

# Webhook Security

Every outbound delivery is signed. Verify the signature before you parse, trust,
or act on any payload.

## The signature header

```http theme={null}
X-VitaRelay-Signature: sha256=3f1a9c...c72b
```

The value after `sha256=` is the **hex-encoded HMAC-SHA256** of the **exact raw
request body**, keyed with your endpoint's signing secret.

<Note>
  The signing secret is issued by a VitaRelay admin in the dashboard (org profile
  → Webhooks panel) and is displayed **once** at creation. Store it in your
  secret manager. If it's lost, ask an admin to rotate it.
</Note>

## Verify in Node.js

```typescript theme={null}
import crypto from "node:crypto";

export function verifyVitaRelaySignature(
  rawBody: string,
  signatureHeader: string | null,
  secret: string,
): boolean {
  if (!signatureHeader) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");
  const received = signatureHeader.replace(/^sha256=/, "");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(received, "hex");
  if (a.length !== b.length) return false;

  return crypto.timingSafeEqual(a, b);
}
```

<Warning>
  Compute the HMAC over the **raw body bytes** exactly as received. Re-serializing
  a parsed object (`JSON.stringify(req.body)`) changes whitespace and key order
  and will fail verification. In Express, use
  `express.raw({ type: "application/json" })` for the webhook route.
</Warning>

## Rules

* Reject with `401` if `X-VitaRelay-Signature` is missing, malformed, or does not match.
* Always use a constant-time comparison (`crypto.timingSafeEqual`) — never `===`.
* Never log the signing secret or the full signature header.
* Terminate webhooks over HTTPS only.

## Replay protection

Each delivery carries `X-VitaRelay-Timestamp` (unix seconds) and
`X-VitaRelay-Delivery` (a UUID). To limit replay exposure:

```typescript theme={null}
const ts = Number(request.headers.get("X-VitaRelay-Timestamp"));
const skewSeconds = Math.abs(Date.now() / 1000 - ts);
if (!Number.isFinite(ts) || skewSeconds > 300) {
  return new Response("Stale delivery", { status: 400 });
}
```

Pair that with deduplication on `X-VitaRelay-Delivery` so a replayed or retried
delivery is a no-op.

<Note>
  Deliveries are at-least-once. A rejected-then-retried delivery arrives with the
  same delivery id, so idempotent handling is required, not optional.
</Note>
