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:
WebhookParserclass —parseJsonBody(request),parseFormUrlencodedBody(request),parseRequest(request)(auto-detects content-type); returns a typedParsedWebhookEvent<T>. Validates input through Zod schemas; throwsWebhookClientErrorwith codePARSER_INVALID_BODY/PARSER_UNSUPPORTED_CONTENT_TYPEon failure. - ADDED:
WebhookVerifierclass — wraps the existingverifySignaturehelper with a class abstraction. PluggablesigningSchemeparameter selects vendor-specific schemes:stripe(HMAC-SHA256 of<timestamp>.<body>),github(HMAC-SHA256 of body, prefixsha256=),slack(HMAC-SHA256 ofv0:<timestamp>:<body>),generic(default — raw HMAC-SHA256 of body). ThrowsWebhookClientErrorwith codesVERIFIER_BAD_SIGNATURE/VERIFIER_TIMESTAMP_OUT_OF_TOLERANCE. - ADDED:
WebhookReceiverclass — orchestrator: verify → parse → dispatch via typed handler registry (registerHandler<T>(eventType, handler)); emitswebhookHooksevents at every stage; structured errors per failure mode.handle(request)returnsPromise<HandleResult>carrying{ status, parsedEvent, dispatchResult }. - ADDED:
webhookHookstyped event bus — five lifecycle events:onWebhookDelivering(handler),onWebhookReceived(handler),onWebhookVerified(handler),onWebhookHandled(handler),onWebhookFailed(handler). Backed by@suaveplan/events. - ADDED:
WEBHOOK_CLIENT_CODESextensions —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 carrymetadata.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 forParsedWebhookEvent<T>. - ADDED:
WebhookHandler<T>,WebhookHandlerRegistry,WebhookHookMap,SigningScheme,HandleResulttypes. - ADDED: subpath exports
./parser/parser,./verifier/verifier,./receiver/receiver,./hooks/hooks. - MODIFIED: root
src/index.tsre-exports the parser, verifier, receiver, hooks, and the new error codes. - MODIFIED:
WEBHOOK_CLIENT_CODESblock — 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-clientand renaming would force every consumer to updatepackage.jsonand 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/idempotencyif they need replay protection. - Vendor-specific event-type registries (Stripe
customer.created, GitHubpull_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 aRequest(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 rejectionpackages/platform/webhook-client/src/verifier/verifier/verifier.test.ts— every signing scheme verified; bad signature rejected; timestamp tolerance enforcedpackages/platform/webhook-client/src/receiver/receiver/receiver.test.ts— full pipeline (verify → parse → dispatch); handler-not-found path; handler-failure pathpackages/platform/webhook-client/src/receiver/receiver/receiver.spec-conformance.test.ts—webhookHooksevents fire in order; OTel spans emitted; error codes carry retry-hint metadatapackages/platform/webhook-client/src/hooks/hooks/hooks.test.ts—webhookHooksregistration / unregistration / event dispatch orderpackages/platform/webhook-client/src/errors/error-codes.spec-conformance.test.ts— every new code carriesmetadata.retryHint; codes registered viadefineCodes
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"→ onlypackages/platform/webhook-client/package.jsonitself).
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.