Stream writes enqueue rows in webhook_outbox inside the same database transaction as the stream update. The live dispatcher in src/webhooks/service.ts polls that table and sends each event to the configured consumer endpoint.
Required configuration:
WEBHOOK_URL: HTTPS endpoint that receives webhookPOSTrequests.WEBHOOK_SECRET: HMAC signing secret used forx-fluxora-signature.WEBHOOK_POLL_INTERVAL_MS: polling interval in milliseconds. Defaults to10000.WEBHOOK_BATCH_SIZE: rows claimed per poll. Defaults to10.WEBHOOK_RETRY_RPS: maximum outbound retry attempts per second per consumer URL. Defaults to10. Set lower (e.g.2) for consumers known to be slow or fragile.WEBHOOK_CIRCUIT_BREAKER_THRESHOLD: consecutive retryable failures before the circuit opens. Defaults to0(disabled). Set e.g.10to enable cross-instance protection.WEBHOOK_CIRCUIT_BREAKER_RESET_MS: how long the circuit stays open before a single half-open probe. Defaults to300000(5 minutes).WEBHOOK_DNS_TIMEOUT_MS: DNS lookup resolution timeout in milliseconds (fail-closed). Defaults to2000(2 seconds).
The service startup path starts the dispatcher after migrations are checked. Shutdown registers the dispatcher as a drainable service, so SIGTERM/SIGINT stops future polls and waits for the in-flight batch before closing database connections.
The dispatcher claims rows with:
SELECT ...
FROM webhook_outbox
WHERE processed = false
AND created_at <= NOW()
ORDER BY created_at ASC, id ASC
LIMIT $1
FOR UPDATE SKIP LOCKEDFOR UPDATE SKIP LOCKED lets multiple API instances run dispatchers concurrently without claiming the same row at the same time. A row is marked processed = true only after the HTTP attempt is complete. If the process exits before commit, PostgreSQL releases the lock and the row remains unprocessed for another worker to deliver, which provides at-least-once delivery.
Failed retryable deliveries are delegated to src/webhooks/retry.ts. The original row is marked processed and a new unprocessed row is inserted with created_at set to the next retry time. The dispatcher only claims rows whose created_at is due, so retries remain durable in PostgreSQL without holding process memory.
To prevent a slow or error-prone consumer from being bombarded with retries, attemptWebhookDeliveryWithRateLimit in src/webhooks/retry.ts enforces a per-consumer-URL sliding-window rate limit before each outbound attempt.
Per-consumer circuit breaker state is persisted in Redis (src/redis/webhookCircuitBreakerStore.ts) so multiple dispatcher instances and process restarts share the same open / half-open / closed view of a struggling consumer.
| State | Behaviour |
|---|---|
closed |
Deliveries allowed; consecutive failures increment toward the threshold. |
open |
Deliveries blocked until circuitBreakerResetMs elapses. |
half-open |
After reset expiry, one probe delivery is allowed across all instances. Success closes the circuit; failure re-opens it. |
- Before firing a retry, the dispatcher calls
checkWebhookDeliveryGate/attemptWebhookDeliveryWithRateLimitwith the consumer endpoint URL. - The circuit breaker store reads/writes JSON state at
webhook_cb:{sha256(url)}. Half-open probe ownership is tracked withwebhook_cb_probe:{sha256(url)}via RedisSET NX. - When the circuit is open, the outbox row is re-enqueued with
created_at = resetAt— no HTTP call is made. - Successful deliveries reset the breaker; retryable failures increment the shared failure counter.
- State transitions increment
fluxora_webhook_circuit_breaker_transitions_total{from_state,to_state}.
- Consumer URLs are SHA-256-hashed before use as Redis key segments (same approach as the rate limiter) to prevent key injection and to avoid storing raw URLs in Redis keys.
- A crafted URL cannot trip a breaker for a different consumer because keys are derived from the full URL digest.
| Condition | Behaviour |
|---|---|
| Circuit closed | Delivery proceeds (subject to rate limit). |
| Circuit open | Delivery deferred to resetAt; no consumer traffic. |
| Half-open probe succeeds | Circuit resets to closed. |
| Half-open probe fails | Circuit re-opens for another circuitBreakerResetMs. |
| Redis unavailable | Fail-open for gate checks; deliveries proceed. Failure recording is best-effort. |
- Before firing a retry, the dispatcher calls
attemptWebhookDeliveryWithRateLimitwith the consumer's endpoint URL and the configuredRateLimitConfig({ limit, windowMs }). - The rate limiter (
src/redis/webhookRateLimit.ts) maintains a Redis sorted set keyed by a SHA-256 hash of the consumer URL. Each recorded attempt is a member with score = timestamp (ms). - Entries older than
windowMsare pruned on every check. If the remaining count is at or abovelimit, the attempt is deferred rather than dropped. - A deferred attempt returns
{ shouldRetry: true, rateLimited: true, retryAt: now + windowMs }. The dispatcher re-inserts the outbox row withcreated_at = retryAt, so the deferral is durable in PostgreSQL. WEBHOOK_RETRY_RPS(default10) controlslimit;windowMsis1000 ms(one second).
| Condition | Behaviour |
|---|---|
| Within rate limit | Attempt proceeds; attempt recorded in Redis. |
| Limit exceeded | Attempt deferred; outbox row re-enqueued with retryAt = now + windowMs. No delivery is dropped. |
| Redis unavailable | Fail-open: attempt proceeds normally. A Redis outage does not halt deliveries. |
maxAttempts reached |
shouldRetry = false; row moves to dead-letter queue regardless of rate limit. |
- Consumer URLs are SHA-256-hashed before use as Redis key segments to prevent key-injection via crafted URLs and to bound key length.
- The rate limiter counts all outbound attempts (not just failures) to protect consumers from burst traffic regardless of outcome.
- Redis credentials are consumed from environment variables only and are never logged.
Webhook requests are signed with the configured secret and include delivery metadata headers. Production endpoints must use HTTPS unless they target loopback for local deployments. URLs with embedded credentials are rejected.
Consumers must treat webhook delivery as at-least-once: verify the signature, deduplicate by x-fluxora-delivery-id, and make handlers idempotent.
All webhook target URLs are validated before any network call to prevent Server-Side Request Forgery (SSRF) attacks. This protection is applied in both the WebhookDispatcher class and the dispatchWebhook helper function.
The SSRF guard blocks the following IP address ranges:
- Loopback addresses:
127.0.0.0/8,::1(includinglocalhost) - Link-local addresses:
169.254.0.0/16(includes AWS metadata endpoint169.254.169.254),fe80::/10 - Private networks:
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7(IPv6 unique local) - Reserved ranges:
0.0.0.0/8,240.0.0.0/4,224.0.0.0/4(multicast) - IPv4-mapped IPv6 loopback:
::ffff:127.0.0.0/8
- HTTPS required by default: All webhook URLs must use HTTPS unless explicitly configured otherwise
- HTTP/HTTPS only: Other protocols (FTP, etc.) are rejected
The guard resolves hostnames to IP addresses and validates each resolved IP against the blocked ranges. This prevents DNS rebinding attacks where an attacker might initially point a hostname to a public IP, then change it to a private IP after validation.
The WEBHOOK_ALLOWED_HOSTS environment variable can be set to restrict webhook delivery to specific hosts:
WEBHOOK_ALLOWED_HOSTS=api.example.com,*.trusted.com- Supports exact hostnames:
api.example.com - Supports wildcard subdomains:
*.trusted.commatchessub.trusted.comandtrusted.com - When not configured, all non-blocked hosts are allowed
- Blocked IP ranges are always rejected, even if in the allowlist
All webhook fetches enforce a timeout (default 30 seconds) to prevent slow-loris attacks and hanging requests. The timeout is applied via AbortController in both the class-based dispatcher and the helper function.
Add to your environment configuration:
# Optional: Restrict webhook delivery to specific hosts
WEBHOOK_ALLOWED_HOSTS=api.example.com,*.trusted.com
# Optional: Webhook DNS resolution timeout (in milliseconds)
WEBHOOK_DNS_TIMEOUT_MS=2000SSRF validation failures are logged without exposing the full URL for security. The validation fails closed: any ambiguous or unresolvable target is rejected with a WebhookTargetValidationError.
If DNS resolution times out or is aborted, it is rejected with a WebhookTargetValidationError containing a DNS_TIMEOUT code, which fails closed.
- Validation function:
validateWebhookTarget(url, options)insrc/webhooks/ssrfGuard.ts - Applied in:
WebhookDispatcher.dispatch()anddispatchWebhook()insrc/webhooks/dispatcher.ts - Timeout: Uses
DEFAULT_RETRY_POLICY.timeoutMs(30 seconds) - DNS resolution: Uses Node.js
dns.promises.lookup()wrapped with a custom timeout helper andAbortControllerbounded byWEBHOOK_DNS_TIMEOUT_MS(default2000ms).