Docs/Verifying

Verify requests

Check that a webhook came from its sender and is not a replay. Zero dependencies, and it never throws on a bad request.

@typedhook/verify checks the Standard Webhooks signature with WebCrypto. It reads webhook-* headers and falls back to svix-*, so it also verifies webhooks from senders that use Svix.

Node 20+
Bun
Deno
Workers
Edge

Install

npm install @typedhook/verify

Verify a request

Create the verifier once, at startup. A missing or malformed secret, or a bad option, throws there, so it fails your deploy instead of every request.

// app/api/webhooks/route.ts
import { createVerifier } from "@typedhook/verify";

const verifier = createVerifier(process.env.WEBHOOK_SECRET!);

export async function POST(req: Request) {
  const result = await verifier.verify(req);
  if (!result.ok) return new Response(result.reason, { status: 400 });

  result.id;        // "evt_3f1c…", same on every retry
  result.timestamp; // Date
  result.payload;   // { event, subject, data }
  return new Response(null, { status: 204 });
}

Express and Fastify: pass the raw body. Parsed and re-serialized JSON changes the bytes and breaks the signature. Use express.raw({ type: "application/json" }) and verifier.verifyPayload(req.body, req.headers).

Failure reasons

verify() resolves to { ok: false, reason } instead of throwing. Checks run in this order, and the body is only read once the headers pass.

headers→id→timestamp→body→signature→JSON
ReasonWhenStage
missing_headersNo webhook-id, -timestamp or -signature (or svix-*)headers
invalid_idThe id contains a dot, which the spec forbidsheaders
invalid_timestampThe timestamp is not an integer of secondsheaders
timestamp_out_of_toleranceMore than ±300s from now. Change it with toleranceSecheaders
unreadable_bodyThe request body was already read or the stream abortedbody
invalid_secretThe secret could not be imported as an HMAC keysignature
invalid_signatureNone of the v1, signatures match (up to 8 are tried, for key rotation)signature
invalid_jsonSignature is valid but the body is not JSONJSON

Handle duplicates

Senders retry until they get a 2xx, so the same webhook can arrive twice, or after a newer one. result.id is the same on every retry. Record it and skip ids you have already processed.

app/api/webhooks/route.ts
const result = await verifier.verify<Events>(req);
if (!result.ok) return new Response(result.reason, { status: 400 });

const fresh = await db.processedWebhook.createMany({
  data: [{ id: result.id }],
  skipDuplicates: true,
});
if (fresh.count === 0) return new Response(null, { status: 204 }); // seen it

await handle(result.payload);
return new Response(null, { status: 204 });

Secrets

Secrets that start with whsec_ are base64-decoded, like Svix secrets. Anything else is used as plain text. An empty secret, or whsec_ followed by invalid base64, throws in createVerifier().

The verifier checks who sent it, not its shape. verify<Events>() types the payload but does not validate it. If you do not fully trust the sender, parse result.payload with a schema.

Edit on GitHubLast updated on October 14, 2026

On this page

For coding agents

Every page is available as Markdown. Point your agent at the full docs in one file.