Skip to content

ar363/webhook-dispatch-engine

Repository files navigation

Webhook Delivery Engine — Retry, DLQ, Concurrent Fan-out

Receive events, fan out to N subscribers concurrently, retry with exponential backoff, route failures to a Dead Letter Queue.

Architecture

POST /events
     │
     ▼
┌─────────────┐     enqueue N jobs     ┌──────────────────┐
│  events     │ ───────────────────▶  │  delivery_jobs   │
│  table      │                        │  (MySQL queue)   │
└─────────────┘                        └────────┬─────────┘
                                                │ SKIP LOCKED
                                                ▼
                                       ┌──────────────────┐
                                       │  Worker Pool     │
                                       │  (concurrent)    │
                                       └────────┬─────────┘
                                                │
                              ┌─────────────────┼─────────────────┐
                              ▼                 ▼                 ▼
                         Subscriber A     Subscriber B     Subscriber C
                              │
                        (on failure)
                              ▼
                    exponential backoff
                    (max 5 attempts)
                              │
                        (still failing)
                              ▼
                    ┌──────────────────┐
                    │  dead_letter     │
                    │  queue           │
                    └──────────────────┘

Quick start

cp .env.example .env.docker
# edit .env.docker so DATABASE_URL/REDIS_URL point at the docker-compose
# service names instead of localhost:
#   DATABASE_URL=mysql://root:root@mysql:3306/webhooks
#   REDIS_URL=redis://redis:6379

docker-compose up

The app runs on http://localhost:8787. Tables are created automatically on startup from db/schema.sql.

Open http://localhost:8787 in a browser for a live console — register subscribers, fire events, and watch them move through the queue in real time, including retries and the Dead Letter Queue. The same actions are also available as plain REST calls, shown below.

Example usage

Subscribe an endpoint to an event type:

curl -X POST http://localhost:8787/subscriptions \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "user.created",
    "endpoint_url": "https://example.com/webhooks",
    "secret": "whsec_123",
    "rate_limit_per_min": 60
  }'

Ingest an event (fans out to every matching subscription):

curl -X POST http://localhost:8787/events \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "user.created",
    "payload": {"user_id": 42, "name": "Ada"},
    "idempotency_key": "evt-abc-123"
  }'

Re-posting the same idempotency_key returns the original event and does not enqueue new deliveries.

Check the Dead Letter Queue for deliveries that exhausted all retries:

curl http://localhost:8787/dlq

Manually retry a dead delivery:

curl -X POST http://localhost:8787/dlq/<dlq-entry-id>/retry

Check system health and queue depth:

curl http://localhost:8787/health

Key design decisions

  • Why MySQL + SKIP LOCKED instead of Kafka/RabbitMQ: removes a dependency, MySQL is already required, and SKIP LOCKED gives lock-free concurrent dequeue with zero additional infra.
  • Exponential backoff formula: 2^attempt * 10 seconds (10s → 20s → 40s → 80s → 160s), capped at 5 attempts before a job is routed to the DLQ.
  • Idempotency: idempotency_key has a UNIQUE constraint at the DB level, so duplicate ingestion returns the original event without re-enqueuing deliveries.
  • Delivery signing: each request is signed with HMAC-SHA256 over the raw payload using the subscription's secret, sent as X-Webhook-Signature, so subscribers can verify authenticity.

Demo

Recorded against a real docker-compose up stack — no mockups. Each screenshot below is the actual console (http://localhost:8787) reacting to real API calls, polling the backend every 1.5s.

demo

1. Empty console

Nothing subscribed, nothing in flight, DLQ empty.

empty state

2 & 3. Register subscribers

One healthy endpoint for user.created, and one pointed at an unreachable host for user.deleted — set up on purpose to demonstrate the retry path below.

subscriber added two subscribers

4. Send an event → delivered

POST /events fans out a job, the worker picks it up on its next poll (SKIP LOCKED dequeue), and the Activity feed's status wire turns green the moment the subscriber returns 2xx.

event delivered

5. Send an event → retrying

Pointed at the broken subscriber, the job comes back RETRYING with its attempt count ticking up — each failure pushes next_attempt_at out by 2^attempt * 10 seconds (10s → 20s → 40s → 80s → 160s).

event retrying

6. Exhausted retries → Dead Letter Queue

After the 5th failed attempt the job is marked DEAD and a row appears in the Dead Letter Queue with the failure reason and a one-click retry.

dead and DLQ

7. Fix the subscriber, retry from the DLQ

Once the endpoint is healthy again, hitting Retry re-enqueues a fresh job — it delivers on the next poll and shows green, while the original dead job and its DLQ entry stay put as an audit trail.

retry succeeded

About

Webhook Delivery Engine with Retry Backoff, DLQ, and Concurrent Fan-out

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors