Skip to content

FRD: Webhook Handling Package

Document Summary

FieldDetails
Feature NameWebhook Handling Package
StatusDraft
OwnerDavid Holmes
ContributorsEngineering
Target Releasev2.0.0
Related LinksADR-051 (Provider Pattern), ADR-065 (Webhook Handling), ADR-027 (Default Tech Stack)
Last Updated2026-05-26
Open Source Librariesstandardwebhooks, svix

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

AreaDescription
Signature verificationverifyWebhookSignature() supporting HMAC-SHA256, Stripe v1= scheme, Svix, and raw HMAC
Replay protectionReplayGuard with configurable max-age window and event ID deduplication
Event envelopeWebhookEvent normalized type with { id, type, vendor, data, receivedAt, verified }
Handler factorycreateWebhookHandler({ verify, normalize, dispatch }) composing the full pipeline
Provider examplesExample normalizers for Stripe, Resend, and generic HMAC webhooks
Test utilitiescreateTestWebhookPayload() helpers for generating signed test payloads
DocumentationStorybook docs with per-vendor integration examples

Out of Scope

AreaReason
Framework-specific route handlersApps integrate the utilities into their own routing layer
Job queue implementationCovered by FRD: Jobs/Queue Provider; this package dispatches to a QueueProvider
Vendor-specific payload parsing for all vendorsOnly provide examples; each vendor adapter handles its own parsing per ADR-051
Outbound webhook sendingSeparate concern; this package is for inbound webhooks only
Webhook registration/subscription managementVendor-side configuration, not a library responsibility

Users and Pain Points

User Groups

UserDescriptionNeeds
App developersEngineers building webhook endpointsComposable verify-before-parse utilities that follow ADR-065
QA engineersTesters validating webhook processingTest payload generators with valid signatures for integration tests
Security reviewersEngineers auditing webhook handlersA single, auditable verification path with no bypass

Pain Points

UserPain PointImpact
App developersEach app reimplements HMAC verification with subtle timing-safe comparison bugsSecurity vulnerabilities from incorrect signature verification
App developersNo replay protection — events can be replayed to trigger duplicate processingData corruption from double-processing (duplicate charges, duplicate emails)
App developersVendor payloads leak into app code — handlers parse Stripe JSON directlyVendor lock-in; changing payment providers requires rewriting handlers
Security reviewersNo standard verification path to audit — each handler is uniqueInconsistent security posture across webhook endpoints

Definitions

TermDefinition
Signature verificationCryptographic proof that the payload was sent by the claimed vendor and was not tampered with
Replay attackRe-sending a previously valid webhook payload to trigger duplicate processing
Replay guardA mechanism that rejects events older than a tolerance window or already seen by event ID
Event envelopeThe normalized wrapper around a webhook payload with standard metadata fields
Timing-safe comparisonA string comparison that takes constant time regardless of where a mismatch occurs, preventing timing attacks
NormalizerA 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.
  • WebhookEvent<T> envelope: { id, type, vendor, data: T, receivedAt, verified }.
  • createWebhookHandler({ verifier, replayGuard, normalizer, dispatcher }) factory returning a (rawPayload, headers) => Promise<WebhookHandlerResult> function.
  • createTestWebhookPayload(secret, payload, scheme) for generating correctly signed payloads in tests.

User Experience

End users are not directly affected. Webhook handling is a backend concern.

Developer Experience

Developers create a webhook handler by composing verification, normalization, and dispatch:

const handler = createWebhookHandler({
verifier: { secret: process.env.STRIPE_WEBHOOK_SECRET, scheme: "stripe-v1" },
replayGuard: { maxAgeMs: 300_000 },
normalizer: stripeNormalizer,
dispatcher: (event) => queue.enqueue("billing.webhook", { type: event.type, payload: event }),
});

Tests use createTestWebhookPayload to generate signed payloads without vendor SDKs.


Requirements

IDRequirementPriorityNotes
FR-001Export verifyWebhookSignature() supporting HMAC-SHA256, Stripe v1, and Svix schemesMust-
FR-002Export ReplayGuard with configurable max-age window and event ID deduplicationMust-
FR-003Export WebhookEvent<T> normalized envelope typeMust-
FR-004Export createWebhookHandler() factory composing verify, replay, normalize, and dispatchMust-
FR-005Export createTestWebhookPayload() for generating signed test payloadsMust-
FR-006Signature verification uses timing-safe comparisonMustSecurity requirement
FR-007Export example normalizers for Stripe and Resend webhooksShould-

Priority Definitions

