# Capability Spec Delta: api-plugin-webhook (add-api-plugin-webhook) **Package**: `@suaveplan/api-plugin-webhook` **Status**: ACCEPTED > Every requirement below carries a `**Verified by:**` line at authoring time. ## ADDED Requirements ### Requirement: createWebhookPlugin returns a Zod-validated PluginDescriptor The package SHALL export `createWebhookPlugin(options): PluginDescriptor` that validates `options` against `WebhookPluginOptionsSchema` (Zod) at the factory boundary. Invalid input SHALL raise `WebhookError(WEBHOOK_INVALID_INPUT)` carrying the `ZodError` as `cause`. The returned descriptor SHALL declare `name: "webhook"`, the resolved `defaults`, and `onInit` / `onClose` lifecycle hooks. #### Scenario: factory produces a valid descriptor - **WHEN** `createWebhookPlugin({})` is called with no overrides - **THEN** the returned descriptor satisfies `PluginDescriptor` with `name === "webhook"`, `version === "0.1.0"`, and an `onInit` hook #### Scenario: invalid options raise WEBHOOK_INVALID_INPUT - **WHEN** `createWebhookPlugin({ maxAttempts: -1 } as never)` is called - **THEN** `WebhookError(WEBHOOK_INVALID_INPUT)` is thrown with `cause instanceof ZodError` **Verified by:** `src/plugin/plugin.test.ts::"createWebhookPlugin returns valid PluginDescriptor"` AND `src/plugin/plugin.test.ts::"invalid options raise WEBHOOK_INVALID_INPUT"` --- ### Requirement: onInit registers webhookService in ServiceRegistry The descriptor's `onInit(ctx)` hook SHALL resolve `app` from `ctx`'s `ServiceRegistry`. If `app` is absent it SHALL throw `WebhookError(WEBHOOK_SERVICE_NOT_AVAILABLE)` listing `["app"]` in `context.missing`. When `app` is present, `onInit` SHALL construct the `WebhookService` (wiring the configured or default `EndpointStore`, `DeadLetterSink`, `idempotencyGuard`, `fetch`, `clock`, `logger`), register it under the `webhookService` slot via `ctx.registerService("webhookService", service)`, and mount the CRUD + test routes via `definePluginRoutes` (or directly on the Hono app if `openAPIRegistry` is absent). #### Scenario: webhookService registered after onInit completes - **WHEN** the plugin's `onInit` is invoked with a `ServiceRegistry` containing `app` - **THEN** `ctx.getService("webhookService")` returns a `WebhookService` instance #### Scenario: missing app raises WEBHOOK_SERVICE_NOT_AVAILABLE - **WHEN** the plugin's `onInit` is invoked without `app` in the `ServiceRegistry` - **THEN** `WebhookError(WEBHOOK_SERVICE_NOT_AVAILABLE)` is thrown with `context.missing` containing `"app"` **Verified by:** `src/plugin/plugin.test.ts::"onInit registers webhookService"` AND `src/plugin/plugin.test.ts::"onInit raises when app service is missing"` --- ### Requirement: onInit mounts CRUD + test routes under routePrefix `onInit` SHALL mount six routes under `options.routePrefix` (default `/webhooks`) on the resolved Hono app, gated by `requireAuth()`: `GET /webhooks`, `POST /webhooks`, `GET /webhooks/:id`, `PATCH /webhooks/:id`, `DELETE /webhooks/:id`, `POST /webhooks/:id/test`. When `openAPIRegistry` is registered, the routes SHALL also be registered there via `definePluginRoutes` so the unified `/openapi.json` exposes them with Zod-typed request/response shapes. If another plugin already owns routes under `routePrefix`, `onInit` SHALL throw `WebhookError(WEBHOOK_ROUTE_PREFIX_COLLISION)`. #### Scenario: routes mounted on Hono app - **WHEN** the plugin's `onInit` completes - **THEN** the Hono app responds (with a 401 from `requireAuth`) to `POST /webhooks` rather than 404 #### Scenario: routePrefix collision raises WEBHOOK_ROUTE_PREFIX_COLLISION - **WHEN** the `OpenAPIRegistry` already contains a route at the configured prefix and `onInit` runs - **THEN** `WebhookError(WEBHOOK_ROUTE_PREFIX_COLLISION)` is thrown with `context.prefix` set to the offending value **Verified by:** `src/plugin/plugin.test.ts::"onInit mounts CRUD routes"` AND `src/plugin/plugin.test.ts::"routePrefix collision raises WEBHOOK_ROUTE_PREFIX_COLLISION"` --- ### Requirement: WebhookService.send delivers a signed payload via webhook-client `WebhookService.send(endpointId, event, data)` SHALL look up the endpoint via `EndpointStore.get`, raise `WebhookError(WEBHOOK_ENDPOINT_NOT_FOUND)` if absent, raise `WebhookError(WEBHOOK_INACTIVE_ENDPOINT)` if `endpoint.active === false`, and otherwise build a `WebhookPayload` (`{ id, event, data, timestamp }`) and deliver it via `@suaveplan/webhook-client`'s `deliver()` using the endpoint's `secret`, `algorithm`, merged `headers`, and the configured `fetch`. The outbound request SHALL carry `Content-Type: application/json`, `X-Webhook-Id`, `X-Webhook-Event`, `X-Webhook-Timestamp`, and the HMAC signature header (`X-Hub-Signature-256` or `-512`). The successful `DeliveryResult` SHALL be returned to the caller. #### Scenario: successful single-attempt delivery - **WHEN** an endpoint with secret `"shared"` is registered and the receiver returns 200 OK - **THEN** `send()` resolves with `result.status === "delivered"`, `result.attempts.length === 1`, AND the receiver observed `X-Webhook-Id`, `X-Webhook-Event`, `X-Webhook-Timestamp`, and a valid `X-Hub-Signature-256: sha256=` header #### Scenario: missing endpoint raises WEBHOOK_ENDPOINT_NOT_FOUND - **WHEN** `send("missing", "evt", {})` is called against an empty store - **THEN** `WebhookError(WEBHOOK_ENDPOINT_NOT_FOUND)` is thrown with `context.endpointId === "missing"` #### Scenario: inactive endpoint raises WEBHOOK_INACTIVE_ENDPOINT - **WHEN** the resolved endpoint has `active === false` - **THEN** `WebhookError(WEBHOOK_INACTIVE_ENDPOINT)` is thrown without contacting the receiver **Verified by:** `tests/integration/delivery.test.ts::"sends a signed payload with canonical headers"` AND `src/services/webhook-service.test.ts::"send raises WEBHOOK_ENDPOINT_NOT_FOUND when endpoint absent"` AND `src/services/webhook-service.test.ts::"send raises WEBHOOK_INACTIVE_ENDPOINT when endpoint inactive"` --- ### Requirement: Retry queue with exponential backoff and jitter When the receiver returns a retryable response (HTTP 429 or 5xx, or a network error / timeout), `WebhookService.send` SHALL retry up to `maxAttempts - 1` additional times with exponential backoff: delay = `initial * 2^(attempt-1)`, capped at `maxRetryDelayMs`, with full jitter applied. 4xx responses other than 429 SHALL terminate immediately (no retry). #### Scenario: receiver returns 500 twice then 200 - **WHEN** the receiver returns HTTP 500 on attempts 1 and 2 and HTTP 200 on attempt 3 - **THEN** `send()` resolves with `result.status === "delivered"`, `result.attempts.length === 3`, AND `result.finalStatusCode === 200` #### Scenario: 4xx terminates without retry - **WHEN** the receiver returns HTTP 422 on attempt 1 - **THEN** `send()` rejects with `WebhookError(WEBHOOK_DELIVERY_EXHAUSTED)` AND only one attempt was made (no retries) **Verified by:** `tests/integration/retry.test.ts::"retries 5xx then succeeds"` AND `tests/integration/retry.test.ts::"4xx terminates without retry"` --- ### Requirement: Dead-letter sink records exhausted deliveries When `WebhookService.send` exhausts all `maxAttempts` retries, the service SHALL record a `DeadLetterEntry` (`{ id, endpointId, tenantId, event, payload, attempts, firstAttemptAt, finalAttemptAt, reason }`) via the configured `DeadLetterSink.record()` and SHALL raise `WebhookError(WEBHOOK_DELIVERY_EXHAUSTED)` to the caller. If the sink itself throws, the service SHALL raise `WebhookError(WEBHOOK_DEAD_LETTER_WRITE_FAILED)` with the sink error as `cause` while preserving the original delivery failure on the chain. #### Scenario: 5x 500 lands in the dead-letter sink - **WHEN** the receiver returns HTTP 500 on all five attempts (`maxAttempts: 5`) - **THEN** `send()` rejects with `WebhookError(WEBHOOK_DELIVERY_EXHAUSTED)` AND the configured `DeadLetterSink.list({ endpointId })` contains a single entry with `attempts.length === 5` #### Scenario: sink write failure raises WEBHOOK_DEAD_LETTER_WRITE_FAILED - **WHEN** the configured sink's `record()` rejects after a delivery is exhausted - **THEN** `WebhookError(WEBHOOK_DEAD_LETTER_WRITE_FAILED)` is thrown with `cause` set to the sink error **Verified by:** `tests/integration/dead-letter.test.ts::"5x 500 lands in dead letter"` AND `src/services/webhook-service.test.ts::"sink write failure raises WEBHOOK_DEAD_LETTER_WRITE_FAILED"` --- ### Requirement: Per-delivery in-flight dedup `WebhookService.send` SHALL dedup concurrent calls that share the same delivery key (`${endpointId}:${payload.id}`). When an `idempotencyGuard` is supplied, the call SHALL be routed through `guard.execute(key, () => delivery)` so cached results are returned for repeat calls within the dedup window. When no guard is supplied, the service SHALL maintain an in-process `Map>` so concurrent calls with the same key resolve to the same delivery and the receiver only sees one request. #### Scenario: concurrent sends with the same id resolve to one delivery - **WHEN** two concurrent `send()` calls supply the same explicit payload id and the receiver counts requests - **THEN** both promises resolve with `status === "delivered"` AND the receiver observed exactly one POST **Verified by:** `src/services/webhook-service.test.ts::"in-flight dedup collapses concurrent sends with same id"` --- ### Requirement: Subscription CRUD routes are tenant-scoped and auth-gated The six CRUD routes mounted under `routePrefix` SHALL be gated by `requireAuth()` so anonymous requests receive HTTP 401. When the request principal carries a `tenantId`, the routes SHALL only see endpoints whose `tenantId` matches the principal's; cross-tenant reads/writes SHALL be rejected with `WebhookError(WEBHOOK_TENANT_MISMATCH)`. When the principal carries no `tenantId` (admin / global scope), the routes SHALL operate on every endpoint regardless of tenant. #### Scenario: anonymous request hits 401 - **WHEN** an unauthenticated request hits `GET /webhooks` - **THEN** the response status is 401 AND the body carries `code === "WEBHOOK_UNAUTHENTICATED" | "AUTH_UNAUTHENTICATED"` (whichever auth gate is wired) #### Scenario: cross-tenant read returns 404 - **WHEN** an authenticated request from tenant `"t-a"` hits `GET /webhooks/:id` for an endpoint owned by tenant `"t-b"` - **THEN** the response status is 404 (the endpoint is hidden from the calling tenant) **Verified by:** `src/routes/build-routes.test.ts::"anonymous request returns 401"` AND `src/routes/build-routes.test.ts::"cross-tenant read returns 404"` --- ### Requirement: POST /webhooks/:id/test fires a synthetic ping `POST /webhooks/:id/test` SHALL look up the endpoint, raise `WEBHOOK_ENDPOINT_NOT_FOUND` if absent or tenant-mismatched, and otherwise call `service.test(endpointId)` which delivers a synthetic event `webhook.ping` with payload `{ requestedAt: ISO8601 }`. The response body SHALL be the `DeliveryResult` (status, finalStatusCode, attempts). #### Scenario: ping is delivered and reported - **WHEN** `POST /webhooks/:id/test` is called against a healthy endpoint backed by a 200-OK receiver - **THEN** the response body has `status === "delivered"`, `finalStatusCode === 200`, AND the receiver observed `event === "webhook.ping"` **Verified by:** `tests/integration/full-boot.test.ts::"POST /webhooks/:id/test fires a synthetic webhook.ping"` --- ### Requirement: OTel instruments emitted from every delivery path The package SHALL register four OTel instruments via `getWebhookInstruments()`: counters `webhook_plugin.deliveries_total{event,status,endpoint_id}` and `webhook_plugin.dead_letters_total{event,endpoint_id}`; histograms `webhook_plugin.delivery_attempts{event,status}` and `webhook_plugin.send_duration_ms{status}`. Every `send()` call SHALL emit both counters / histograms regardless of outcome, with the `status` attribute taking one of `delivered | exhausted | not_found | inactive | invalid`. #### Scenario: successful delivery records the counter - **WHEN** a single successful delivery completes against a real in-memory `MeterProvider` - **THEN** the collected metrics contain `webhook_plugin.deliveries_total` with `status: "delivered"` value `>= 1` AND `webhook_plugin.send_duration_ms` recorded at least one observation #### Scenario: exhausted delivery records the dead-letter counter - **WHEN** a delivery exhausts all retries - **THEN** the collected metrics contain `webhook_plugin.dead_letters_total` with value `>= 1` **Verified by:** `src/otel/instruments.test.ts::"deliveries_total increments on success"` AND `src/otel/instruments.test.ts::"dead_letters_total increments on exhaustion"` --- ### Requirement: Every WEBHOOK_* error code is reachable from a production path The package SHALL define exactly ten error codes via `defineCodes("WEBHOOK", [...])`: `INVALID_INPUT`, `ENDPOINT_NOT_FOUND`, `TENANT_MISMATCH`, `EVENT_NOT_SUBSCRIBED`, `DELIVERY_EXHAUSTED`, `DEAD_LETTER_WRITE_FAILED`, `STORE_ERROR`, `SERVICE_NOT_AVAILABLE`, `ROUTE_PREFIX_COLLISION`, `INACTIVE_ENDPOINT`. Every code SHALL be thrown from at least one production code path in `src/` (asserted by the spec-conformance test). #### Scenario: spec-conformance proves every code is thrown - **WHEN** the `api-plugin-webhook.spec-conformance.test.ts` suite runs - **THEN** the observed set of thrown `WEBHOOK_*` codes equals the declared set (10 of 10) **Verified by:** `src/api-plugin-webhook.spec-conformance.test.ts::"every WEBHOOK_* code is thrown from a production path"` --- ### Requirement: Subpath exports are tree-shakeable The package SHALL declare 14 subpath exports in `package.json` (`.`, `./plugin/plugin`, `./services/webhook-service`, `./store/endpoint-store`, `./store/memory`, `./dead-letter/dead-letter`, `./dead-letter/memory`, `./routes/routes`, `./schemas/options`, `./schemas/endpoint`, `./schemas/routes`, `./error-codes/error-codes`, `./otel/instruments`, `./types/types`). The package SHALL declare `"sideEffects": false`. #### Scenario: every subpath resolves to a built file - **WHEN** the dep-graph test enumerates `package.json` exports - **THEN** every subpath maps to a `dist/.js` entry and a sibling `.d.ts` **Verified by:** `tests/dep-graph.test.ts::"declares 14 tree-shakeable subpath exports"`