9.5 KiB
Capability Spec Delta: webhook (webhook-receiver)
Package: @suaveplan/webhook-client
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. (Seeopenspec/AGENTS.mdSection 13.)
ADDED Requirements
Requirement: WebhookParser parses JSON and form-urlencoded bodies
The package SHALL expose a WebhookParser class with parseJsonBody(request), parseFormUrlencodedBody(request), and parseRequest(request) methods. parseRequest SHALL auto-detect the parser based on the request's Content-Type header. Each method SHALL return a typed ParsedWebhookEvent<T> carrying { id, type, payload, receivedAt, signature?, headers }.
Scenario: JSON body parsed
- WHEN
parseRequestis called with aRequestwhoseContent-Typeisapplication/jsonand body is{"id":"evt_1","type":"customer.created","data":{}} - THEN the result is a
ParsedWebhookEventwithid === "evt_1",type === "customer.created"
Scenario: form-urlencoded body parsed
- WHEN
parseRequestis called with aRequestwhoseContent-Typeisapplication/x-www-form-urlencodedand body isid=evt_2&type=ping - THEN the result is a
ParsedWebhookEventwithid === "evt_2",type === "ping"
Scenario: unsupported content-type rejected
- WHEN
parseRequestis called withContent-Type: application/xml - THEN it throws
WebhookClientErrorwith codeWEBHOOK_CLIENT.PARSER_UNSUPPORTED_CONTENT_TYPEandmetadata.retryHint === "permanent"
Scenario: malformed JSON rejected
- WHEN
parseJsonBodyis called with bodynot json - THEN it throws
WebhookClientErrorwith codeWEBHOOK_CLIENT.PARSER_INVALID_BODYandmetadata.retryHint === "permanent"
Verified by: packages/platform/webhook-client/src/parser/parser/parser.test.ts::"parses JSON body" AND packages/platform/webhook-client/src/parser/parser/parser.test.ts::"parses form-urlencoded body" AND packages/platform/webhook-client/src/parser/parser/parser.test.ts::"rejects unsupported content-type as PARSER_UNSUPPORTED_CONTENT_TYPE" AND packages/platform/webhook-client/src/parser/parser/parser.test.ts::"rejects malformed JSON as PARSER_INVALID_BODY"
Requirement: WebhookVerifier supports vendor signing schemes
The package SHALL expose a WebhookVerifier class with a signingScheme parameter accepting 'stripe' | 'github' | 'slack' | 'generic'. Each scheme SHALL match its vendor's signing convention: Stripe (HMAC-SHA256 of <timestamp>.<body>), GitHub (HMAC-SHA256 of body, header prefix sha256=), Slack (HMAC-SHA256 of v0:<timestamp>:<body>), generic (raw HMAC-SHA256 of body).
Scenario: Stripe scheme verifies signed body
- WHEN a body and Stripe-format signature header
t=<ts>,v1=<sig>are presented andsigningScheme: 'stripe' - THEN
verify(request, secret)resolves successfully
Scenario: GitHub scheme honours sha256= prefix
- WHEN the header is
X-Hub-Signature-256: sha256=<sig>andsigningScheme: 'github' - THEN the verifier strips the prefix, computes HMAC-SHA256 of the body, and accepts the match
Scenario: tampered body rejected
- WHEN the body is mutated after signing
- THEN the verifier throws
WebhookClientErrorwith codeWEBHOOK_CLIENT.VERIFIER_BAD_SIGNATUREandmetadata.retryHint === "permanent"
Scenario: timestamp older than tolerance rejected
- WHEN Stripe's
t=value is older thantimestampToleranceMs(default 5 min) relative to the injectedClock - THEN the verifier throws
WebhookClientErrorwith codeWEBHOOK_CLIENT.VERIFIER_TIMESTAMP_OUT_OF_TOLERANCEandmetadata.retryHint === "permanent"
Verified by: packages/platform/webhook-client/src/verifier/verifier/verifier.test.ts::"verifies Stripe-signed body" AND packages/platform/webhook-client/src/verifier/verifier/verifier.test.ts::"strips github sha256= prefix" AND packages/platform/webhook-client/src/verifier/verifier/verifier.test.ts::"rejects tampered body as VERIFIER_BAD_SIGNATURE" AND packages/platform/webhook-client/src/verifier/verifier/verifier.test.ts::"rejects expired timestamp as VERIFIER_TIMESTAMP_OUT_OF_TOLERANCE"
Requirement: WebhookReceiver orchestrates verify → parse → dispatch
The package SHALL expose a WebhookReceiver class with registerHandler<T>(eventType, handler), unregisterHandler(eventType), and handle(request) methods. The handle orchestrator SHALL run the pipeline verify → parse → dispatch and return a HandleResult carrying { status, parsedEvent, dispatchResult }. webhookHooks events SHALL fire at every stage in the documented order: onWebhookReceived → onWebhookVerified → onWebhookHandled (or onWebhookFailed).
Scenario: full pipeline dispatches to registered handler
- WHEN a handler is registered for
customer.createdandhandle(request)is called with a valid Stripe request - THEN the handler is invoked exactly once with the parsed event AND the result is
{ status: "handled", parsedEvent, dispatchResult }
Scenario: handler-not-found surfaces as RECEIVER_HANDLER_NOT_FOUND
- WHEN no handler is registered for the event type
- THEN
handlethrowsWebhookClientErrorwith codeWEBHOOK_CLIENT.RECEIVER_HANDLER_NOT_FOUNDandmetadata.retryHint === "permanent"
Scenario: handler-throws surfaces as RECEIVER_HANDLER_FAILED
- WHEN the registered handler itself throws
- THEN
handlethrowsWebhookClientErrorwith codeWEBHOOK_CLIENT.RECEIVER_HANDLER_FAILED,metadata.retryHint === "transient", ANDcausecarries the original error
Scenario: hooks fire in order
- WHEN
handleruns successfully end-to-end - THEN the recorded fire order is
["onWebhookReceived", "onWebhookVerified", "onWebhookHandled"]
Scenario: failed pipeline emits onWebhookFailed instead of onWebhookHandled
- WHEN verification fails
- THEN the recorded fire order is
["onWebhookReceived", "onWebhookFailed"](noonWebhookVerified, noonWebhookHandled)
Verified by: packages/platform/webhook-client/src/receiver/receiver/receiver.test.ts::"dispatches to registered handler" AND packages/platform/webhook-client/src/receiver/receiver/receiver.test.ts::"throws RECEIVER_HANDLER_NOT_FOUND when no handler registered" AND packages/platform/webhook-client/src/receiver/receiver/receiver.test.ts::"throws RECEIVER_HANDLER_FAILED when handler throws" AND packages/platform/webhook-client/src/receiver/receiver/receiver.spec-conformance.test.ts::"hooks fire in success order" AND packages/platform/webhook-client/src/receiver/receiver/receiver.spec-conformance.test.ts::"emits onWebhookFailed when verification fails"
Requirement: webhookHooks typed event bus
The package SHALL expose a webhookHooks event bus typed by WebhookHookMap. Five lifecycle events SHALL be supported: onWebhookDelivering, onWebhookReceived, onWebhookVerified, onWebhookHandled, onWebhookFailed. The bus SHALL be backed by @suaveplan/events. Listener registration SHALL return an unsubscribe function.
Scenario: typed listener receives typed payload
- WHEN
onWebhookReceived(handler)is registered and a handler signature claims{ event: ParsedWebhookEvent<{ id: string }> } - THEN TypeScript infers the payload type without
any(verified viaexpectTypeOf)
Scenario: unsubscribe stops further deliveries
- WHEN a listener is registered, then its returned unsubscribe is called
- THEN subsequent
webhookHooks.emit("onWebhookReceived", ...)calls do not invoke the listener
Scenario: onWebhookDelivering fires from outbound delivery path
- WHEN the existing
WebhookClient.deliver(outbound) is invoked - THEN
onWebhookDeliveringis emitted with the outboundWebhookPayload(covers the legacy delivery path through the same hook bus)
Verified by: packages/platform/webhook-client/src/hooks/hooks/hooks.test.ts::"typed listener receives typed payload" AND packages/platform/webhook-client/src/hooks/hooks/hooks.test.ts::"unsubscribe stops deliveries" AND packages/platform/webhook-client/src/hooks/hooks/hooks.test.ts::"onWebhookDelivering fires from outbound deliver"
Requirement: Error codes carry retry-hint metadata
The package SHALL register six new error codes via defineCodes: PARSER_INVALID_BODY, PARSER_UNSUPPORTED_CONTENT_TYPE, VERIFIER_BAD_SIGNATURE, VERIFIER_TIMESTAMP_OUT_OF_TOLERANCE, RECEIVER_HANDLER_NOT_FOUND, RECEIVER_HANDLER_FAILED. Each SHALL carry metadata.retryHint: 'transient' | 'permanent' so callers can branch without parsing message strings.
Scenario: every new code declares retryHint
- WHEN
WEBHOOK_CLIENT_CODESis loaded - THEN every new code's metadata object satisfies
Object.hasOwn(meta, "retryHint")AND the value is"transient"or"permanent"
Scenario: handler-failure flagged transient
- WHEN
RECEIVER_HANDLER_FAILEDis emitted - THEN
error.metadata.retryHint === "transient"
Scenario: bad-signature flagged permanent
- WHEN
VERIFIER_BAD_SIGNATUREis emitted - THEN
error.metadata.retryHint === "permanent"
Verified by: packages/platform/webhook-client/src/errors/error-codes.spec-conformance.test.ts::"every new code declares retryHint" AND packages/platform/webhook-client/src/errors/error-codes.spec-conformance.test.ts::"handler-failure flagged transient" AND packages/platform/webhook-client/src/errors/error-codes.spec-conformance.test.ts::"bad-signature flagged permanent"