Skip to content

Webhooks

@lumenflow/surfaces’s http/webhooks/ module (WU-3522, INIT-091 phase 3) ships signed outbound webhooks with durable delivery history and idempotent replay, so a service account can verify a delivery and safely replay one it missed during an outage.

Every outbound event is signed with HMAC-SHA256 over a canonical (key-sorted) JSON encoding of its payload:

import {
  createWebhookKeyStore,
  signWebhookEvent,
  formatWebhookSignatureHeader,
  verifyWebhookSignature,
} from '@lumenflow/surfaces/http/webhooks/signing.js';

const keyStore = createWebhookKeyStore({ keyId: 'k1', secret: 'shared-secret', createdAt: now });

const header = signWebhookEvent(payload, keyStore.getSigningKey());
const headerValue = formatWebhookSignatureHeader(header); // "t=...,kid=...,v1=..."

verifyWebhookSignature(payload, headerValue, 'shared-secret'); // true

Verification needs only the raw shared secret — never the mutable key store — so a consumer can verify a delivery without holding any server-side mutable state. Signature comparison uses timingSafeEqual, and verification fails closed (returns false, never throws) on malformed input, since this is a trust boundary.

keyStore.rotateKey({ keyId: 'k2', secret: 'new-secret', createdAt: now });
keyStore.revokeKey('k1');

Every sign/verify lookup reads the store at call time, never a value captured once at startup — rotating in a new key or revoking an old one takes effect on the very next call, with no redeploy or process restart required.

Delivery history is the one durable outbox for signed webhooks: signing produces the signature, this store is where the resulting delivery is persisted and queried, and replay re-drains from this same store rather than standing up a second queue.

import { createDurableWebhookDeliveryHistoryStore } from '@lumenflow/surfaces/http/webhooks/delivery-history.js';

const store = createDurableWebhookDeliveryHistoryStore({
  filePath: '.lumenflow/state/webhooks/delivery-history.ndjson',
});

const record = store.record({
  eventId: 'evt-1',
  endpointId: 'ep-1',
  kind: 'wu:completed',
  payload,
  signatureHeader: headerValue,
});
store.appendAttempt({ eventId: 'evt-1', outcome: 'success', responseStatus: 200 });

store.listByEndpoint('ep-1');
store.listFailedAttempts('ep-1');

Storage is append-only NDJSON — the same durable-outbox convention this repo already uses elsewhere — so the store survives a process restart: reopening it against the same file rebuilds identical in-memory state by replaying the log from the start. status (pending / in_flight / acked / abandoned) reuses the same ReplayArtifactStatus vocabulary @lumenflow/control-plane-sdk already publishes, rather than a parallel status enum.

import {
  replayMissedWebhookDelivery,
  listReplayableWebhookDeliveries,
} from '@lumenflow/surfaces/http/webhooks/replay.js';

const outstanding = listReplayableWebhookDeliveries(store, 'ep-1'); // not yet acked
await replayMissedWebhookDelivery({ eventId: 'evt-1', store, deliverer });

Replay never regenerates a payload, signature, or event id: it re-drains the same durable record and hands it, byte for byte, to the injected WebhookDeliverer. Because the event’s identity never changes across attempts, a consumer that dedupes by eventId sees a replay as a no-op retry of an event it may already have processed — exactly the at-least-once, idempotent-consumer contract this repo’s other outbox uses. The actual network call is injected via WebhookDeliverer (a port); this module owns replay policy, not HTTP transport.

  • Contract spine — the error taxonomy webhook error classes map onto.