# `webhook-service.ts` — `WebhookService` implementation ## Purpose Wraps `@suaveplan/webhook-client`'s `deliver` function with the plugin's orchestration layer: endpoint lookup, tenant scoping, per-delivery dedup, dead-letter sink wiring, error-code translation, and OpenTelemetry emission. The result is the single object registered in the host `ServiceRegistry` under the `webhookService` slot — downstream plugins call it to send signed events, fan out to every subscriber of an event, manage subscriptions programmatically, and fire the synthetic `webhook.ping` event used by the `POST /webhooks/:id/test` route. ## Features - `send(endpointId, event, data)` — looks up the endpoint, raises `WEBHOOK_ENDPOINT_NOT_FOUND` / `WEBHOOK_INACTIVE_ENDPOINT` on miss / inactive, signs and delivers via webhook-client. - `sendAll(event, data, filter?)` — fans out to every active endpoint subscribed to `event`; raises `WEBHOOK_EVENT_NOT_SUBSCRIBED` if no endpoint is listening. - `register` / `unregister` / `listEndpoints` — programmatic CRUD wrappers around the configured `EndpointStore`. - `test(endpointId)` — fires a synthetic `webhook.ping` event with `{ requestedAt: ISO8601 }`. - Per-delivery dedup — concurrent `send()` calls with the same `(endpointId, payload.id)` collapse to one delivery. When an `idempotencyGuard` is supplied, calls route through `guard.execute`; otherwise an in-process `Map>` carries the dedup. - Dead-letter — every exhausted delivery records a `DeadLetterEntry` via the configured sink and surfaces `WEBHOOK_DELIVERY_EXHAUSTED` to the caller. Sink write failures surface `WEBHOOK_DEAD_LETTER_WRITE_FAILED` with the sink error as `cause`. - OTel — every code path increments `webhook_plugin.deliveries_total` with the appropriate `status` label (`delivered`, `exhausted`, `not_found`, `inactive`, `invalid`). ## Usage: Basic ```ts import { createWebhookService } from "@suaveplan/api-plugin-webhook/services/webhook-service"; import { MemoryEndpointStore } from "@suaveplan/api-plugin-webhook/store/memory"; import { MemoryDeadLetterSink } from "@suaveplan/api-plugin-webhook/dead-letter/memory"; const service = createWebhookService({ endpointStore: new MemoryEndpointStore(), deadLetterSink: new MemoryDeadLetterSink(), maxAttempts: 5, initialRetryDelayMs: 500, maxRetryDelayMs: 30_000, idempotencyGuard: null, logger: console as never, clock: () => Date.now(), fetch: globalThis.fetch.bind(globalThis), }); const endpoint = await service.register({ tenantId: null, url: "https://partner.example.com/hooks", secret: "shared-secret", events: ["order.created"], }); await service.send(endpoint.id, "order.created", { orderId: 42 }); ``` ## Usage: Advanced ```ts // Fan-out to every active subscriber, gated by tenant const results = await service.sendAll( "order.created", { orderId: 42 }, { tenantId: "tenant-a" } ); console.log(`${results.length} deliveries fired`); // Probe an endpoint without firing real business data const ping = await service.test(endpoint.id); console.log(ping.status, ping.finalStatusCode); ``` ## API Reference - `createWebhookService(options)` — factory. Returns a `WebhookService`. Every option is required (the plugin's `onInit` fills defaults before calling). - `WebhookService.send(endpointId, event, data)` — resolves with `DeliveryResult` on success, rejects with `WebhookError` on miss / inactive / exhausted. - `WebhookService.sendAll(event, data, filter?)` — resolves with `readonly DeliveryResult[]`. Empty subscriptions raise `WEBHOOK_EVENT_NOT_SUBSCRIBED`. - `WebhookService.register(input)` — proxy to `EndpointStore.create`. Store errors are translated to `WEBHOOK_STORE_ERROR`. - `WebhookService.unregister(id)` — proxy to `EndpointStore.delete` (idempotent). - `WebhookService.listEndpoints(filter?)` — proxy to `EndpointStore.list`. - `WebhookService.test(endpointId)` — fires a synthetic `webhook.ping`. ## Implementation Notes The dedup key (`${endpointId}:${payload.id}`) was chosen so two distinct payload ids for the same endpoint can deliver concurrently — only same-id sends collapse. This matches the webhook-client contract where `payload.id` IS the delivery identity. The `idempotencyGuard` branch and the in-process branch must produce identical observable behavior — concurrent same-key sends both return the same `DeliveryResult`. The exhaustion path always records the dead-letter entry BEFORE throwing so operators can replay even when the caller's catch block fails to log. The OTel `status` enum carries five values rather than the obvious two (`delivered`/`failed`) so dashboards can distinguish receiver failures (`exhausted`) from misconfiguration (`not_found`/`inactive`) and fan-out edge cases (`invalid`).