Skip to main content
0.0.x

Deliver outbound webhooks

Goal: deliver signed outbox events with retries and a DLQ.

Use the existing workers plugin for authenticated delivery, bounded retries, and durable terminal-failure records. This recipe creates no new package, plugin, or runtime primitive.

Delivery is at-least-once, never exactly-once. A receiver can commit the effect and lose its HTTP response, or a worker can crash after sending and before recording completion. Both permit duplicates. The attempt budget bounds one handler execution, not all crash redeliveries. Successful receipt is not an SLA: after exhaustion the delivery remains in the DLQ for an operator to inspect or reprocess.

Inbound defineWebhook is the opposite direction: it verifies events arriving at your service. This recipe sends events from your background worker.

Generate the delivery job

Install the workers plugin using Add a plugin, then run its plugin CLI from your workspace root:

deno x -A jsr:@netscript/plugin-workers@0.0.7/cli add job deliver-webhook --template=webhook-delivery

The command emits workers/jobs/deliver-webhook.ts and regenerates the static worker registry. It contains a schema-backed handler, a defineJob definition, and a configuration-injected createDeliveryHandler factory. Register the exported deliveryJob with maxRetries 0 (the builder uses .retry(0)); keep that value in your workers configuration as well. The handler owns HTTP retries. Crash recovery belongs to the queue/runtime; this recipe does not guarantee replay after cancellation. Do not multiply this budget with runtime job retries.

Inject these values into the worker service, under a service identity:

Service setting Purpose
WEBHOOK_ENDPOINT_ID Consumer-owned endpoint registry key.
WEBHOOK_ENDPOINT_URL Active HTTPS endpoint, supplied by deployment configuration.
WEBHOOK_SIGNING_SECRET Secret resolved by service configuration; never put it in a job payload.
WEBHOOK_DLQ_PATH Persistent Deno KV database path, shared across worker restarts.
WEBHOOK_MAX_ATTEMPTS Total HTTP attempts per execution, default 5, integer from 1 to 100.

The generated default handler opens that configured KV database and closes its connection in finally. It uses the existing KvDeadLetterStore, implementing DeadLetterStorePort. Mount persistent storage; an ephemeral worker filesystem cannot preserve failures. For a remote queue backend, replace this composition with an existing durable queue store suited to your deployment. See Queue reference.

For multiple endpoints, own registry rows with id, url, secretReference, and active. Resolve secret references using your service's configuration provider, then inject a snapshot map and your existing DLQ store through createDeliveryHandler. Reject missing/inactive endpoints; never accept a URL or signing secret from an untrusted producer payload. The default entrypoint illustrates one active configured endpoint. Subscription CRUD, secret storage, URL authorization and endpoint lifecycle remain consumer-owned. The template requires HTTPS and refuses redirects and URL credentials.

Source deliveries from committed command outbox rows

The command outbox relay is available now through @netscript/service/commands/relay and the checked worker sink through @netscript/plugin-workers-core/integration/commands. Use the existing service transaction and relay, rather than sending HTTP during a request or starting a second polling loop.

At the relay boundary, deliveryId = CommandOutboxDelivery.id, the stable command-outbox ID. Persist the raw JSON body once, reuse it across attempts, and keep enqueuedAt from the durable producer record. Do not substitute the worker run ID or generate a fresh UUID on redelivery. The relay's dedupeKey is the worker command acceptance key; it may differ from the outbox row ID.

This adapter wraps the checked worker sink to pass the outbox ID in the selected job's payload while preserving the existing worker acceptance protocol. checkedWorkerSink must already target the schema-backed delivery job; resolveEndpointId selects a consumer-owned active endpoint and readEnqueuedAt reads its durable creation timestamp.

import type { CommandOutboxDelivery, CommandOutboxSink } from '@netscript/service/commands/relay';

declare const checkedWorkerSink: CommandOutboxSink;
declare const resolveEndpointId: (message: CommandOutboxDelivery) => string;
declare const readEnqueuedAt: (id: string) => string;

const outboundSink: CommandOutboxSink = {
  id: checkedWorkerSink.id,
  publish(message, signal) {
    return checkedWorkerSink.publish({
      ...message,
      payload: {
        deliveryId: message.id,
        endpointId: resolveEndpointId(message),
        enqueuedAt: readEnqueuedAt(message.id),
        body: JSON.stringify(message.payload),
      },
    }, signal);
  },
};

Register outboundSink in the existing relay's sink map. Run drainOnce from existing backend scheduling and await stop during shutdown. The checked sink settles publication only after durable worker acceptance, not after the remote HTTP effect. The relay may publish again after a crash; receiver deduplication is still required. See Command outbox relay and Checked worker command sink.

If you enqueue directly in a request without the transactional outbox, the announcement is at-most-once: the service can commit then crash before enqueueing. The worker may retry an accepted job, but cannot recover an announcement it never received. The phone and web application only issue commands; delivery never depends on an open application or an owner session.

Signing and receiver deduplication

Each request is a POST of the persisted raw UTF-8 JSON body. Headers are:

