Search docs

Jump between documentation pages.

Browse docs

Outbound webhook delivery

DaloyJS already verifies inbound webhooks with verifyWebhookSignature() and signs payloads with signWebhookPayload(). As of 0.37.0 createWebhookSender() closes the loop on the outbound side: it delivers events to your subscribers with a timestamped HMAC signature, bounded retries with exponential backoff, Retry-After awareness, a per-attempt timeout, and dead-letter semantics — all with zero runtime dependencies and SSRF-safe transport by default.

  • Signed delivery — each request carries webhook-id, webhook-timestamp, and webhook-signature (sha256=…) computed over "<timestamp>.<body>", the same convention verifyWebhookSignature() validates.
  • Retry with backoff— transient statuses (408/429/500/502/503/504) and network errors are retried with exponential backoff + jitter, honouring Retry-After.
  • Dead-letter— events that exhaust their attempts (or fail permanently) are handed to a WebhookDeadLetterSink for later inspection or replay.
  • SSRF-safe by default — the transport defaults to fetchGuard(), so a subscriber URL pointing at cloud metadata or a private range is refused (and never retried).

Quick start

ts
import { createWebhookSender, MemoryWebhookDeadLetterSink } from "@daloyjs/core";

const deadLetter = new MemoryWebhookDeadLetterSink();

const send = createWebhookSender({
  secret: process.env.WEBHOOK_SIGNING_SECRET!,
  deadLetter,
});

const result = await send({
  url: subscriber.endpoint,
  eventType: "invoice.paid",
  payload: { id: invoice.id, amount: invoice.total },
});

if (!result.ok) {
  console.warn("delivery failed", result.attempts, result.status, result.error);
}

What the receiver sees

Every delivery is a POST with a stable idempotency id and a signature your subscriber verifies with the same shared secret:

http
POST /hooks HTTP/1.1
content-type: application/json
user-agent: DaloyJS-Webhook/1.0
webhook-id: 7c1c2d4e-...-9f
webhook-timestamp: 1700000000
webhook-signature: sha256=9f8a...c2

{"id":"in_1","amount":4200}

The signature is computed once and reused across retries, so the webhook-id and webhook-signatureare identical on every attempt — receivers can safely dedupe on the id.

Verifying on the receiving end

A DaloyJS receiver verifies the delivery with the inbound helper, using the same secret and the webhook-timestamp header:

ts
import { verifyWebhookSignature } from "@daloyjs/core";

app.post("/hooks", async (c) => {
  const body = await c.req.arrayBuffer();
  const ok = await verifyWebhookSignature({
    payload: new Uint8Array(body),
    signature: c.req.header("webhook-signature")!,
    secret: process.env.WEBHOOK_SIGNING_SECRET!,
    timestamp: Number(c.req.header("webhook-timestamp")),
    toleranceSeconds: 300,
  });
  if (!ok) return c.text("invalid signature", 401);
  // ... handle the event
  return c.text("ok");
});

Retry & backoff

Failed deliveries are retried up to maxAttempts (default 5) with exponential backoff between retryDelayMs and maxRetryDelayMs. A Retry-After header on a 429/503 takes precedence (capped at maxRetryDelayMs). Only transient statuses and network/timeout errors are retried; a 400 or any other non-retryable status fails immediately.

ts
const send = createWebhookSender({
  secret,
  maxAttempts: 6,
  retryDelayMs: 250,
  maxRetryDelayMs: 60_000,
  backoffFactor: 2,
  jitter: true,
  timeoutMs: 10_000,
  retryableStatuses: [408, 429, 500, 502, 503, 504],
  respectRetryAfter: true,
  onAttempt: (a) =>
    console.log("attempt", a.attempt, "status", a.status, "retry?", a.willRetry),
});

Dead-letter semantics

When an event exhausts its attempts — or fails permanently (a non-retryable status or an SSRF refusal) — it is handed to the configured WebhookDeadLetterSink. The built-in MemoryWebhookDeadLetterSink is a bounded ring buffer; in production, implement the one-method interface to persist to your queue or table:

ts
import type { WebhookDeadLetter, WebhookDeadLetterSink } from "@daloyjs/core";

class TableDeadLetterSink implements WebhookDeadLetterSink {
  async add(letter: WebhookDeadLetter): Promise<void> {
    await db.deadLetters.insert({
      id: letter.id,
      url: letter.url,
      eventType: letter.eventType,
      body: Buffer.from(letter.payload),
      contentType: letter.contentType,
      attempts: letter.attempts,
      lastStatus: letter.lastStatus,
      lastError: letter.lastError,
      failedAt: new Date(letter.failedAt),
    });
  }
}

The stored payload and timestamp are exactly what was signed, so a dead-lettered event can be re-delivered later without re-signing under a new timestamp.

SSRF posture

The transport defaults to fetchGuard(). A subscriber URL that resolves to a cloud-metadata address or a private range is refused with an SsrfBlockedError, which the sender treats as a permanent failure: it is never retried and goes straight to the dead-letter sink. To use a custom transport (for example, a resilientFetch() wrapping fetchGuard()), pass fetch explicitly — but never default it to the bare global fetch for subscriber-controlled URLs.