The universal webhook signature verification library.
One uniform API to verify Stripe Β· GitHub Β· Slack Β· Shopify Β· Discord Β· Svix and more β
zero dependencies, Web Crypto only, runs everywhere.
Live demo Β· Install Β· Quickstart Β· Providers Β· Frameworks Β· Contributing Β· License
Sign a webhook request in your browser and watch hookproof accept the genuine one and reject every tampered variant β a bad body, a wrong secret/key, an expired timestamp β with the exact error code. It runs 100% client-side (Web Crypto), which is only possible because the whole library is Web-standard and dependency-free.
Run it locally, or deploy your own copy in one click:
pnpm demo:build && npx serve demo # http://localhost:3000Hand-rolling webhook verification is easy to get subtly, dangerously wrong:
- Timing attacks β comparing signatures with
===leaks where they differ, so hookproof only compares decoded bytes throughcrypto.subtle.verify(...). - Replay β timestamped schemes (Stripe, Slack, Svix, β¦) are replayable unless you reject stale requests, so hookproof enforces a configurable window (default 300s) with an injectable clock.
- The raw-body footgun β framework body parsers rewrite the bytes, so the body must be verified exactly as received. hookproof takes
string | Uint8Arrayand never touches it before verifying.
One API handles all of it, for every provider.
npm install hookproof
# or: pnpm add hookproof Β· bun add hookproof Β· deno add npm:hookproofThree lines in a Next.js App Router route:
import { verifyWebhook } from "hookproof";
export async function POST(request: Request) {
const webhook = await verifyWebhook("stripe", {
payload: new Uint8Array(await request.arrayBuffer()), // raw bytes β never JSON.parse first
headers: request.headers,
secret: process.env.STRIPE_WEBHOOK_SECRET,
});
return Response.json({ received: true, id: webhook.id });
}Prefer the non-throwing variant when you want to branch on the reason:
import { verifyWebhookSafe } from "hookproof";
const result = await verifyWebhookSafe("stripe", {
payload: rawBody,
headers: request.headers,
secret: process.env.STRIPE_WEBHOOK_SECRET,
});
if (!result.ok) {
// result.code is a stable, machine-readable string
console.warn(`rejected: ${result.code}`);
} else {
handle(result.webhook);
}Every provider is also importable directly for tree-shaking:
import { verifyStripe } from "hookproof/stripe";
import { verifyGithub } from "hookproof/github";Nothing here uses node:crypto β all crypto goes through globalThis.crypto.subtle.
| Runtime | Supported | Notes |
|---|---|---|
| Node.js β₯ 20 | β | |
| Bun | β | |
| Deno | β | |
| Cloudflare Workers | β | |
| Vercel Edge | β | |
| Ed25519 providers (Discord) | Needs Web Crypto Ed25519 (Node β₯ 20, Deno, Bun, recent Workers); throws UnsupportedRuntimeError otherwise. |
13 providers shipped.
| Provider | Status | Scheme docs |
|---|---|---|
clerk |
β shipped | docs |
discord |
β shipped | docs |
github |
β shipped | docs |
lemonsqueezy |
β shipped | docs |
meta |
β shipped | docs |
paddle |
β shipped | docs |
resend |
β shipped | docs |
shopify |
β shipped | docs |
slack |
β shipped | docs |
standardwebhooks |
β shipped | docs |
stripe |
β shipped | docs |
svix |
β shipped | docs |
twilio |
β shipped | docs |
π Up for grabs (49) β paypal, square, adyen, mollie, razorpay, coinbase-commerce, wise, iyzico, paytr, gumroad, patreon, whop, gitlab, bitbucket, linear, jira, sentry, vercel, netlify, render, railway, supabase, hasura, contentful, sanity, strapi, algolia, typeform, tally, cal.com, calendly, zoom, intercom, zendesk, hubspot, mailgun, postmark, sendgrid, loops, customerio, brevo, klaviyo, workos, stytch, kinde, docusign, dropbox-sign, shippo, easypost. Claim one and add it in ~30 minutes.
Missing one? Add a provider in ~30 minutes.
Cloudflare Workers and Deno use the native Request, so no adapter is needed β preserve the raw
bytes with new Uint8Array(await request.arrayBuffer()).
import { verifyNextWebhook } from "hookproof/next";
export async function POST(request: Request) {
try {
const webhook = await verifyNextWebhook("github", request, {
secret: process.env.GITHUB_WEBHOOK_SECRET,
});
return Response.json({ received: true, payload: webhook.payload });
} catch {
return new Response("invalid signature", { status: 400 });
}
}Mount express.raw({ type: "*/*" }) on the webhook route so req.body stays a raw Buffer:
import express from "express";
import { verifyExpressWebhook } from "hookproof/express";
const app = express();
app.post("/webhooks/stripe", express.raw({ type: "*/*" }), async (req, res) => {
try {
const webhook = await verifyExpressWebhook("stripe", req, {
secret: process.env.STRIPE_WEBHOOK_SECRET,
});
res.json({ received: true, id: webhook.id });
} catch {
res.status(400).send("invalid signature");
}
});import { Hono } from "hono";
import { verifyHonoWebhook } from "hookproof/hono";
const app = new Hono();
app.post("/webhooks/slack", async (c) => {
try {
await verifyHonoWebhook("slack", c, { secret: c.env.SLACK_SIGNING_SECRET });
return c.json({ received: true });
} catch {
return c.text("invalid signature", 400);
}
});import { verifyWebhook } from "hookproof";
interface Env {
GITHUB_WEBHOOK_SECRET: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
try {
await verifyWebhook("github", {
payload: new Uint8Array(await request.arrayBuffer()),
headers: request.headers,
secret: env.GITHUB_WEBHOOK_SECRET,
});
return new Response("ok");
} catch {
return new Response("invalid signature", { status: 400 });
}
},
};import { verifyWebhook } from "hookproof";
Deno.serve(async (request: Request) => {
try {
await verifyWebhook("github", {
payload: new Uint8Array(await request.arrayBuffer()),
headers: request.headers,
secret: Deno.env.get("GITHUB_WEBHOOK_SECRET"),
});
return new Response("ok");
} catch {
return new Response("invalid signature", { status: 400 });
}
});verifyWebhook throws a WebhookVerificationError subclass on any failure; verifyWebhookSafe
returns a discriminated result instead. Error codes are stable and machine-readable:
missing_header Β· malformed_header Β· invalid_signature Β· timestamp_out_of_tolerance Β· missing_secret Β· missing_public_key Β· missing_url Β· unsupported_provider Β· unsupported_runtime
Error messages never contain secret material or payload contents.
Providers are thin configurations over shared engines, so the whole library stays tiny and every provider behaves consistently:
- HMAC engine β hash (SHA-1/256/512), encoding (hex/base64/base64url), header + prefix, custom key derivation, multi-signature, and an optional replay window. Covers most providers.
- Svix / Standard Webhooks engine β
{id}.{ts}.{body}, base64,whsec_key derivation,v1,signature lists. - Ed25519 engine β asymmetric verification via
crypto.subtle.verify("Ed25519", β¦)(Discord). - Bespoke β the rare provider that doesn't fit (Twilio signs the URL + params).
Adding a provider is a small config plus test vectors β see CONTRIBUTING.md.
Your provider missing? Adding one takes about 30 minutes:
pnpm new-provider acmeThe scaffold generates the provider, its test skeleton, and its README, and registers it β you fill in the scheme config and vectors. See CONTRIBUTING.md and the provider wishlist.
Found a vulnerability? Please follow the responsible-disclosure process in SECURITY.md β do not open a public issue.
MIT Β© hookproof contributors
