# DaloyJS webhooks

DaloyJS already verifies *inbound* webhooks with `verifyWebhookSignature()` and signs payloads with `signWebhookPayload()`. `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).

**Diagram: Delivery lifecycle**

Participants: createWebhookSender(), fetchGuard(), Subscriber, Dead-letter sink

1. **createWebhookSender() -> Subscriber** (request) - Signed POST (attempt 1) - webhook-id, webhook-timestamp, webhook-signature=sha256=...
2. **Subscriber -> createWebhookSender()** (response) - 503 + Retry-After (transient) - retryable status -> schedule a retry
3. **createWebhookSender() -> Subscriber** (async) - Retry with backoff + jitter - same webhook-id/signature reused. Honours Retry-After
4. **Subscriber -> createWebhookSender()** (response) - 2xx -> delivered - result.ok === true
5. **fetchGuard() -> Dead-letter sink** (note) - SSRF refusal or attempts exhausted - SsrfBlockedError is permanent: never retried -> WebhookDeadLetterSink

Each delivery is a signed POST. Transient failures are retried with exponential backoff and jitter while reusing the same signature. An SSRF refusal or an exhausted attempt budget is sent straight to the dead-letter sink for inspection or replay.

## 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-signature` are identical on every attempt, so 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",
  {
    operationId: "receiveWebhook",
    responses: {
      200: { description: "ok" },
      401: { description: "invalid signature" },
    },
  },
  async ({ request }) => {
    const body = new Uint8Array(await request.arrayBuffer());
    const ok = await verifyWebhookSignature({
      payload: body,
      signature: request.headers.get("webhook-signature")!,
      secret: process.env.WEBHOOK_SIGNING_SECRET!,
      timestamp: Number(request.headers.get("webhook-timestamp")),
      toleranceSeconds: 300,
    });
    if (!ok) return { status: 401 as const, body: { error: "invalid signature" } };
    // ... handle the event
    return { status: 200 as const, body: { ok: true } };
  },
);
```

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

---

Source: https://daloyjs.dev/docs/webhook-delivery