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, andwebhook-signature(sha256=…) computed over"<timestamp>.<body>", the same conventionverifyWebhookSignature()validates. - Retry with backoff— transient statuses (
408/429/500/502/503/504) and network errors are retried with exponential backoff + jitter, honouringRetry-After. - Dead-letter— events that exhaust their attempts (or fail permanently) are handed to a
WebhookDeadLetterSinkfor 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
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:
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:
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.
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:
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.