Receive events, fan out to N subscribers concurrently, retry with exponential backoff, route failures to a Dead Letter Queue.
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 │
└──────────────────┘
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 upThe 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.
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/dlqManually retry a dead delivery:
curl -X POST http://localhost:8787/dlq/<dlq-entry-id>/retryCheck system health and queue depth:
curl http://localhost:8787/health- Why MySQL +
SKIP LOCKEDinstead of Kafka/RabbitMQ: removes a dependency, MySQL is already required, andSKIP LOCKEDgives lock-free concurrent dequeue with zero additional infra. - Exponential backoff formula:
2^attempt * 10seconds (10s → 20s → 40s → 80s → 160s), capped at 5 attempts before a job is routed to the DLQ. - Idempotency:
idempotency_keyhas aUNIQUEconstraint at the DB level, so duplicate ingestion returns the original event without re-enqueuing deliveries. - Delivery signing: each request is signed with
HMAC-SHA256over the raw payload using the subscription'ssecret, sent asX-Webhook-Signature, so subscribers can verify authenticity.
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.
1. Empty console
Nothing subscribed, nothing in flight, DLQ empty.
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.
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.
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).
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.
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.