Header Meaning
Webhook-Id Stable command-outbox ID; authenticated receiver delivery identity.
Webhook-Timestamp Unix seconds refreshed for each attempt.
Webhook-Signature v1= followed by hex HMAC-SHA256 over timestamp + '.' + deliveryId + '.' + rawBody.
Idempotency-Key Same delivery ID for receivers using this conventional HTTP carrier.

Verify the raw body before parsing. Limit the request body to 64 KiB before reading it; the sender also enforces 64 KiB. Check timestamp skew (five minutes here), a single signature version, and equal Webhook-Id / Idempotency-Key values. crypto.subtle.verify performs cryptographic verification without a timing-sensitive string comparison. The delivery ID is included in the authenticated input, so an attacker cannot relabel a signed delivery to bypass deduplication.

async function verifyDelivery(
  rawBody: string,
  headers: Headers,
  secret: string,
  nowSeconds: number,
): Promise<string> {
  const id = headers.get('webhook-id');
  const timestamp = headers.get('webhook-timestamp') ?? '';
  const signature = headers.get('webhook-signature') ?? '';
  if (
    !id || id.length > 256 || headers.get('idempotency-key') !== id ||
    !/^\d{1,12}$/.test(timestamp) || Math.abs(nowSeconds - Number(timestamp)) > 300 ||
    !/^v1=[0-9a-f]{64}$/.test(signature) || new TextEncoder().encode(rawBody).length > 65536
  ) {
    throw new Error('Invalid webhook headers or timestamp');
  }
  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['verify'],
  );
  const bytes = Uint8Array.from(signature.slice(3).match(/../g)!, (hex) => parseInt(hex, 16));
  const valid = await crypto.subtle.verify(
    'HMAC',
    key,
    bytes,
    new TextEncoder().encode(timestamp + '.' + id + '.' + rawBody),
  );
  if (!valid) throw new Error('Invalid webhook signature');
  return id;
}

The authenticated HTTP Idempotency-Key is authoritative for this recipe's receiver. If the body also carries a command-envelope idempotencyKey, require equality and reject a conflict before invoking a command handler; never silently prefer the unsigned envelope value. The receiving service adapter must explicitly supply this verified key to its command boundary. Do not assume NetScript automatically reads these outbound headers as command metadata.

Persist a unique (endpointId, deliveryId) receipt atomically with the receiver's domain effect. On a duplicate return a successful 2xx response without repeating the effect. A read followed by a separate write is racy; use a unique constraint/transaction or an atomic compare-and-set. Keep dedup records at least as long as the producer's retry and operator replay retention. Checking signatures alone does not prevent authenticated retries from repeating effects.

Retry budget, outcome and dead letters

The generated policy defaults to 5 total attempts, a 1-second base, a 30-second cap and a 10-second per-attempt timeout. After failure number n, full jitter selects a delay in [0, min(30000, 1000 * 2^(n - 1))). Every non-2xx response and network failure consumes an attempt; there is no sleep after the last failure. Response bodies are canceled without being read. Attempt count and HTTP outcome are reported through the existing job progress callback; completion returns delivered or dead_lettered with the delivery ID and count.

The loop propagates ctx.signal to fetch and waits. Each fetch timeout is capped by ctx.deadlineAt; it refuses a retry that cannot fit the remaining job budget. When the handler observes an exhausted deadline or cannot fit the next retry delay, it awaits a DLQ append with errorCode: 'job_deadline_exceeded'. An aborted ctx.signal, including a runtime timeout abort, still propagates without dead-lettering; cancellation recovery depends on the runtime and is not proven by this recipe. Configure the overall job timeout for the intended budget (five attempts need up to 50 seconds of HTTP time plus capped jitter).

After the configured maximum attempts or handler-observed deadline exhaustion, the handler awaits deadLetters.append with the existing DeadLetterRecord: messageId is the outbox ID, deliveryCount is the attempt count, the original payload is retained, and reason is max_attempts_exceeded. Only a successful append completes the job. A store failure propagates; it never acknowledges a silently dropped delivery. The delivery ID stays stable; the existing KV store keys terminal records by namespace, failure time and message ID, so separate exhausted executions can retain separate failure records. No signing secret or arbitrary receiver response text is stored in the record. Protect DLQ access because the original event payload can contain private data.

Use the existing store's list, depth and reprocess methods to inspect and replay failures. Replays keep the original delivery ID and body; resolve the current service secret and generate a fresh signature timestamp. Operators own replay authorization and retention.

Non-goals and verification

This recipe is not a delivery SLA, subscription management API, receiver-side framework, new plugin, or an exactly-once primitive. It changes no inbound trigger behavior. Runtime retry/DLQ ownership could be reconsidered when workers expose attempts and a terminal-store seam; this recipe uses the current handler context and existing queue vocabulary.

The generated-worker tests prove raw-body signatures, capped jitter, the attempt cap, durable-store routing, cancellation, deadlines, and rejection of a hardcoded endpoint. The page's TypeScript fences are extracted and checked against package export entrypoints. Full scaffold runtime E2E and a later consumer observation are separate release evidence; local template execution does not establish them.