Verify a delivery
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-00000000abcdStatic 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
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
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=be763329bae2e2a825ab63fbd212cea858ed1c5b3a4b3d7386570a5682aa2053header 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.