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.
Install
npm install @typedhook/verifyVerify 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.
| Reason | When | Stage |
|---|---|---|
missing_headers | No webhook-id, -timestamp or -signature (or svix-*) | headers |
invalid_id | The id contains a dot, which the spec forbids | headers |
invalid_timestamp | The timestamp is not an integer of seconds | headers |
timestamp_out_of_tolerance | More than ±300s from now. Change it with toleranceSec | headers |
unreadable_body | The request body was already read or the stream aborted | body |
invalid_secret | The secret could not be imported as an HMAC key | signature |
invalid_signature | None of the v1, signatures match (up to 8 are tried, for key rotation) | signature |
invalid_json | Signature is valid but the body is not JSON | JSON |
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.
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.