Files
suaveplan/openspec/changes/webhook-receiver/proposal.md
2026-05-27 23:29:53 +01:00

8.7 KiB

Webhook receiver — parser, verifier class, receiver, hooks, error taxonomy

Package: @suaveplan/webhook-client Status: PROPOSED Type: feature + spec-conformance

Why

@suaveplan/webhook-client (packages/platform/webhook-client/) currently ships only the outbound delivery surface: WebhookClient, deliver, signing helpers (generateSignature, parseSignatureHeader, verifySignature), and the delivery types. The legacy package shipped both delivery AND receive surfaces, and downstream consumers (admin webhooks endpoint, billing webhook ingest, vendor-event mux) need the inbound side: a parser, a verifier class abstraction, a WebhookReceiver orchestrator, a typed hook bus, and a structured error taxonomy with retry-hint metadata. Without these, every consumer hand-rolls the verify → parse → dispatch pipeline with inconsistent error semantics.

All five legacy modules already exist (packages/universal/webhook-client/src/parser/parser.ts 130 L; verifier/verifier.ts 127 L; receiver/receiver.ts 135 L; hooks.ts 58 L; errors/error-codes.ts 164 L) and pass the genesis quality bar — porting them closes the parity gap before consumers fork.

The package name is kept as @suaveplan/webhook-client (not renamed to @suaveplan/webhook) to avoid a breaking rename. The receiver lives at the subpath @suaveplan/webhook-client/receiver/receiver alongside the existing core/client/webhook-client subpath.

What changes

@suaveplan/webhook-client

  • ADDED: WebhookParser class — parseJsonBody(request), parseFormUrlencodedBody(request), parseRequest(request) (auto-detects content-type); returns a typed ParsedWebhookEvent<T>. Validates input through Zod schemas; throws WebhookClientError with code PARSER_INVALID_BODY / PARSER_UNSUPPORTED_CONTENT_TYPE on failure.
  • ADDED: WebhookVerifier class — wraps the existing verifySignature helper with a class abstraction. Pluggable signingScheme parameter selects vendor-specific schemes: stripe (HMAC-SHA256 of <timestamp>.<body>), github (HMAC-SHA256 of body, prefix sha256=), slack (HMAC-SHA256 of v0:<timestamp>:<body>), generic (default — raw HMAC-SHA256 of body). Throws WebhookClientError with codes VERIFIER_BAD_SIGNATURE / VERIFIER_TIMESTAMP_OUT_OF_TOLERANCE.
  • ADDED: WebhookReceiver class — orchestrator: verify → parse → dispatch via typed handler registry (registerHandler<T>(eventType, handler)); emits webhookHooks events at every stage; structured errors per failure mode. handle(request) returns Promise<HandleResult> carrying { status, parsedEvent, dispatchResult }.
  • ADDED: webhookHooks typed event bus — five lifecycle events: onWebhookDelivering(handler), onWebhookReceived(handler), onWebhookVerified(handler), onWebhookHandled(handler), onWebhookFailed(handler). Backed by @suaveplan/events.
  • ADDED: WEBHOOK_CLIENT_CODES extensions — PARSER_INVALID_BODY, PARSER_UNSUPPORTED_CONTENT_TYPE, VERIFIER_BAD_SIGNATURE, VERIFIER_TIMESTAMP_OUT_OF_TOLERANCE, RECEIVER_HANDLER_NOT_FOUND, RECEIVER_HANDLER_FAILED. Each code SHALL carry metadata.retryHint: 'transient' | 'permanent' so callers can tell "transient — retry" from "bad signature — drop".
  • ADDED: ParsedWebhookEvent<T> interface — { id, type, payload: T, receivedAt, signature?, headers }.
  • ADDED: WebhookSchema<T> Zod-builder helper — WebhookSchema(payloadSchema) returns a Zod object schema for ParsedWebhookEvent<T>.
  • ADDED: WebhookHandler<T>, WebhookHandlerRegistry, WebhookHookMap, SigningScheme, HandleResult types.
  • ADDED: subpath exports ./parser/parser, ./verifier/verifier, ./receiver/receiver, ./hooks/hooks.
  • MODIFIED: root src/index.ts re-exports the parser, verifier, receiver, hooks, and the new error codes.
  • MODIFIED: WEBHOOK_CLIENT_CODES block — extended in place, no breaking renames.

