Documentation·v0.1.2

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.

1

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:

terminalZero-Dep CLI
# Autodetect AGENTS.md or CLAUDE.md and inject rules
npx @amirabaris/webhook-kit init-agent
# Or target a specific agent file
npx @amirabaris/webhook-kit init-agent --target CLAUDE.md
npx @amirabaris/webhook-kit init-agent --target AGENTS.md
What the injected rules enforce:
  • 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.
2

Install the package

@amirabaris/webhook-kit has zero runtime dependencies, ships dual ESM/CJS exports, and includes full TypeScript declarations.

terminal (pnpm)
pnpm add @amirabaris/webhook-kit
3

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, typed WebhookKitError subclasses for centralized try / catch handling.
app/api/webhook/route.tsDiscriminated Union
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 logging
const 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 code
console.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 verification
const event = result.payload
console.log("Verified event:", event.type, event.id)
return Response.json({ received: true, id: event.id })
}
Automatic Stream-Cloning Guarantee:

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”.

4

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.ts3-Line Next.js Route
// app/api/webhook/route.ts
import { createWebhookHandler } from "@amirabaris/webhook-kit"
// Declarative 3-line route handler for Next.js App Router
// Automatically clones request stream, verifies HMAC, and dispatches typed event
export 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)
break
default:
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
})
Express & Node Body Parser Pitfall:

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.

5

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:

  1. Timestamp Tolerance Window: Validates delivery timestamps against toleranceSeconds (default 300s). Rejects non-finite values (NaN, Infinity) and negative values to prevent validation bypass.
  2. Pluggable Replay Store (store): Automatically extracts the event ID (from headers like svix-id, X-GitHub-Delivery, X-Shopify-Webhook-Id, or payload.id) with support for string and numeric IDs (such as Shopify order IDs), and rejects duplicates with 409 Conflict (ReplayAttackError).
app/api/webhook/route.tsZero-Dep Built-In
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 servers
const 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)
},
})
6

Error Hierarchy & Machine-Readable Codes

All errors inherit from WebhookKitError and carry machine-readable code properties for programmatic handling:

Error ClassError CodeHTTP StatusCause & Mitigation
SignatureVerificationErrorSIGNATURE_VERIFICATION_FAILED401HMAC digest mismatch, wrong signing secret, or invalid digest format (e.g. non-hex characters or wrong length).
TimestampToleranceErrorTIMESTAMP_TOLERANCE_EXCEEDED401Payload timestamp outside toleranceSeconds window, or non-finite timestamp/tolerance value (e.g. NaN or Infinity).
ReplayAttackErrorWEBHOOK_REPLAY_DETECTED409Event ID was already processed in store. Prevents duplicate webhook side effects during retry storms.
InvalidSignatureHeaderErrorINVALID_SIGNATURE_HEADER401Header is missing, empty, or lacks required prefix (sha256=, t=...,v1=...).
PayloadExtractionErrorPAYLOAD_EXTRACTION_FAILED400Request body is an unsupported format or input stream was prematurely closed.
WebhookKitErrorWEBHOOK_KIT_ERROR500Base class for all webhook-kit errors. Contains code and message properties.
7

Reference: Providers & Runtimes

Built-in Providers
  • Stripe: Parses Stripe-Signature (timestamp t=... + normalized hex HMAC v1=...), typed StripeWebhookEvent.
  • GitHub: Parses x-hub-signature-256 (strict 64-char hex sha256=...), auto-extracts x-github-delivery, typed GithubWebhookEvent.
  • Shopify: Parses x-shopify-hmac-sha256 (strict 44-char Base64 HMAC-SHA256), auto-extracts x-shopify-webhook-id, typed ShopifyWebhookEvent.
  • Custom / Generic HMAC: Supports SHA-256, SHA-384, SHA-512, and SHA-1 in hex or base64.
Supported Runtimes
  • 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).
Extensible Plugin ArchitectureWebhookProviderPlugin Interface
lib/custom-plugin.tsCustom Plugin
import type {
WebhookProviderPlugin,
WebhookVerifyOptions,
WebhookVerificationResult,
} from "@amirabaris/webhook-kit"
import {
computeSubtleHmac,
timingSafeEqual,
SignatureVerificationError,
} from "@amirabaris/webhook-kit"
// Extensible provider plugin interface
export const customSmsPlugin: WebhookProviderPlugin<{ messageId: string }> = {
name: "custom-sms",
async verify(options: WebhookVerifyOptions): Promise<WebhookVerificationResult<{ messageId: string }>> {
// 1. Compute expected HMAC with Web Cryptography API
const expected = await computeSubtleHmac(
"SHA-256",
options.secret,
options.rawBody ?? "",
"hex"
)
// 2. Timing-safe equality check
const 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" },
}
},
}