Files
skills/docs/api-stack/api/api-plugin-webhook/src/services/webhook-service.md
2026-05-29 08:20:56 +01:00

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, 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<string, Promise<DeliveryResult>> 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

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 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).