PriorityMeaning
MustRequired for this feature to ship.
ShouldImportant, but can be deferred if needed.
CouldNice to have. Not required for initial release.

Functional Requirements

IDRequirementUser BenefitPriority
FUNC-001verifyWebhookSignature(payload, signature, secret, "hmac-sha256") returns true for valid signaturesStandard HMAC verification without manual crypto codeMust
FUNC-002verifyWebhookSignature with "stripe-v1" scheme parses the t= timestamp and v1= signature from the headerStripe webhook verification without Stripe SDKMust
FUNC-003ReplayGuard.check(eventId, timestamp) rejects events older than maxAgeMsPrevents replay attacksMust
FUNC-004ReplayGuard.check() rejects events with an already-seen ID within the windowPrevents duplicate processingMust
FUNC-005createWebhookHandler() returns 401 result for invalid signaturesUnverified payloads never reach app codeMust
FUNC-006createWebhookHandler() returns 200 result and dispatches to the queue on successFast acknowledgment per ADR-065Must
FUNC-007createTestWebhookPayload() generates a payload with a valid signature for any supported schemeTest webhook handlers without vendor sandboxesMust
FUNC-008ReplayGuard automatically evicts expired event IDs to prevent unbounded memory growthLong-running servers stay healthyShould

Non-Functional Requirements

IDRequirementCategoryPriority
NFR-001Signature verification uses crypto.timingSafeEqual to prevent timing attacksSecurityMust
NFR-002Zero vendor SDK imports — verification is implemented from Node.js crypto primitivesMaintainabilityMust
NFR-003ReplayGuard memory usage is bounded — expired entries are evicted automaticallyPerformanceMust
NFR-004Handler factory returns structured results (status code + body), not HTTP response objectsCompatibilityMust
NFR-005All utilities work in Node.js and edge runtimes (no Buffer-only APIs)CompatibilityShould
NFR-006Signing secrets must never appear in error messages or logsSecurityMust

API / Interface Requirements

Public API

NameTypeDescriptionRequired
verifyWebhookSignaturefunction(payload, signature, secret, scheme) => booleanYes
ReplayGuardclassConfigurable replay protection with check(eventId, timestamp)Yes
WebhookEvent<T>type{ id, type, vendor, data: T, receivedAt, verified }Yes
WebhookHandlerResulttype{ status: number, body: string, event?: WebhookEvent }Yes
createWebhookHandlerfunctionFactory composing verifier, replay guard, normalizer, dispatcherYes
createTestWebhookPayloadfunction(secret, payload, scheme) => { body, headers }Yes
SignatureSchemetype"hmac-sha256" | "stripe-v1" | "svix"Yes

Example Usage

import {
createWebhookHandler,
createTestWebhookPayload,
} from "@dmwd/design-system/webhooks";
// Production handler
const handler = createWebhookHandler({
verifier: { secret: process.env.WEBHOOK_SECRET!, scheme: "hmac-sha256" },
replayGuard: { maxAgeMs: 300_000 },
normalizer: (verified) => ({
id: verified.id,
type: `billing.${verified.eventType}`,
vendor: "stripe",
data: verified.data,
receivedAt: new Date().toISOString(),
verified: true,
}),
dispatcher: async (event) => {
await queue.enqueue("billing.webhook", { type: event.type, payload: event });
},
});
// In a route handler
const result = await handler(rawBody, request.headers);
return new Response(result.body, { status: result.status });
// In tests
const { body, headers } = createTestWebhookPayload("test_secret", {
id: "evt_123",
eventType: "invoice.paid",
data: { invoiceId: "inv_456" },
}, "hmac-sha256");

API Notes

  • The handler factory returns a WebhookHandlerResult, not an HTTP Response, so it works with any framework.
  • The normalizer function receives the parsed payload and returns a WebhookEvent — it is the boundary where vendor shapes become internal shapes.
  • ReplayGuard is instantiated by the factory but can also be used standalone for custom handlers.

Accessibility Requirements

IDRequirementNotes
A11Y-001Package is a backend utility with no UI — accessibility requirements do not applyNo UI components

Checklist

  • Keyboard support is defined. (N/A — no UI)
  • Focus behavior is defined. (N/A — no UI)
  • Screen reader behavior is defined. (N/A — no UI)
  • Color contrast requirements are met. (N/A — no UI)
  • Reduced motion behavior is considered. (N/A — no UI)
  • Semantic HTML expectations are documented. (N/A — no UI)
  • ARIA usage is defined only where needed. (N/A — no UI)

Content and Documentation Requirements

