webhook-kit
Zero-dependency, edge-compatible webhook signature verification and declarative route handlers powered by standard Web Cryptography (crypto.subtle). Designed for agentic workflows, automatic stream-cloning, and pluggable replay defense.
Agentic Flow: Configure AI Guardrails First
AI coding agents (Claude Code, Cursor, Copilot, Codex) frequently break webhook endpoints by calling await req.json() before checking HMAC signatures or attempting to import node:crypto.
Run this CLI command first in your project repository. It autodetects AGENTS.md or CLAUDE.md and injects idempotent verification rules so your coding agents generate correct, stream-safe code automatically:
# Autodetect AGENTS.md or CLAUDE.md and inject rulesnpx @amirabaris/webhook-kit init-agent# Or target a specific agent filenpx @amirabaris/webhook-kit init-agent --target CLAUDE.mdnpx @amirabaris/webhook-kit init-agent --target AGENTS.md
- Pass the raw Request or byte string directly — never parse JSON before signature verification.
- Rely on Web Cryptography (
crypto.subtle) for universal edge compatibility. - Leverage automatic stream-cloning so downstream logic never encounters a locked stream.
Install the package
@amirabaris/webhook-kit has zero runtime dependencies, ships dual ESM/CJS exports, and includes full TypeScript declarations.
pnpm add @amirabaris/webhook-kit
Verification Patterns: Functional vs Exception
webhook-kit provides two verification styles to match your preferred control flow:
- Functional Pattern (
verifyWebhookSignature): Returns a discriminated union (result.valid) for strict compiler narrowing without exceptions. - Exception Pattern (
assertWebhookSignature): Returns the strongly typed payload directly or throws specific, typedWebhookKitErrorsubclasses for centralizedtry / catchhandling.
import { verifyWebhookSignature } from "@amirabaris/webhook-kit"export async function POST(req: Request) {// Pass the raw Request directly — req.clone() is used internally// so req.bodyUsed stays false for downstream middleware or loggingconst result = await verifyWebhookSignature({body: req,provider: "stripe", // Built-in: 'stripe' | 'github' | 'shopify' (or generic HMAC)secret: process.env.STRIPE_WEBHOOK_SECRET!,toleranceSeconds: 300, // Replay attack protection window})// Discriminated union narrowing:if (!result.valid) {// result.error is strongly typed WebhookKitError with machine-readable codeconsole.error("Verification failed:", result.error.code, result.error.message)return new Response(`Unauthorized: ${result.error.message}`, { status: 401 })}// result.payload is safely parsed and typed only after valid HMAC verificationconst event = result.payloadconsole.log("Verified event:", event.type, event.id)return Response.json({ received: true, id: event.id })}
When you pass a Web Standard Request to any verification function, webhook-kit internally calls req.clone().text(). The original request body stream remains unconsumed (req.bodyUsed === false), allowing downstream proxies, middleware, or await req.json() to execute safely without throwing “Body already consumed”.
Route Handlers & Framework Recipes
Use createWebhookHandler to create drop-in route handlers with automatic stream-cloning, HMAC verification, replay detection, and automatic HTTP status mapping (401, 400, 409).
// app/api/webhook/route.tsimport { createWebhookHandler } from "@amirabaris/webhook-kit"// Declarative 3-line route handler for Next.js App Router// Automatically clones request stream, verifies HMAC, and dispatches typed eventexport const POST = createWebhookHandler({provider: "stripe",secret: process.env.STRIPE_WEBHOOK_SECRET!,onEvent: async (event, { rawBody, request, eventId }) => {// event is strongly typed StripeWebhookEvent// request.bodyUsed remains false thanks to automatic stream-cloning!switch (event.type) {case "checkout.session.completed":console.log("Fulfilling checkout session:", event.data.object.id)breakdefault:console.log(`Unhandled event type: ${event.type}`)}},// Automatically returns:// - 200 { received: true } on success// - 401 Unauthorized on invalid signature / missing header// - 400 Bad Request on stream extraction error// - 409 Conflict on duplicate replay event})
In Express servers, global app.use(express.json()) re-encodes incoming JSON and alters raw bytes, breaking HMAC checks. Always register webhook routes with express.raw({ type: "application/json" }) before any global body-parsing middleware.
Replay Attack Defense & Idempotency Store
When upstream providers retry failed webhooks, network hiccups can cause duplicate event executions (like double billing or repeated order fulfillment). webhook-kit employs two layers of defense:
- Timestamp Tolerance Window: Validates delivery timestamps against
toleranceSeconds(default 300s). Rejects non-finite values (NaN,Infinity) and negative values to prevent validation bypass. - Pluggable Replay Store (
store): Automatically extracts the event ID (from headers likesvix-id,X-GitHub-Delivery,X-Shopify-Webhook-Id, orpayload.id) with support for string and numeric IDs (such as Shopify order IDs), and rejects duplicates with409 Conflict(ReplayAttackError).
import {createWebhookHandler,createInMemoryReplayStore,} from "@amirabaris/webhook-kit"// Bounded in-memory LRU store with automatic TTL cleanup// Perfect for single-instance Node.js, Bun, or Deno serversconst store = createInMemoryReplayStore({maxSize: 10_000, // Max items in memory (LRU eviction)defaultTtlSeconds: 300, // 5-minute deduplication window})export const POST = createWebhookHandler({provider: "stripe",secret: process.env.STRIPE_WEBHOOK_SECRET!,store,onEvent: async (event, { eventId }) => {// Automatically extracts eventId from headers (X-GitHub-Delivery, X-Shopify-Webhook-Id)// or payload.id (e.g. evt_...). Rejects duplicate calls with 409 Conflict.console.log("Processing order fulfillment once:", eventId)},})
Error Hierarchy & Machine-Readable Codes
All errors inherit from WebhookKitError and carry machine-readable code properties for programmatic handling:
| Error Class | Error Code | HTTP Status | Cause & Mitigation |
|---|---|---|---|
| SignatureVerificationError | SIGNATURE_VERIFICATION_FAILED | 401 | HMAC digest mismatch, wrong signing secret, or invalid digest format (e.g. non-hex characters or wrong length). |
| TimestampToleranceError | TIMESTAMP_TOLERANCE_EXCEEDED | 401 | Payload timestamp outside toleranceSeconds window, or non-finite timestamp/tolerance value (e.g. NaN or Infinity). |
| ReplayAttackError | WEBHOOK_REPLAY_DETECTED | 409 | Event ID was already processed in store. Prevents duplicate webhook side effects during retry storms. |
| InvalidSignatureHeaderError | INVALID_SIGNATURE_HEADER | 401 | Header is missing, empty, or lacks required prefix (sha256=, t=...,v1=...). |
| PayloadExtractionError | PAYLOAD_EXTRACTION_FAILED | 400 | Request body is an unsupported format or input stream was prematurely closed. |
| WebhookKitError | WEBHOOK_KIT_ERROR | 500 | Base class for all webhook-kit errors. Contains code and message properties. |
Reference: Providers & Runtimes
- Stripe: Parses
Stripe-Signature(timestampt=...+ normalized hex HMACv1=...), typedStripeWebhookEvent. - GitHub: Parses
x-hub-signature-256(strict 64-char hexsha256=...), auto-extractsx-github-delivery, typedGithubWebhookEvent. - Shopify: Parses
x-shopify-hmac-sha256(strict 44-char Base64 HMAC-SHA256), auto-extractsx-shopify-webhook-id, typedShopifyWebhookEvent. - Custom / Generic HMAC: Supports SHA-256, SHA-384, SHA-512, and SHA-1 in hex or base64.
- Cloudflare Workers & Edge isolates (native Web Crypto, zero Node polyfills).
- Next.js (Node.js & Edge runtimes with App Router Request).
- Node.js (≥ 22.0.0, native
globalThis.crypto.subtle). - Bun & Deno (native Web Cryptography & Web Standards, zero shims).
import type {WebhookProviderPlugin,WebhookVerifyOptions,WebhookVerificationResult,} from "@amirabaris/webhook-kit"import {computeSubtleHmac,timingSafeEqual,SignatureVerificationError,} from "@amirabaris/webhook-kit"// Extensible provider plugin interfaceexport const customSmsPlugin: WebhookProviderPlugin<{ messageId: string }> = {name: "custom-sms",async verify(options: WebhookVerifyOptions): Promise<WebhookVerificationResult<{ messageId: string }>> {// 1. Compute expected HMAC with Web Cryptography APIconst expected = await computeSubtleHmac("SHA-256",options.secret,options.rawBody ?? "","hex")// 2. Timing-safe equality checkconst valid = timingSafeEqual(expected, options.signature ?? "")if (!valid) {return {valid: false,rawBody: options.rawBody ?? "",error: new SignatureVerificationError(),}}return {valid: true,rawBody: options.rawBody ?? "",payload: { messageId: "msg_123" },}},}