Verify-before-parse helpers, replay protection, normalized event envelopes, and job queue handoff for vendor webhooks. Implements the pattern defined in ADR-065.
Introduction
Overview
ADR-065 defines a rigorous webhook handling pattern: verify signature before parsing, reject replayed events, normalize vendor payloads into internal domain events, and acknowledge fast with async processing on a job queue. No implementation exists today. Each app reimplements signature verification with subtle differences, has inconsistent replay windows, and parses vendor payloads directly in handler code. This FRD defines a webhook-handling package that provides signature verification, replay protection, event normalization, and job queue dispatch as composable utilities.
Goals
Provide verifyWebhookSignature() helpers for common vendor signature schemes (HMAC-SHA256, Stripe v1, Svix, raw HMAC).
Implement replay protection with configurable tolerance window and event ID deduplication.
Define a WebhookEvent normalized envelope type that all vendor payloads map to.
Provide a createWebhookHandler() factory that composes verification, replay protection, normalization, and queue dispatch.
Ship per-provider examples (Stripe, Resend, generic HMAC) demonstrating the full flow.
Include a comprehensive test suite validating signature verification and replay rejection.
Non-Goals
Implementing vendor-specific payload parsers for every possible webhook source.
Building HTTP route handlers for specific frameworks (Express, Hono, Astro) — the package provides framework-agnostic utilities.
Implementing the job queue itself (see FRD: Jobs/Queue Provider).
Building a webhook management UI or dashboard.
Outbound webhook sending.
Scope
In Scope
Area
Description
Signature verification
verifyWebhookSignature() supporting HMAC-SHA256, Stripe v1= scheme, Svix, and raw HMAC
Replay protection
ReplayGuard with configurable max-age window and event ID deduplication
Event envelope
WebhookEvent normalized type with { id, type, vendor, data, receivedAt, verified }
Handler factory
createWebhookHandler({ verify, normalize, dispatch }) composing the full pipeline
Provider examples
Example normalizers for Stripe, Resend, and generic HMAC webhooks
Test utilities
createTestWebhookPayload() helpers for generating signed test payloads
Documentation
Storybook docs with per-vendor integration examples
Out of Scope
Area
Reason
Framework-specific route handlers
Apps integrate the utilities into their own routing layer
Job queue implementation
Covered by FRD: Jobs/Queue Provider; this package dispatches to a QueueProvider
Vendor-specific payload parsing for all vendors
Only provide examples; each vendor adapter handles its own parsing per ADR-051
Outbound webhook sending
Separate concern; this package is for inbound webhooks only
Webhook registration/subscription management
Vendor-side configuration, not a library responsibility
Users and Pain Points
User Groups
User
Description
Needs
App developers
Engineers building webhook endpoints
Composable verify-before-parse utilities that follow ADR-065
QA engineers
Testers validating webhook processing
Test payload generators with valid signatures for integration tests
Security reviewers
Engineers auditing webhook handlers
A single, auditable verification path with no bypass
Pain Points
User
Pain Point
Impact
App developers
Each app reimplements HMAC verification with subtle timing-safe comparison bugs
Security vulnerabilities from incorrect signature verification
App developers
No replay protection — events can be replayed to trigger duplicate processing
Data corruption from double-processing (duplicate charges, duplicate emails)
No standard verification path to audit — each handler is unique
Inconsistent security posture across webhook endpoints
Definitions
Term
Definition
Signature verification
Cryptographic proof that the payload was sent by the claimed vendor and was not tampered with
Replay attack
Re-sending a previously valid webhook payload to trigger duplicate processing
Replay guard
A mechanism that rejects events older than a tolerance window or already seen by event ID
Event envelope
The normalized wrapper around a webhook payload with standard metadata fields
Timing-safe comparison
A string comparison that takes constant time regardless of where a mismatch occurs, preventing timing attacks
Normalizer
A function that transforms a vendor-specific payload into the standard WebhookEvent envelope
Current State
Existing Behavior
ADR-065 documents the webhook handling pattern in detail but no code implements it. The auth provider (auth-provider.ts) includes a verifyWebhook() method, but it is provider-specific and not reusable across vendors. No shared signature verification, replay protection, or event normalization utilities exist.
Current Limitations
No shared verifyWebhookSignature() utility — each app rolls its own HMAC check.
No replay protection — handlers process every delivery, including retries and replays.
No normalized event type — handlers parse raw vendor JSON with any types.
No test payload generators — testing webhook handlers requires manual payload construction.
No composable handler factory — the ADR-065 pattern must be implemented from scratch each time.
Existing Workarounds
Apps use crypto.timingSafeEqual directly with hand-written HMAC logic.
Some apps check a Stripe event timestamp but do not track event IDs for deduplication.
Handlers type vendor payloads as any or use vendor SDK type imports.
Proposed Solution
Summary
Add a src/lib/webhooks/ directory containing signature verification utilities, a replay guard, a WebhookEvent envelope type, a composable handler factory, and test helpers. The package does not depend on any vendor SDK — vendor-specific signature schemes are implemented from primitives (crypto.createHmac). The handler factory composes verification, replay checking, normalization, and queue dispatch into a single pipeline.
Key Capabilities
verifyWebhookSignature(payload, signature, secret, scheme) with built-in support for HMAC-SHA256, Stripe v1= timestamp scheme, and Svix.
ReplayGuard class with configurable maxAgeMs (default 5 minutes) and in-memory event ID set with automatic expiry.