Out of scope

  • Renaming the package to @suaveplan/webhook — out of scope. The package is published at @suaveplan/webhook-client and renaming would force every consumer to update package.json and re-publish. Document the receiver as a sibling subpath; keep the name.
  • Replay-protection idempotency store — out of scope; tracked separately. The receiver records the event ID via OTel attributes but does not persist it. Consumers wire @suaveplan/idempotency if they need replay protection.
  • Vendor-specific event-type registries (Stripe customer.created, GitHub pull_request.opened, etc.) — out of scope. The receiver registry is generic; vendor-specific event-type taxonomies are downstream concerns.
  • Receiver as an HTTP framework adapter (express / hono / fastify wrappers) — out of scope. WebhookReceiver.handle(request) accepts a Request (Web Fetch API); framework adapters are tiny and downstream.

Source mapping

This change derives from ../suaveplan/enterprise/webhook-client.md and the upstream package source.

Requirement Source file Source lines Notes
WebhookParser class + JSON / form-urlencoded parsers packages/universal/webhook-client/src/parser/parser.ts L1-L130 Verbatim port
WebhookVerifier class packages/universal/webhook-client/src/verifier/verifier.ts L1-L127 Verbatim port; reuse existing generateSignature / verifySignature from utils/signature.js
WebhookReceiver class + handler registry packages/universal/webhook-client/src/receiver/receiver.ts L1-L135 Verbatim port
webhookHooks typed event bus packages/universal/webhook-client/src/hooks.ts L1-L58 Verbatim port; back with @suaveplan/events instead of upstream's bespoke Hooks class
Error code catalog with retry-hint metadata packages/universal/webhook-client/src/errors/error-codes.ts L1-L164 Verbatim port
ParsedWebhookEvent<T>, SigningScheme, WebhookHandler<T>, WebhookHookMap, HandleResult types packages/universal/webhook-client/src/types/webhook-client.types.ts L1-L64 Verbatim port; co-locate with existing webhook-client.types.ts

Requirements with no source line

Requirement Justification
metadata.retryHint on every new error code Genesis-wide convention: structured error metadata so callers can branch without parsing message strings. Upstream encodes retry-hint in prose, not metadata.
webhookHooks backed by @suaveplan/events AGENTS.md §0 ("If an existing implementation covers 80%+ of the requirement, port or wrap it — do not start from scratch"). Upstream uses a bespoke Hooks class; genesis already has @suaveplan/events.
WebhookSchema<T> Zod-builder helper AGENTS.md §13 row 2 ("Zod schemas wired at every declared boundary"). Upstream uses bare TS interfaces.

Verification approach

Each requirement carries a **Verified by:** line in the spec delta.

  • packages/platform/webhook-client/src/parser/parser/parser.test.ts — JSON / form-urlencoded round-trip, content-type rejection, malformed-body rejection
  • packages/platform/webhook-client/src/verifier/verifier/verifier.test.ts — every signing scheme verified; bad signature rejected; timestamp tolerance enforced
  • packages/platform/webhook-client/src/receiver/receiver/receiver.test.ts — full pipeline (verify → parse → dispatch); handler-not-found path; handler-failure path
  • packages/platform/webhook-client/src/receiver/receiver/receiver.spec-conformance.test.ts — webhookHooks events fire in order; OTel spans emitted; error codes carry retry-hint metadata
  • packages/platform/webhook-client/src/hooks/hooks/hooks.test.ts — webhookHooks registration / unregistration / event dispatch order
  • packages/platform/webhook-client/src/errors/error-codes.spec-conformance.test.ts — every new code carries metadata.retryHint; codes registered via defineCodes

Impact

Affected dependents

Run bunx turbo ls --filter=...@suaveplan/webhook-client to enumerate.

  • No dependents (verified by grep -rl "@suaveplan/webhook-client" /home/ubuntu/web/genesis/packages/ --include="package.json" → only packages/platform/webhook-client/package.json itself).

Pure additive change. Existing WebhookClient / deliver / signing helpers unchanged.

Migration impact

None — additive only. Existing inbound hand-rolls in downstream consumers (none yet in genesis) can migrate to WebhookReceiver at their own pace. The receiver is opt-in: callers either continue calling verifySignature directly (pre-existing surface) or wire the full WebhookReceiver pipeline.

The package name stays as @suaveplan/webhook-client; the receiver is documented as a sibling capability under the ./receiver/* subpath. A renamed package (@suaveplan/webhook) is out of scope to avoid breaking the publish identity.