4.7 KiB
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, raisesWEBHOOK_ENDPOINT_NOT_FOUND/WEBHOOK_INACTIVE_ENDPOINTon miss / inactive, signs and delivers via webhook-client.sendAll(event, data, filter?)— fans out to every active endpoint subscribed toevent; raisesWEBHOOK_EVENT_NOT_SUBSCRIBEDif no endpoint is listening.register/unregister/listEndpoints— programmatic CRUD wrappers around the configuredEndpointStore.test(endpointId)— fires a syntheticwebhook.pingevent with{ requestedAt: ISO8601 }.- Per-delivery dedup — concurrent
send()calls with the same(endpointId, payload.id)collapse to one delivery. When anidempotencyGuardis supplied, calls route throughguard.execute; otherwise an in-processMap<string, Promise<DeliveryResult>>carries the dedup. - Dead-letter — every exhausted delivery records a
DeadLetterEntryvia the configured sink and surfacesWEBHOOK_DELIVERY_EXHAUSTEDto the caller. Sink write failures surfaceWEBHOOK_DEAD_LETTER_WRITE_FAILEDwith the sink error ascause. - OTel — every code path increments
webhook_plugin.deliveries_totalwith the appropriatestatuslabel (delivered,exhausted,not_found,inactive,invalid).
Usage: Basic
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
// 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 aWebhookService. Every option is required (the plugin'sonInitfills defaults before calling).WebhookService.send(endpointId, event, data)— resolves withDeliveryResulton success, rejects withWebhookErroron miss / inactive / exhausted.WebhookService.sendAll(event, data, filter?)— resolves withreadonly DeliveryResult[]. Empty subscriptions raiseWEBHOOK_EVENT_NOT_SUBSCRIBED.WebhookService.register(input)— proxy toEndpointStore.create. Store errors are translated toWEBHOOK_STORE_ERROR.WebhookService.unregister(id)— proxy toEndpointStore.delete(idempotent).WebhookService.listEndpoints(filter?)— proxy toEndpointStore.list.WebhookService.test(endpointId)— fires a syntheticwebhook.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).