IDRequirementLocationPriority
DOC-001Storybook docs page explaining the webhook handling pattern and ADR-065 complianceStorybookMust
DOC-002Inline JSDoc on every exported function and typeSource codeMust
DOC-003Example: Stripe webhook handler using createWebhookHandler with queue dispatchStorybookMust
DOC-004Example: Resend webhook handlerStorybookShould
DOC-005Example: testing a webhook handler with createTestWebhookPayloadStorybookMust

Dependencies

DependencyTypeOwnerStatusNotes
ADR-065 webhook handlingArchitectureEngineeringReadyDefines the pattern this package implements
ADR-051 provider patternArchitectureEngineeringReadyWebhook handlers sit inside provider wrapper folders
Node.js crypto moduleRuntimeNode.jsReadyUsed for HMAC computation and timing-safe comparison
Jobs/queue providerLibraryEngineeringNot StartedWebhook handlers dispatch to the queue provider

Risks and Tradeoffs

Risk / TradeoffImpactMitigation
In-memory replay guard does not persist across server restartsBrief window of duplicate processing after restartDocument that production deployments should use a persistent replay guard (Redis-backed)
Supporting multiple signature schemes increases code surfaceMore code to audit and maintainEach scheme is a small, isolated function; the factory delegates to the matching verifier
Framework-agnostic design means apps must wire their own route handlersSlightly more integration code per appProvide copy-paste examples for Astro, Hono, and Express in documentation
Edge runtime compatibility requires avoiding Buffer-only APIsSome Node.js crypto patterns need adaptationUse Uint8Array and TextEncoder where possible; document Node.js-only fallbacks

Open Questions

IDQuestionOwnerStatusResolution
Q-001Should the ReplayGuard support a persistent backend (Redis) as a pluggable store, or only in-memory?David HolmesOpen
Q-002Should the package export framework-specific wrappers (e.g. createAstroWebhookRoute) or remain purely framework-agnostic?David HolmesOpen
Q-003Should the handler factory support multiple signature secrets for key rotation?David HolmesOpen

Acceptance Criteria

IDCriteriaRelated Requirement
AC-001verifyWebhookSignature() correctly verifies HMAC-SHA256, Stripe v1, and Svix signaturesFR-001
AC-002verifyWebhookSignature() uses crypto.timingSafeEqual internallyFR-006, NFR-001
AC-003ReplayGuard rejects events older than the configured maxAgeMsFR-002, FUNC-003
AC-004ReplayGuard rejects events with duplicate IDs within the windowFUNC-004
AC-005createWebhookHandler() returns status 401 for invalid signaturesFUNC-005
AC-006createWebhookHandler() returns status 200 and calls the dispatcher for valid eventsFUNC-006
AC-007createTestWebhookPayload() generates payloads that pass verifyWebhookSignature()FR-005, FUNC-007
AC-008Unit tests cover valid signatures, invalid signatures, replay rejection, and expired eventsFR-001 through FR-006
AC-009No vendor SDK is imported anywhere in the packageNFR-002
AC-010Signing secrets do not appear in any error messagesNFR-006

LLM Handoff Instructions

Expected LLM Behavior

  • Place all files under src/lib/webhooks/.
  • Implement signature verification using Node.js crypto module only — no vendor SDK imports.
  • Use crypto.timingSafeEqual for all signature comparisons.
  • ReplayGuard should use a Map<string, number> with periodic cleanup of expired entries.
  • The handler factory should return WebhookHandlerResult objects, not HTTP Response instances.
  • createTestWebhookPayload must produce payloads that round-trip through verifyWebhookSignature.
  • Add unit tests in webhook-handling.test.ts covering all verification schemes, replay scenarios, and handler composition.
  • Reference ADR-065 in JSDoc comments.

LLM Should Not

  • Import any vendor SDK (Stripe, Svix, etc.).
  • Build framework-specific route handlers.
  • Implement the job queue — only define the dispatcher callback type.
  • Add runtime dependencies beyond Node.js built-ins.
  • Store secrets in code or test fixtures (use well-known test values like "whsec_test").

Decision Log

DateDecisionReasonOwner
2026-05-26Framework-agnostic handler returning result objects, not HTTP ResponsesWorks with Astro, Hono, Express, and edge runtimes without framework couplingDavid Holmes
2026-05-26In-memory replay guard as the default, with pluggable store as a future extensionKeeps initial implementation simple; most apps can tolerate brief replay windows on restartDavid Holmes
2026-05-26Support 3 signature schemes (HMAC-SHA256, Stripe v1, Svix) at launchCovers the most common vendors; additional schemes are easy to addDavid Holmes

Document History

DateAuthorChange
2026-05-26David HolmesInitial draft