HookRelay
Reliable webhook delivery with durable state, signed requests, bounded retries, and dead-letter recovery. Every attempt stays inspectable.
A successful request is only the beginning
Outbound webhooks need a reliability layer between accepting an event and a receiver actually processing it. Network failures, timeouts, transient HTTP errors, and duplicate requests are normal parts of that path.
HookRelay is a production-style local reference implementation of that layer. It accepts events idempotently, commits delivery state to PostgreSQL, schedules asynchronous work through Redis and BullMQ, signs outgoing requests, and records every attempt.

Durable state, separate from execution
The Fastify API validates endpoint and event input with Zod. Event acceptance creates an event and its initial delivery in one PostgreSQL transaction, before queue scheduling is attempted.
BullMQ carries the durable deliveryId. The worker reloads canonical event and endpoint data from PostgreSQL, starts a persisted attempt, signs and sends the request, classifies the response, and writes the outcome back to the database.

The dashboard reads deliveries, attempt timelines, retry scheduling, latency, failures, and replay lineage through the API. Metrics are calculated from PostgreSQL state rather than inferred from transient queue contents.
One event, even when requests race
POST /events requires an Idempotency-Key. The first request creates one event and one initial delivery. Repeating the same key with the same endpoint, event, and payload returns the existing records. Reusing the key for different input returns 409 idempotency_conflict.
PostgreSQL uniqueness makes concurrent identical requests converge. An in-memory lock would not provide the same database-backed guarantee.
Durable acceptance comes before queue scheduling. If Redis scheduling fails, the API returns 503 with durableAccepted: true, asking the client to retry the same request with the same idempotency key.
That boundary is part of the design, not a hidden promise of exactly-once execution. A receiver can process a request before a worker crashes while recording success; a later retry may deliver the same logical event again.
Retry deliberately. Replay without erasing history.
Retries follow a fixed 5s / 15s / 45s / 120s schedule, for a maximum of five attempts. A five-second request timeout bounds the network operation. Only failures classified as transient consume that retry budget.
| Outcome | Classification | Action |
|---|---|---|
| HTTP 2xx | Success | Mark delivered |
| HTTP 408, 429, or 5xx | Retryable | Retry if budget remains |
| Network error or timeout | Retryable | Retry if budget remains |
| Other HTTP response | Terminal | Move directly to dead letter |
| Retryable failure on attempt five | Exhausted | Move to dead letter |

Only dead-letter deliveries can be replayed. Replay has its own idempotency key and creates a new linked delivery with a fresh retry budget. The source is never reset: its terminal status and original attempt history stay intact.

Trade-offs
| Choice | Why | Cost |
|---|---|---|
| PostgreSQL as the source of truth | Delivery state, attempts, metrics, and replay lineage remain durable. | The database participates in the critical execution path. |
| BullMQ jobs carry only delivery IDs | Workers reload canonical records instead of trusting stale queued payloads. | Each execution needs database reads. |
| Fixed retry delays and a five-attempt budget | Recovery is bounded and easy to reproduce. | This version does not implement adaptive retry policies. |
| New delivery for manual replay | Preserve the failed history and give the replay an independent budget. | Operations must understand linked delivery lineage. |
| Durable acceptance before scheduling | Avoid losing or duplicating durable rows when scheduling fails. | The dual-write gap needs client retry; no outbox is implemented. |
| At-least-once semantics | Acknowledge the crash window between remote processing and local persistence. | Receivers must deduplicate events. |
Every request is authenticated with HMAC-SHA256 over timestamp.rawBody. The included receiver example validates timestamp freshness and compares signatures in constant time. Authentication and multi-tenancy for the platform itself remain outside the current version.
Measured results
The committed local benchmark used one API process, one BullMQ worker, one PostgreSQL instance, one Redis instance, and an immediate local HTTP receiver.
| Measurement | Local result |
|---|---|
| Measured runs | 3 × 500 deliveries |
| Terminal success | 1,500 / 1,500 · 100% |
| Delivery throughput | 71.0–78.6 deliveries / second |
| Mean delivery throughput | 76.0 deliveries / second |
| Ingestion throughput | 426.7–599.6 events / second |
| Client-observed ingestion p95 | 48.5–70.0 ms |
Two correctness tests each issued 25 concurrent requests. Identical ingestion converged to one event and one initial delivery. Identical replay requests converged to one replay delivery, while the source dead letter remained unchanged.
The deterministic receiver also exercises immediate success, 500 → 500 → 200 recovery, and exhaustion after five failures through the real API, queue, worker, signing, and database paths.
The boundary of this version
The implemented local scope includes the API, durable data model, queue worker, request signing, bounded retries, dead-letter recovery, idempotent replay, dashboard, deterministic demo, tests, and benchmark evidence.
The next reliability extension would address the documented database / queue dual-write gap with an outbox or reconciliation mechanism. That is outside the current implementation and is not represented as shipped.
Other deliberate non-goals are platform authentication and multi-tenancy, secret encryption or rotation, horizontal worker-capacity validation, multi-region delivery, and high-availability guarantees. Keeping those boundaries visible makes the local evidence easier to interpret.
FAQ
Does HookRelay guarantee exactly-once delivery?
No. Delivery is at least once. A worker can crash after a receiver processes a request but before local success is recorded, so consumers should deduplicate by event ID.
Which responses are retried?
Network errors, five-second timeouts, HTTP 408, HTTP 429, and 5xx responses are retryable. Other non-2xx responses go directly to dead letter. The maximum is five attempts.
What happens if queue scheduling fails?
The event and delivery have already been persisted. The API returns 503 with durableAccepted: true and asks the client to retry the same request with the same idempotency key.
Does replay overwrite the failed delivery?
No. Replay creates a new delivery linked to the source, with its own attempt history and retry budget. The original remains a dead letter.
How can a receiver verify a webhook?
Use the supplied event identity, timestamp, and HMAC-SHA256 signature over timestamp.rawBody. The repository includes a verifier demonstrating freshness checks and constant-time comparison.
Is there a hosted production service?
The README describes a local reference implementation with a deterministic receiver, operations dashboard, tests, and bounded benchmarks. It does not claim a hosted production service or high-availability guarantees.