Webhook signature verification middleware for Hono. Verify webhooks from any provider with one line.
Works on Cloudflare Workers, Deno, Bun, Node.js, and any platform that supports the Web Crypto API.
| Provider | Signature Header | Algorithm |
|---|---|---|
| Stripe | Stripe-Signature |
HMAC-SHA256 + timestamp |
| GitHub | X-Hub-Signature-256 |
HMAC-SHA256 |
| Slack | X-Slack-Signature |
HMAC-SHA256 + timestamp |
| Shopify | X-Shopify-Hmac-Sha256 |
HMAC-SHA256 (base64) |
| Twilio | X-Twilio-Signature |
HMAC-SHA1 + URL + params |
| LINE | X-Line-Signature |
HMAC-SHA256 (base64) |
| Discord | X-Signature-Ed25519 |
Ed25519 |
| Standard Webhooks | webhook-signature |
HMAC-SHA256 (svix-compatible) |
| Custom | Any | defineProvider() |
npm install hono-webhook-verify
# or
pnpm add hono-webhook-verify
# or
bun add hono-webhook-verifyimport { Hono } from "hono";
import { webhookVerify } from "hono-webhook-verify";
import { stripe } from "hono-webhook-verify/providers/stripe";
import type { WebhookVerifyVariables } from "hono-webhook-verify";
const app = new Hono<{ Variables: WebhookVerifyVariables }>();
app.post(
"/webhooks/stripe",
webhookVerify({
provider: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),
}),
(c) => {
const payload = c.get("webhookPayload");
const rawBody = c.get("webhookRawBody");
const provider = c.get("webhookProvider"); // "stripe"
return c.json({ received: true });
},
);
export default app;import { stripe } from "hono-webhook-verify/providers/stripe";
webhookVerify({
provider: stripe({
secret: "whsec_...",
tolerance: 300, // optional: timestamp tolerance in seconds (default: 300)
}),
});import { github } from "hono-webhook-verify/providers/github";
webhookVerify({
provider: github({ secret: "your-webhook-secret" }),
});import { slack } from "hono-webhook-verify/providers/slack";
webhookVerify({
provider: slack({
signingSecret: "your-signing-secret",
tolerance: 300, // optional: timestamp tolerance in seconds (default: 300)
}),
});import { shopify } from "hono-webhook-verify/providers/shopify";
webhookVerify({
provider: shopify({ secret: "your-webhook-secret" }),
});import { twilio } from "hono-webhook-verify/providers/twilio";
webhookVerify({
provider: twilio({ authToken: "your-auth-token" }),
});import { line } from "hono-webhook-verify/providers/line";
webhookVerify({
provider: line({ channelSecret: "your-channel-secret" }),
});import { discord } from "hono-webhook-verify/providers/discord";
webhookVerify({
provider: discord({ publicKey: "your-ed25519-public-key-hex" }),
});import { standardWebhooks } from "hono-webhook-verify/providers/standard-webhooks";
webhookVerify({
provider: standardWebhooks({
secret: "whsec_...", // base64-encoded secret with optional whsec_ prefix
tolerance: 300, // optional: timestamp tolerance in seconds (default: 300)
}),
});Use defineProvider() with the built-in crypto utilities to create a provider for any webhook source:
import {
defineProvider,
webhookVerify,
hmac,
fromHex,
timingSafeEqual,
} from "hono-webhook-verify";
const myProvider = defineProvider<{ secret: string }>((options) => ({
name: "my-service",
async verify({ rawBody, headers }) {
const signature = headers.get("X-My-Signature");
if (!signature) {
return { valid: false, reason: "missing-signature" };
}
const expected = await hmac("SHA-256", options.secret, rawBody);
const received = fromHex(signature);
if (!received || !timingSafeEqual(expected, received)) {
return { valid: false, reason: "invalid-signature" };
}
return { valid: true };
},
}));
app.post(
"/webhooks/my-service",
webhookVerify({ provider: myProvider({ secret: "..." }) }),
(c) => c.json({ ok: true }),
);Available crypto utilities: hmac, toHex, fromHex, toBase64, fromBase64, timingSafeEqual.
After successful verification, the middleware sets these variables on the Hono context:
| Variable | Type | Description |
|---|---|---|
webhookRawBody |
string |
The raw request body |
webhookPayload |
unknown |
Parsed JSON payload (or null if not JSON) |
webhookProvider |
string |
Provider name (e.g., "stripe", "github") |
For TypeScript, use the WebhookVerifyVariables type:
import type { WebhookVerifyVariables } from "hono-webhook-verify";
const app = new Hono<{ Variables: WebhookVerifyVariables }>();By default, verification failures return a 401 response in RFC 9457 Problem Details format:
{
"type": "https://hono-webhook-verify.dev/errors/missing-signature",
"title": "Missing webhook signature",
"status": 401,
"detail": "Required webhook signature header is missing"
}When hono-problem-details is installed, error responses are generated using its problemDetails().getResponse(). Otherwise, a built-in fallback is used. No configuration needed — detection is automatic.
Use the onError callback for custom error responses:
// Logging
webhookVerify({
provider: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),
onError: (error, c) => {
console.error("Webhook verification failed:", error.title, error.detail);
return c.json({ error: "Invalid webhook" }, 401);
},
});// Custom error response with logging
webhookVerify({
provider: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),
onError: (error, c) => {
console.error("Webhook verification failed:", error.detail);
return c.json({ error: error.title }, error.status as 400 | 401);
},
});Use detectProvider() to identify the webhook source from request headers:
import { detectProvider } from "hono-webhook-verify";
const provider = detectProvider(request.headers);
// => "stripe" | "github" | "slack" | "shopify" | "twilio" | "line" | "discord" | "standard-webhooks" | nullHandle multiple webhook providers on a single endpoint:
import { Hono } from "hono";
import { detectProvider, webhookVerify } from "hono-webhook-verify";
import type { WebhookVerifyVariables } from "hono-webhook-verify";
import { github } from "hono-webhook-verify/providers/github";
import { stripe } from "hono-webhook-verify/providers/stripe";
const providers = {
stripe: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),
github: github({ secret: process.env.GITHUB_WEBHOOK_SECRET! }),
};
const app = new Hono<{ Variables: WebhookVerifyVariables }>();
app.post("/webhooks", async (c, next) => {
const name = detectProvider(c.req.raw.headers);
const provider = name ? providers[name as keyof typeof providers] : undefined;
if (!provider) {
return c.json({ error: "Unknown webhook provider" }, 400);
}
return webhookVerify({ provider })(c, next);
}, (c) => {
const provider = c.get("webhookProvider");
const payload = c.get("webhookPayload");
console.log(`Received ${provider} webhook`);
return c.json({ received: true });
});import { Hono } from "hono";
import { webhookVerify } from "hono-webhook-verify";
import type { WebhookVerifyVariables } from "hono-webhook-verify";
import { stripe } from "hono-webhook-verify/providers/stripe";
type Bindings = { STRIPE_WEBHOOK_SECRET: string };
const app = new Hono<{ Bindings: Bindings; Variables: WebhookVerifyVariables }>();
app.post("/webhooks/stripe", (c, next) => {
const middleware = webhookVerify({
provider: stripe({ secret: c.env.STRIPE_WEBHOOK_SECRET }),
});
return middleware(c, next);
}, (c) => {
return c.json({ received: true });
});
export default app;import { Hono } from "npm:hono";
import { webhookVerify } from "npm:hono-webhook-verify";
import { github } from "npm:hono-webhook-verify/providers/github";
const app = new Hono();
app.post("/webhooks/github",
webhookVerify({
provider: github({ secret: Deno.env.get("GITHUB_WEBHOOK_SECRET")! }),
}),
(c) => c.json({ received: true }),
);
Deno.serve(app.fetch);import { Hono } from "hono";
import { webhookVerify } from "hono-webhook-verify";
import { github } from "hono-webhook-verify/providers/github";
const app = new Hono();
app.post("/webhooks/github",
webhookVerify({
provider: github({ secret: Bun.env.GITHUB_WEBHOOK_SECRET! }),
}),
(c) => c.json({ received: true }),
);
export default app;- Check the secret format: Stripe uses
whsec_...prefix. Standard Webhooks secrets are base64-encoded (with optionalwhsec_prefix). Discord requires a hex-encoded Ed25519 public key. - Don't read the body before the middleware:
webhookVerifyreadsc.req.text()internally. If another middleware consumes the body first, verification will fail because the raw body won't match the signature. - Environment variables: Ensure your secret is loaded correctly. An extra newline or whitespace in
.envcan cause mismatches.
- Clock skew: Ensure your server's clock is synchronized (NTP). Providers like Stripe, Slack, and Standard Webhooks include timestamps and reject if the difference exceeds the tolerance (default: 300 seconds).
- Increase tolerance: If your processing pipeline has high latency, increase the
toleranceoption:stripe({ secret: "whsec_...", tolerance: 600 }) // 10 minutes
All providers validate that the secret is non-empty at construction time. If you see "<provider>: secret must not be empty", check that your environment variable is set and not undefined.
Twilio signs the full request URL including the protocol and host. Behind a reverse proxy, c.req.url may report http:// instead of https://. Ensure your proxy sets the correct X-Forwarded-Proto header and your app reconstructs the correct URL.
- All signature comparisons use constant-time comparison (
crypto.subtle.timingSafeEqualwhen available, XOR fallback otherwise) - Signatures are decoded to raw bytes before comparison to prevent timing leaks from string operations
- Timestamp-based providers (Stripe, Slack, Standard Webhooks) reject expired signatures to prevent replay attacks
- Discord uses Ed25519 asymmetric verification via
crypto.subtle.verify