TechFabricTechFabricPlatform
Platform referenceHost

Webhook delivery

Deliver Host outbox events to customer-registered webhook subscriptions with @fabricorg/webhook-delivery, including fan-out, signed delivery, and governed operator actions.

@fabricorg/webhook-delivery delivers canonical events from the durable outbox to HTTPS endpoints that tenants register as subscriptions. It is outbound only and is specified by ADR 0018. It starts no scheduler: the application calls its cycles from its own workers.

What it provides

  • Two-stage fan-out. createWebhookFanOutPublisher() is an outbox publisher. Inside the relay it stores one fan-out job per event, keyed by event ID. runWebhookFanOutCycle() runs later in a worker, on its own lease and retry budget, and turns each job into delivery intents or recorded denials per matching subscription.
  • Delivery cycle. runWebhookDeliveryCycle() claims intents, re-reads the subscription, re-resolves the destination, signs each attempt (X-Fabric-Signature: v1=<hex HMAC-SHA256 of "<unix-seconds>.<raw-body>">), hands it to a transport and settles the outcome with the retry schedule and dead letter.
  • Governed actions. createWebhookDeliveryModule() returns a Platform FabricModule with the operator mutations: subscription create, update, pause, resume and delete; staged and emergency secret rotation; delivery-intent and fan-out-job requeue; actor enrichment. They run through the governed Host path and carry secret references, never secret bytes.
  • Node transport. createNodeWebhookTransport() and createNodeWebhookDnsLookup() from @fabricorg/webhook-delivery/node. The transport connects only to the addresses the resolver vetted as public and follows no redirects.

Requirements

pnpm add @fabricorg/webhook-delivery@^0.1.0 @fabricorg/platform@^1.7.0 @fabricorg/platform-host@^7.3.0

The package needs Node.js 20 or newer (run 22.23.4, 24.22.1, or 26.12.1 or newer in the same release line in production for the Brotli decoder fix in CVE-2026-111497; Node 20 is end-of-life and has no fixed release) and peers on @fabricorg/platform ^1.7.0 and @fabricorg/platform-host ^7.3.0. Host 7.3.0 is the release that runs submission admission, which the package's actions rely on. On an older Host the actions refuse at execution as admission_not_enforced, after Host has already stored the submitted parameters.

Enable these Host features first (ADR 0018, "Adoption gates"): the outbox relay on a transactional store, outbox dead-letter requeue (requeueOutboxDeadLetter with authorizeOutboxRequeue), and the opt-in envelope actor (outbox.includeActor) if admission needs the actor. Production storage is PostgreSQL; call ensureSchema() on both PostgreSQL stores at startup or in a migration step.

Composition root

import { Pool } from "pg";
import { createModuleRegistry } from "@fabricorg/platform";
import { createGovernedActionHost, runOutboxRelayCycle } from "@fabricorg/platform-host";
import {
	PostgresWebhookDeliveryStore,
	PostgresWebhookSubscriptionStore,
	createWebhookDeliveryModule,
	createWebhookDestinationResolver,
	createWebhookFanOutPublisher,
	runWebhookDeliveryCycle,
	runWebhookFanOutCycle,
} from "@fabricorg/webhook-delivery";
import { createNodeWebhookDnsLookup, createNodeWebhookTransport } from "@fabricorg/webhook-delivery/node";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const clock = { now: () => new Date() };
const deliveries = new PostgresWebhookDeliveryStore({ pool, clock, replayHorizonMs });
const subscriptions = new PostgresWebhookSubscriptionStore({ pool, clock });
await subscriptions.ensureSchema();
await deliveries.ensureSchema();

const resolver = createWebhookDestinationResolver({ lookup: createNodeWebhookDnsLookup() });
const transport = createNodeWebhookTransport();

// Governed operator actions. Nothing else is wired for admission.
const webhooks = createWebhookDeliveryModule({
	subscriptions,
	deliveries,
	resolver,
	secretReferences, // { verify({ tenantId, spaceId, secretRef }) }
	actorReader,
	authorizeOperatorAction,
	clock,
});
const host = createGovernedActionHost({
	registry: createModuleRegistry([...appModules, webhooks]),
	store: hostStore, // must be outbox-capable, e.g. PostgresPlatformHostStore
	authorization, // includes authorizeOutboxRequeue; without it every outbox requeue is refused
	// Without `outbox`, Host appends no outbox records and nothing reaches the fan-out.
	outbox: { producerModuleVersion, classifyPayload, includeActor },
});

// Worker loops, scheduled by the application.
const publisher = createWebhookFanOutPublisher({ store: deliveries, clock, policy: payloadPolicy });
await runOutboxRelayCycle({
	store: hostStore, publisher, workerId: "relay-1",
	maxAttempts: 24, // the default of 10 dead-letters a fan-out job insert too soon
	retryDelayMs: (attempt) => Math.min(1_000 * 2 ** (attempt - 1), 15 * 60_000),
});

await runWebhookFanOutCycle({
	deliveries, subscriptions, admission, clock, workerId: "fan-out-1",
	policy: payloadPolicy, actorRequired: false, random: Math.random,
});

await runWebhookDeliveryCycle({
	deliveries, subscriptions, authorization: deliveryAuthorization, signer,
	resolver, transport, clock, workerId: "delivery-1", random: Math.random,
});

admission, signer, secretReferences, actorReader, authorizeOperatorAction, deliveryAuthorization, payloadPolicy, replayHorizonMs, and Host's authorization, producerModuleVersion, classifyPayload and includeActor are the application's own: the ports and the egress allowlist are described in the package README. The relay budget above follows ADR 0018: delays growing from one second and capped at 15 minutes, and at least 24 attempts. includeActor is needed only when subscription admission reads the actor.

Admission needs no wiring

Every action in the module declares admitSubmission, and Host 7.3.0 runs it on each submission after authorization and before it writes the invocation, the idempotency key, an event or an error. The check refuses a secret reference that is malformed, or that the tenant's secret store does not hold, so the value is never stored. The application passes no option and registers no hook for this. The only requirement is a Host at 7.3.0 or later; the handler itself refuses without the admission record Host adds.

Semantics to know

  • Delivery is at least once; receivers deduplicate on the event ID.
  • A subscription's secret is a reference. Rotation writes a new configuration version, and a staged rotation reports when the previous secret may be retired.
  • The webhook stores are not Host's store, so a store write and the Host event append are not one transaction. The README's "Atomicity and re-runs" section states which actions are idempotent.
  • The package's own action events are not delivered to subscriptions.

On this page