# 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` 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 `.`), GitHub (HMAC-SHA256 of body, header prefix `sha256=`), Slack (HMAC-SHA256 of `v0::`), generic (raw HMAC-SHA256 of body). #### Scenario: Stripe scheme verifies signed body - **WHEN** a body and Stripe-format signature header `t=,v1=` 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=` 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(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"`