# Add @suaveplan/api-plugin-webhook — Signed outbound webhook delivery as a definePlugin **Package**: `@suaveplan/api-plugin-webhook` (NEW, server-tier) **Status**: PROPOSED **Type**: new-package **Position**: server plugin layered on `@suaveplan/api` + `@suaveplan/webhook-client`; depends only on already-shipped foundations (api-plugin, api-openapi, webhook-client, idempotency, types, error) - **Spec delta**: ./specs/api-plugin-webhook/spec.md ## Why Genesis ships `@suaveplan/webhook-client` (HMAC-signed delivery + retry, 0.1.0) and the `@suaveplan/api-plugin` / `@suaveplan/api-openapi` foundation. Every API that needs to fan signed events out to subscriber endpoints (order events, account events, partner notifications) currently has to wire endpoint storage, route validation, retry queue orchestration, dead-letter sink, and signature header conventions by hand. That defeats the point of having a webhook client and an OpenAPI plugin foundation. This package collapses that scaffolding into a single `createWebhookPlugin(options)` factory that: 1. Registers a typed `webhookService` in the host `ServiceRegistry` exposing `send(endpoint, event)` / `register(endpoint)` / `unregister(id)` / `listEndpoints(tenantId?)`. 2. Mounts a small CRUD surface for subscription management (`GET/POST /webhooks`, `GET/PATCH/DELETE /webhooks/:id`, plus `POST /webhooks/:id/test`) gated by `requireAuth()` and tenant-scoped if the request carries a tenant. 3. Drives delivery through `@suaveplan/webhook-client` (HMAC-SHA256 by default, exponential backoff with jitter, 5 attempt cap, dead-letter sink) and uses `@suaveplan/idempotency` for per-delivery dedup so callers can safely retry `send()`. 4. Persists endpoints behind an `EndpointStore` interface with an in-memory default — a SurrealDB-backed sibling plugin lands separately. It is the canonical exemplar of "wrap a `@suaveplan/` library as a `@suaveplan/api-plugin-*`" for downstream plugins that follow the same shape (`api-plugin-rate-limit`, `api-plugin-audit`). ## What changes Net-new package at `0.1.0`. ADDED: ### Plugin factory + descriptor - `createWebhookPlugin(options: WebhookPluginOptions): PluginDescriptor` — Zod-validated factory returning a `PluginDescriptor` from `@suaveplan/api-plugin`. Invalid input raises `WebhookError(WEBHOOK_INVALID_INPUT)` carrying the `ZodError` as `cause`. - `WebhookPluginOptions` (validated via `WebhookPluginOptionsSchema`): `{ routePrefix?: string (default "/webhooks"); endpointStore?: EndpointStore; deadLetterSink?: DeadLetterSink; maxAttempts?: number (default 5); initialRetryDelayMs?: number (default 500); maxRetryDelayMs?: number (default 30_000); idempotencyGuard?: DistributedIdempotencyGuard; logger?: LoggerLike; clock?: Clock; fetch?: FetchLike }`. Every optional dep that isn't supplied defaults to an injectable in-memory implementation owned by the package. - `WebhookPluginConfig` — the resolved (post-default-merge) options shape. ### Endpoint store contract (the persistence seam) - `EndpointStore` interface: `list({ tenantId? }): Promise` / `get(id): Promise` / `create(input: CreateEndpointInput): Promise` / `update(id, patch: UpdateEndpointInput): Promise` / `delete(id): Promise`. - `MemoryEndpointStore` — default in-memory `Map`-backed implementation. Thread-safe under cooperative async (no concurrent JS execution). - `WebhookEndpoint`: `{ id: string; tenantId: string | null; url: string; secret: string; events: readonly string[]; description?: string; headers?: Record; algorithm: "sha256" | "sha512"; active: boolean; createdAt: string; updatedAt: string }`. - `WebhookEndpointSchema` / `CreateEndpointInputSchema` / `UpdateEndpointInputSchema` Zod schemas — used at the route boundary and by the store conformance harness. ### Dead-letter sink - `DeadLetterSink` interface: `record(entry: DeadLetterEntry): Promise` / `list({ endpointId?, limit? }): Promise`. - `MemoryDeadLetterSink` — default in-memory implementation. Records every delivery that exhausts `maxAttempts` retries with the full `DeliveryResult.attempts` history, the originating event, and the `firstAttemptAt` / `finalAttemptAt` timestamps. - `DeadLetterEntry`: `{ id: string; endpointId: string; tenantId: string | null; event: string; payload: WebhookPayload; attempts: readonly DeliveryAttempt[]; firstAttemptAt: string; finalAttemptAt: string; reason: string }`. ### Webhook service (registered in the host ServiceRegistry) - `WebhookService` interface: `send(endpointId, event, data): Promise` / `sendAll(event, data, { tenantId? }): Promise` (fans out to every active endpoint subscribed to `event`) / `register(input): Promise` / `unregister(id): Promise` / `listEndpoints(filter): Promise` / `test(endpointId): Promise` (sends a synthetic `webhook.ping` payload). - ServiceRegistry module augmentation: `declare module "@suaveplan/api-plugin/service-registry" { interface ServiceRegistry { webhookService: WebhookService } }`. ### Routes (the OpenAPI surface) All routes mount at `options.routePrefix` (default `/webhooks`), are gated by `requireAuth()`, and filter by tenant when the request principal carries a `tenantId`: - `GET /webhooks` — list active endpoints for the current tenant (admin sees all). - `POST /webhooks` — create an endpoint. Body: `CreateEndpointInputSchema`. Caller supplies the HMAC secret. - `GET /webhooks/:id` — read one endpoint. Returns 404 on miss or tenant mismatch. - `PATCH /webhooks/:id` — partial update (url, events, headers, active flag, description). Cannot rotate `tenantId`. Secret rotation is opt-in via the body. - `DELETE /webhooks/:id` — hard delete. Idempotent on miss. - `POST /webhooks/:id/test` — fire a synthetic `webhook.ping` event at the endpoint and return the `DeliveryResult`. Honours rate limit + idempotency. Every route is built with `RouteBuilder` (`POST`/`GET`/`PATCH`/`DELETE` from `@suaveplan/api-openapi`) so the unified `/openapi.json` includes Zod-typed request/response shapes for every method. ### Delivery semantics - Signing — HMAC-SHA256 by default via `generateSignature` from `@suaveplan/webhook-client/signature`. Algorithm pinned per endpoint. Header naming follows webhook-client convention (`X-Hub-Signature-256` / `X-Hub-Signature-512`). - Standard outbound headers — `X-Webhook-Id` (delivery id), `X-Webhook-Event` (event name), `X-Webhook-Timestamp` (ISO 8601 UTC), plus signature header above. Caller-supplied `endpoint.headers` are merged with these (caller wins on `Content-Type` only, never on the four canonical headers). - Retry — exponential backoff with full jitter, starting at `initialRetryDelayMs`, doubling each attempt, capped at `maxRetryDelayMs`, exhausted after `maxAttempts` (default 5). Receivers returning 4xx (except 429) terminate immediately; 429 and 5xx retry; network errors and timeouts retry. - Dead-letter — once `maxAttempts` is exhausted, the package records a `DeadLetterEntry` via the configured sink and surfaces a `WebhookError(WEBHOOK_DELIVERY_EXHAUSTED)` to the caller of `send()`. The entry preserves the full attempt history so operators can replay manually. - Idempotency — every `send()` call computes a delivery key from `(endpointId, payload.id)`. When an `idempotencyGuard` is supplied, the call routes through `guard.execute(key, () => delivery)` so duplicate sends within the dedup window return the cached result. Without a guard, deliveries are dedup'd per process via an in-flight `Map`. ### Errors + observability - `WebhookError extends SuaveplanError` via `defineCodes("WEBHOOK", [...])`. Codes: `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`. - OTel: counters `webhook_plugin.deliveries_total{event,status,endpoint_id}`, `webhook_plugin.dead_letters_total{event,endpoint_id}`; histogram `webhook_plugin.delivery_attempts{event,status}`; histogram `webhook_plugin.send_duration_ms{status}`. Registered via the package's own `getWebhookInstruments()` factory pattern (mirrors `api-plugin-auth`). ### Subpath exports `.`, `./plugin/plugin`, `./services/webhook-service`, `./store/endpoint-store`, `./store/memory-store`, `./dead-letter/dead-letter`, `./dead-letter/memory-sink`, `./routes/routes`, `./schemas/options`, `./schemas/endpoint`, `./schemas/routes`, `./error-codes/error-codes`, `./otel/instruments`, `./types/types`. ## Out of scope - **SurrealDB-backed endpoint store.** Lands as a sibling `@suaveplan/api-plugin-webhook-surreal` plugin — keeps this package zero-IO so it can land in any storage tier. - **Inbound webhook receivers.** This package is delivery-only; consuming webhooks from third parties belongs to a future `@suaveplan/api-plugin-webhook-receiver`. - **Replay UI / admin dashboard.** Dead-letter entries are exposed via the `DeadLetterSink` interface; surfacing them in an admin app is per-application. - **Cron-based redelivery from the dead-letter sink.** The sink records entries; redelivery orchestration lives in the operator's scheduled jobs. - **Multi-tenant tenant-id auto-injection.** Tenants are owned by `@suaveplan/api-tenant`; this plugin reads `c.get("user")?.tenantId` if the auth plugin is wired upstream and falls back to global scope otherwise. - **Filter / transform DSL.** Endpoints subscribe to literal event names today. A glob / topic DSL is deferred until two consumers need it. ## Source mapping This package is **genesis-original** — there is no upstream `packages/server/api-plugin-webhook/` in `../suaveplan/` to port (only the enterprise audit doc `../suaveplan/enterprise/api-plugin-webhook.md`, which describes intent but not implementation). Design intent is drawn from: - `../suaveplan/enterprise/api-plugin-webhook.md` — the enterprise audit (118 lines) that scopes purpose, required improvements, and acceptance criteria. - Genesis `@suaveplan/api-plugin` `PluginContext` + `ServiceRegistry` shape (commit 0f338653). - Genesis `@suaveplan/api-openapi` `definePluginRoutes` + `RouteBuilder` (commit 99a213ed). - Genesis `@suaveplan/webhook-client` (HMAC signing, retry, attempt tracking). - Genesis `@suaveplan/api-plugin-auth` (canonical `definePlugin` consumer shape — service registration, route mounting, ServiceRegistry augmentation, OTel pattern, error-code shape). - Genesis `@suaveplan/idempotency` `DistributedIdempotencyGuard`. | Requirement | Source | Notes | |---|---|---| | Plugin wiring via `definePlugin` | enterprise/api-plugin-webhook.md L37 + `@suaveplan/api-plugin` | "Plugin wiring — built with definePlugin() from @suaveplan/api, full onInit/onReady/onClose lifecycle" | | Zod request/response schemas at route boundary | enterprise/api-plugin-webhook.md L39 | "Zod request/response schemas via @suaveplan/api-schemas — published OpenAPI 3.1 + JSON Schema" | | Auth gating + tenant isolation | enterprise/api-plugin-webhook.md L40 | "Authentication & authorization — JWT, RBAC, tenant isolation enforced in every query" — adapted to genesis `requireAuth()` | | Idempotency on mutations | enterprise/api-plugin-webhook.md L42 | "Idempotency — `Idempotency-Key` header, @suaveplan/idempotency guard on all mutations" | | Observability — counters / histograms / spans | enterprise/api-plugin-webhook.md L44 | "OpenTelemetry spans, @suaveplan/logger structured logs with trace correlation, Prometheus metrics" | | Retries + dead-letter sink on external calls | enterprise/api-plugin-webhook.md L45 | "Retries & circuit breakers on external calls via @suaveplan/retry" — extended with dead-letter sink | | HMAC-signed delivery | enterprise/api-plugin-webhook.md L47 | "Webhooks — signed via @suaveplan/webhook-client, delivery tracked via @suaveplan/delivery-tracking" | | No mocks — real services in tests | enterprise/api-plugin-webhook.md L48 + L55 | "Integration tests against real services (no mocks)" — genesis enforces via real Hono receiver | | 100% coverage / docs / no banned patterns | enterprise/api-plugin-webhook.md L52-L65 | "Cross-cutting (every package)" block | | File-size + biome + no `any` budget | enterprise/api-plugin-webhook.md L54 + L60-L61 | inherited from CLAUDE.md | ### Requirements with no source line | Requirement | Justification | |---|---| | Pluggable `EndpointStore` interface | Decouples persistence so SurrealDB / Redis / Postgres siblings can plug in without forking. The audit calls for SurrealDB integration but explicitly invites pluggable stores via L37 "Plugin / extension interface (where it makes sense) so downstream packages can contribute behaviour without forking" (L71). | | Pluggable `DeadLetterSink` interface | Same rationale — operators may wire dead letters into Mailpit / Slack / S3. | | Per-delivery in-flight dedup without an external guard | Default safety net so callers don't have to wire `@suaveplan/idempotency` to get re-entrancy safety. Audit L42 calls for the guard on mutations; this extends the safety net to single-process delivery. | ## Verification approach Every requirement in `specs/api-plugin-webhook/spec.md` carries a `**Verified by:**` line pointing at: - `src//.test.ts` — unit scenarios (no IO, injectable defaults). - `src//.spec-conformance.test.ts` — type-level + observability + lifecycle scenarios. - `tests/integration/delivery.test.ts` — real listening Hono receiver on a random port; verifies signed payload, headers, retry-then-success, dead-letter after 5x 500. - `tests/integration/full-boot.test.ts` — real `PluginRegistry` + real Hono app; full register → send → receive → assert round-trip. - `tests/dep-graph.test.ts` — pins dependency invariants (14 subpaths, sibling `.md` per impl file). ## Migration impact None — additive, net-new package. No existing consumer in the monorepo (`bunx turbo ls --filter=...@suaveplan/api-plugin-webhook` returns empty). ## Impact ### Affected dependents No dependents (verified by `bunx turbo ls --filter=...@suaveplan/api-plugin-webhook` returning the empty set on the genesis branch — net-new package). ### Catalog additions required None. All deps are already pinned: `hono` (`catalog:server`), `@hono/zod-openapi` (`catalog:server`), `zod` (`catalog:forms`), `@opentelemetry/api` (`catalog:observability`).