# Verify a delivery

Source: https://capacms.com/docs/webhooks/verify

Check a webhook's signature before you trust it, with the SDK or by hand in any language.

Every delivery is signed with your endpoint's secret. Check the signature before you act on a request: anyone can send a `POST` to your URL, and only Capa holds the secret.

## The headers

```
Content-Type:     application/json
User-Agent:       Capa-Webhooks/1
Capa-Event-Id:    evt_00000000-0000-4000-8000-00000000abcd
Capa-Event-Type:  instance.published
Capa-Delivery-Id: 0f9b1d22-5c3e-4a77-8b11-6d2e4f8a0c91
Capa-Timestamp:   1790172131
Capa-Signature:   t=1790172131,v1=881c07f0…074f
Idempotency-Key:  evt_00000000-0000-4000-8000-00000000abcd
```

Static headers you set on the endpoint are sent too. They cannot replace a `Capa-*` header, `Content-Type` or `Idempotency-Key`.

`Capa-Event-Id` is the same on every attempt at an event and on every redelivery of it. `Capa-Delivery-Id` stays the same across one delivery's retries and changes when someone redelivers. Quote it when you ask Capa support about a delivery. `Capa-Timestamp` and `Capa-Signature` are the only headers that change between two attempts, because each attempt is signed when it is sent.

## The signature

`Capa-Signature` is `t=<unix seconds>,v1=<hex>`, and may carry more than one `v1`. The signed value is the string `<t>.<body>`, where `<body>` is the exact request body. The algorithm is HMAC-SHA256, keyed with the endpoint's secret (`whsec_…`, prefix included), written as lowercase hex.

Reject the request when the header does not parse, when the timestamp is more than 300 seconds from now, or when no `v1` matches.

## With the SDK

```ts
import { verifyWebhookSignature } from "@capacms/sdk";

export async function POST(request: Request) {
  const body = await request.text(); // the raw body, see below
  const ok = await verifyWebhookSignature({
    payload: body,
    header: request.headers.get("capa-signature") ?? "",
    secret: process.env.CAPA_WEBHOOK_SECRET!,
    // toleranceSeconds: 300 by default
  });
  if (!ok) return new Response("bad signature", { status: 400 });

  const event = JSON.parse(body);
  if (await alreadyHandled(event.id)) return new Response("ok");
  await handle(event);
  return new Response("ok");
}
```

`verifyWebhookSignature` uses WebCrypto, so the same function runs in Node, Bun, Deno, Cloudflare Workers, edge runtimes and the browser. It returns `false` for every bad input rather than throwing.

## By hand, in Node

```js
const crypto = require("node:crypto");

function verify(body, header, secret, toleranceSeconds = 300) {
  const parts = Object.create(null);
  const signatures = [];
  for (const entry of header.split(",")) {
    const i = entry.indexOf("=");
    if (i < 1) continue;
    const k = entry.slice(0, i).trim();
    const v = entry.slice(i + 1).trim();
    if (k === "t" && parts.t === undefined && /^\d+$/.test(v)) parts.t = Number(v);
    else if (k === "v1" && v) signatures.push(v);
  }
  if (parts.t === undefined || signatures.length === 0) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - parts.t) > toleranceSeconds) return false;

  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${body}`).digest("hex");
  const want = Buffer.from(expected, "utf8");
  return signatures.some(
    (s) => Buffer.byteLength(s, "utf8") === want.length && crypto.timingSafeEqual(Buffer.from(s, "utf8"), want),
  );
}
```

## Use the raw body

The body you verify must be the bytes that arrived. A body that went through `JSON.parse` and back through `JSON.stringify` is a different string, and it never verifies.

| Framework            | What to do                                                                                                                                    |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Express              | `app.post("/hooks", express.raw({ type: "application/json" }), handler)`, then `req.body` is a Buffer                                         |
| Fastify              | a raw-body content-type parser for that route, such as `addContentTypeParser("application/json", { parseAs: "string" })` scoped to the plugin |
| Next.js App Router   | `await request.text()`                                                                                                                        |
| Next.js Pages Router | `export const config = { api: { bodyParser: false } }`, then read the stream                                                                  |

## Test vector

Check a receiver in any language against this vector:

```
secret    whsec_testvectortestvectortestvectortestvector00
previous  whsec_previousvectorpreviousvectorpreviousvec00
t         1790121600
body      {"id":"evt_00000000-0000-4000-8000-000000000001","type":"webhook.test","apiVersion":"2026-09-22"}
header    t=1790121600,v1=881c07f0e7c16b0c6e87b603aaf06002e6638c3585848430aed9b2e8b7ea074f
rotated   t=1790121600,v1=881c07f0e7c16b0c6e87b603aaf06002e6638c3585848430aed9b2e8b7ea074f,v1=be763329bae2e2a825ab63fbd212cea858ed1c5b3a4b3d7386570a5682aa2053
```

`header` verifies with `secret`. `rotated` verifies with either secret. A body with one byte changed fails. `t + 300` is accepted and `t + 301` is not.

## Handle each event once

Retries and redeliveries send the same event again with the same `id` and `Idempotency-Key`. Store the ids you have handled, and answer `2xx` without acting when one repeats.
