Svix webhook signature verification in a Hono route: working versions (tested 2026-10-15)
Verify Svix-signed webhooks (the scheme Clerk and Resend use) on the raw body in a Hono handler: valid, tampered, stale-timestamp, wrong-secret and duplicate-delivery cases, signed locally with svix.
passed6 passed · 0 failed2026-10-15 20:09 UTC- Status
- passed
- Last passed
- 2026-10-15 20:09 UTC
- Tests
- 6 / 6
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38799498972
The code
Exactly the files that passed. Your agent can pull them with get_recipe("svix-webhook-verify-hono") (install), or see them on GitHub.
{
"name": "svix-webhook-verify-hono",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"svix": "2.8.0",
"hono": "4.13.13"
},
"devDependencies": {
"@types/node": "26.6.5",
"tsx": "4.23.15"
}
}
import { Hono } from "hono";
import { Webhook, WebhookVerificationError } from "svix";
export type Event = { type: string; data: Record<string, unknown> };
/** Hono route that verifies Svix (and Svix-style, e.g. Clerk/Resend) webhooks on the raw body. */
export function makeApp(secret: string, onEvent: (e: Event, id: string) => void) {
const wh = new Webhook(secret);
const seen = new Set<string>();
const app = new Hono();
app.post("/webhooks", async (c) => {
const raw = await c.req.text(); // must verify the exact bytes, never re-serialized JSON
const headers = {
"svix-id": c.req.header("svix-id") ?? "",
"svix-timestamp": c.req.header("svix-timestamp") ?? "",
"svix-signature": c.req.header("svix-signature") ?? "",
};
let evt: Event;
try {
// svix 2.x verify() returns undefined (1.x returned the parsed payload), so parse after it passes.
wh.verify(raw, headers);
evt = JSON.parse(raw) as Event;
} catch (e) {
if (e instanceof WebhookVerificationError) return c.json({ error: e.message }, 400);
throw e;
}
if (seen.has(headers["svix-id"])) return c.json({ ok: true, duplicate: true });
seen.add(headers["svix-id"]);
onEvent(evt, headers["svix-id"]);
return c.json({ ok: true });
});
return app;
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { Webhook } from "svix";
import { makeApp, type Event } from "../src/app.ts";
// Svix secrets are base64 with a whsec_ prefix.
const SECRET = "whsec_" + Buffer.from("provenpin-test-secret-32-bytes!!").toString("base64");
const signer = new Webhook(SECRET);
function signed(body: string, id: string, ts = new Date()) {
return {
method: "POST",
body,
headers: {
"content-type": "application/json",
"svix-id": id,
"svix-timestamp": String(Math.floor(ts.getTime() / 1000)),
"svix-signature": signer.sign(id, ts, body),
},
};
}
test("valid signature is accepted and parsed", async () => {
const got: [Event, string][] = [];
const app = makeApp(SECRET, (e, id) => got.push([e, id]));
const body = JSON.stringify({ type: "user.created", data: { id: "u_1" } });
const res = await app.request("/webhooks", signed(body, "msg_1"));
assert.equal(res.status, 200);
assert.deepEqual(got, [[{ type: "user.created", data: { id: "u_1" } }, "msg_1"]]);
});
test("tampered body is rejected", async () => {
const app = makeApp(SECRET, () => assert.fail("should not run"));
const req = signed(JSON.stringify({ type: "a", data: {} }), "msg_2");
req.body = JSON.stringify({ type: "b", data: {} });
const res = await app.request("/webhooks", req);
assert.equal(res.status, 400);
});
test("stale timestamp is rejected (replay protection)", async () => {
const app = makeApp(SECRET, () => assert.fail("should not run"));
const old = new Date(Date.now() - 10 * 60 * 1000);
const res = await app.request("/webhooks", signed(JSON.stringify({ type: "a", data: {} }), "msg_3", old));
assert.equal(res.status, 400);
assert.match(((await res.json()) as { error: string }).error, /timestamp/i);
});
test("missing headers are rejected", async () => {
const app = makeApp(SECRET, () => {});
const res = await app.request("/webhooks", { method: "POST", body: "{}" });
assert.equal(res.status, 400);
});
test("duplicate svix-id is acknowledged once", async () => {
let n = 0;
const app = makeApp(SECRET, () => n++);
const body = JSON.stringify({ type: "invoice.paid", data: {} });
await app.request("/webhooks", signed(body, "msg_4"));
const again = await app.request("/webhooks", signed(body, "msg_4"));
assert.deepEqual(await again.json(), { ok: true, duplicate: true });
assert.equal(n, 1);
});
test("signature from another secret fails", async () => {
const other = new Webhook("whsec_" + Buffer.from("another-secret-another-secret-xx").toString("base64"));
const app = makeApp(SECRET, () => {});
const body = "{\"type\":\"x\",\"data\":{}}";
const ts = new Date();
const res = await app.request("/webhooks", {
method: "POST", body,
headers: { "svix-id": "m", "svix-timestamp": String(Math.floor(ts.getTime() / 1000)), "svix-signature": other.sign("m", ts, body) },
});
assert.equal(res.status, 400);
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Hono + @hono/zod-openapi typed routes with generated OpenAPI docs
Define Hono routes with createRoute and Zod schemas so requests are validated, handlers are typed and an OpenAPI 3.1 document is generated at /doc.
Hono + Drizzle ORM + SQLite REST API (Vitest)
CRUD REST API in Hono backed by Drizzle ORM on better-sqlite3, with Zod validation, tested with Vitest and app.request().
Hono + hono/jwt bearer auth with Zod login
Issue HS256 JWTs from a Zod-validated login route in Hono and guard routes with the built-in jwt middleware.
Hono + Valibot request validation (Vitest)
Validate JSON bodies and query strings in Hono with Valibot schemas via @hono/valibot-validator, with custom error responses, tested with Vitest.