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

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. (See openspec/AGENTS.md Section 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 parseRequest is called with a Request whose Content-Type is application/json and body is {"id":"evt_1","type":"customer.created","data":{}}
  • THEN the result is a ParsedWebhookEvent with id === "evt_1", type === "customer.created"

Scenario: form-urlencoded body parsed

  • WHEN parseRequest is called with a Request whose Content-Type is application/x-www-form-urlencoded and body is id=evt_2&type=ping
  • THEN the result is a ParsedWebhookEvent with id === "evt_2", type === "ping"

Scenario: unsupported content-type rejected

  • WHEN parseRequest is called with Content-Type: application/xml
  • THEN it throws WebhookClientError with code WEBHOOK_CLIENT.PARSER_UNSUPPORTED_CONTENT_TYPE and metadata.retryHint === "permanent"

Scenario: malformed JSON rejected

  • WHEN parseJsonBody is called with body not json
  • THEN it throws WebhookClientError with code WEBHOOK_CLIENT.PARSER_INVALID_BODY and metadata.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 and signingScheme: 'stripe'
  • THEN verify(request, secret) resolves successfully

Scenario: GitHub scheme honours sha256= prefix

  • WHEN the header is X-Hub-Signature-256: sha256=<sig> and signingScheme: '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 WebhookClientError with code WEBHOOK_CLIENT.VERIFIER_BAD_SIGNATURE and metadata.retryHint === "permanent"

Scenario: timestamp older than tolerance rejected

  • WHEN Stripe's t= value is older than timestampToleranceMs (default 5 min) relative to the injected Clock
  • THEN the verifier throws WebhookClientError with code WEBHOOK_CLIENT.VERIFIER_TIMESTAMP_OUT_OF_TOLERANCE and metadata.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.created and handle(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 handle throws WebhookClientError with code WEBHOOK_CLIENT.RECEIVER_HANDLER_NOT_FOUND and metadata.retryHint === "permanent"

Scenario: handler-throws surfaces as RECEIVER_HANDLER_FAILED

  • WHEN the registered handler itself throws
  • THEN handle throws WebhookClientError with code WEBHOOK_CLIENT.RECEIVER_HANDLER_FAILED, metadata.retryHint === "transient", AND cause carries the original error

Scenario: hooks fire in order

  • WHEN handle runs 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"] (no onWebhookVerified, no onWebhookHandled)

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 via expectTypeOf)

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 onWebhookDelivering is emitted with the outbound WebhookPayload (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_CODES is 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_FAILED is emitted
  • THEN error.metadata.retryHint === "transient"

Scenario: bad-signature flagged permanent

  • WHEN VERIFIER_BAD_SIGNATURE is 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"