A small, reliable webhook delivery service on Cloudflare Workers. You hand it an event and a destination; it delivers with retries, backs off when the destination is unhealthy, signs every request, and tells you exactly what happened.
It's the open-source shape of what Svix / Hookdeck / Convoy do — built deliberately small so the architecture is easy to read end to end.
POST /ingest ──▶ KV (event + status) ──▶ Queue ──▶ consumer ──┬─▶ deliver (HMAC-signed)
│
Durable Object per destination ◀────┤ rate limit + circuit breaker
(strongly-consistent state) │
└─▶ exhausted retries ─▶ dead-letter queue
GET /events/:id ◀── status + recent attempt log
The interesting decisions are all about failure, because delivery is the easy 5%:
- Transient vs permanent, decided explicitly. A
503/429/timeout is worth retrying; a401/404/410is not. We classify every attempt (delivery-outcome.ts) and eitherack(done / permanently failed) orretry(transient). The opposite — atry/catchthat swallows everything and silently drops or infinitely retries — is the most common way these systems rot. - Cloudflare Queues for retries + DLQ.
max_retries+dead_letter_queueinwrangler.jsoncgive backoff and a parking lot for free; we add capped exponential backoff on top. Dead-lettered events are recorded and logged, never dropped. - A Durable Object per destination (
destination.ts) holds the rate-limit token bucket and a circuit breaker — the per-destination "reputation rails." This state has to be strongly consistent and low-latency (you can't rate-limit correctly on eventually-consistent KV), which is exactly the job DOs exist for. Each instance also keeps a SQLite attempt log. - The policy is pure. Token bucket and circuit breaker are pure functions (
rate-and-circuit.ts); the DO is a thin shell that loads state, calls them, and persists. So the logic is unit-tested without a Worker runtime, and the next person can read the policy in one file. - Signed payloads. Every delivery carries an HMAC-SHA256 signature over
timestamp.body(signing.ts) so destinations can verify authenticity and reject replays. - Idempotent ingest. Re-posting the same
idreturns the existing status instead of double-sending.
# Queue an event for delivery
curl -X POST localhost:8787/ingest -H 'content-type: application/json' -d '{
"url": "https://example.com/webhook",
"secret": "whsec_your_signing_secret",
"payload": { "type": "invoice.paid", "amount": 4200 }
}'
# → { "id": "…", "status": "queued" }
# Check delivery status + recent attempts
curl localhost:8787/events/<id>The destination receives webhook-id, webhook-timestamp, and webhook-signature: v1,<hex> headers.
bun install
wrangler kv namespace create EVENTS # paste the id into wrangler.jsonc
bun run dev # local: queues, DO, and KV all run in miniflare
bun run test # unit tests for the delivery + rate/circuit policy
bun run typecheck| Path | What |
|---|---|
src/index.ts |
Hono routes (/ingest, /events/:id) + queue dispatcher + DO export |
src/queue/consumer.ts |
Delivery + dead-letter handlers; ack / retry / stop decisions |
src/do/destination.ts |
Durable Object: rate limit + circuit breaker + SQLite attempt log |
src/lib/rate-and-circuit.ts |
Pure token-bucket + circuit-breaker policy |
src/lib/delivery-outcome.ts |
Pure transient/permanent classification |
src/lib/signing.ts |
HMAC-SHA256 request signing |
This is a focused reference, not a hosted product: no multi-tenant auth, dashboard, or per-destination config UI (rates are constants in destination.ts). Those are deliberately left out so the delivery/retry/rate-limit core stays legible. The pieces that matter for reliability are all here and tested.
MIT.