2026
HookForge
an asynchronous webhook delivery service — events are queued, delivered with signed HTTP requests, retried through SQS redelivery, and moved to a dead-letter queue when they keep failing.
why
webhook delivery looks simple until the receiving service isn't available.
if an application sends an HTTP request directly and the receiver is down, the sender has to decide whether to retry, how long to wait, and when to give up. that delivery logic doesn't really belong in the application producing the event.
HookForge separates accepting an event from delivering it. the API stores the event and puts a small delivery job onto a queue, then returns immediately. a background worker handles the actual HTTP delivery.
the problem
a webhook delivery system needs to deal with things that don't happen in a happy-path demo: receivers can timeout, return 500, rate-limit with 429, or reject a request permanently with a 4xx.
the naive approach is to put retry logic directly inside the HTTP request — make a request, wait, try again, sleep, try again. that ties up application work and makes the delivery path harder to reason about.
I wanted the queue to own the retry boundary instead.
architecture
the API receives an event and stores it in an in-memory event store. it then puts only the eventId and endpointId onto Amazon SQS rather than copying the entire event into the message.
a background DeliveryWorker polls SQS and hands each job to the DeliveryEngine. the engine performs exactly one HTTP delivery attempt and records the result.
the important part is that the engine doesn't contain a retry loop.
a successful 2xx response acknowledges the SQS message and marks the event DELIVERED. a permanent 4xx failure is treated as DEAD and is also acknowledged. temporary failures — 408, 429, 5xx, timeouts, and other delivery exceptions — leave the message available for SQS redelivery.
after the configured number of receives is exhausted, SQS moves the message to a dead-letter queue instead of allowing a permanently failing endpoint to consume delivery attempts forever.
signing
each registered endpoint receives a randomly generated 32-byte signing secret.
before sending a webhook, HookForge creates a signature using HMAC-SHA256 over:
timestamp + "." + body
the request includes the event id, timestamp, and sha256= signature in HTTP headers.
the receiving service can calculate the signature independently and compare it with the one sent by HookForge, giving it a way to verify that the request came from a holder of the endpoint secret and that the signed payload was not changed.
the secret is returned only when an endpoint is created; normal endpoint responses don't expose it.
what i learned
the most useful design decision was moving retry responsibility out of the delivery engine and into SQS.
the delivery engine only needs to answer one question: "what happened on this attempt?" the queue then decides whether that work should be considered finished or become available for another attempt.
that separation makes the delivery path much easier to reason about and means the same worker contract works locally with an in-memory queue and against the real AWS SQS implementation.
I also learned that reliability introduces boundaries rather than guarantees. storing an event and enqueueing a message are two separate operations, so there is a possible failure window between them. solving that properly would require a durable store and an outbox-style approach rather than pretending the two operations are atomic.
what broke
the most interesting failure wasn't an HTTP bug — it was seeing the system behave differently when the receiver deliberately returned 500.
the first attempt correctly moved the event to RETRYING, but the application didn't itself schedule another HTTP request. SQS redelivered the message, producing another independent delivery attempt.
after the configured number of receives, the message moved to the DLQ.
that test made the distinction between application retry logic and queue redelivery concrete.
security
endpoint registration includes basic SSRF protection and blocks known cloud metadata addresses. localhost remains allowed for local development.
this is intentionally not presented as complete SSRF protection: production deployment would need stronger destination validation, including DNS/IP handling and careful redirect policy.
payloads are also capped at approximately 256KB to avoid allowing arbitrarily large event bodies.
what's next
the current version intentionally keeps event and endpoint state in memory so the project stays small and focused on the delivery architecture.
a production version would replace the stores with durable persistence and use an outbox or equivalent mechanism to close the store-to-queue failure window. it would also need authentication, rate limiting, stronger SSRF protection, and a mechanism to reconcile events when messages ultimately reach the DLQ.
all projects