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

# Verifying signatures

<Info>
  **Pre-release** — contact your Fortary account team for access.
</Info>

Every delivery is signed with your webhook's secret (`whsec_…`). Verifying the signature proves the request came from Fortary and that the body was not modified in transit. **Always verify before trusting a payload.**

## Delivery headers

| Header                       | Example                 | Description                                         |
| ---------------------------- | ----------------------- | --------------------------------------------------- |
| `x-fortary-webhook-id`       | `whs_…`                 | The webhook receiving the event                     |
| `x-fortary-event-id`         | `evt_…`                 | The event being delivered — your deduplication key  |
| `x-fortary-delivery-id`      | `del_…`                 | This delivery; a redelivery gets a new id           |
| `x-fortary-delivery-attempt` | `3`                     | Attempt counter within this delivery, starting at 1 |
| `x-fortary-signature`        | `t=1718027000,v1=5f8c…` | Timestamp + HMAC signature (scheme below)           |

Delivery metadata travels in headers, never in the body — the body of every retry is **byte-identical**.

## The signature scheme

```
x-fortary-signature: t=1718027000,v1=a1b2c3…
```

The signature is **HMAC-SHA256** over the string `"{t}.{body}"` — the unix timestamp, a literal `.`, then the raw request body — keyed with your `whsec_…` secret, hex-encoded. Reject deliveries whose timestamp differs from your clock by more than **5 minutes**. The format matches Stripe's scheme, so existing Stripe-style verification middleware typically works with the header name swapped.

During the 24-hour grace window after a [secret rotation](/api/reference/webhooks/rotate-webhook-secret), the header carries **two** `v1` entries — one per secret. Verify against each and accept if any matches.

## Verification steps

1. Read the **raw request body** — the exact bytes received, before any JSON parsing.
2. Parse the header into the timestamp (`t=`) and every `v1=` signature.
3. Reject if `|now − t|` is greater than 5 minutes.
4. Compute `HMAC-SHA256(secret, "{t}.{body}")`.
5. Compare against each `v1` value using a **constant-time** comparison. Accept if any matches.

If verification fails, respond with a `4xx` and do not process the payload.

## Sample code (Node.js / TypeScript)

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

const TOLERANCE_SECONDS = 5 * 60;

export interface VerifyWebhookSignatureParams {
  /** Your signing secret (whsec_…). */
  secret: string;
  /** The x-fortary-signature header value. */
  signature: string;
  /** The exact request body bytes, before any JSON parsing. */
  rawBody: string;
  /** Override for testing; defaults to the current time. */
  nowSeconds?: number;
}

export const verifyWebhookSignature = ({
  secret,
  signature,
  rawBody,
  nowSeconds,
}: VerifyWebhookSignatureParams): boolean => {
  const parts = signature.split(",");
  const timestampPart = parts.find((part) => part.startsWith("t="));
  const signatureParts = parts.filter((part) => part.startsWith("v1="));
  if (timestampPart === undefined || signatureParts.length === 0) return false;

  const timestamp = Number(timestampPart.slice(2));
  if (!Number.isInteger(timestamp)) return false;

  const now = nowSeconds ?? Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
  return signatureParts.some((part) => {
    const candidate = Buffer.from(part.slice(3), "hex");
    return candidate.length === expected.length && timingSafeEqual(candidate, expected);
  });
};
```

<Warning>
  **Verify the raw body.** The most common integration bug is parsing the JSON and re-serializing it before computing the HMAC — key order or whitespace changes and the signature no longer matches. Configure your framework to give you the unmodified body bytes for this route (for example `express.raw()`, or Fastify's `addContentTypeParser` with the raw buffer).
</Warning>
