ProvenPin Install

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
pinned versions4
svix@2.8.0hono@4.13.13@types/node@26.6.5tsx@4.23.15
run it
npm install --no-audit --no-fund --ignore-scripts=false
npm test
test environment
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.

package.json
{
  "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"
  }
}
src/app.ts
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;
}
test/app.test.ts
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.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.