The queue is a table
The delivery row is the queue item. No outbox, no broker.
Open source · v1.0
A self-hostable webhook delivery service in Go and Postgres. It fans each event out, signs it, retries for about 23 hours and records every attempt.
POST https://api.acme.example/hooks webhook-id: msg_2pQx7Lr4vK9tNcW1 webhook-timestamp: 1792486000 webhook-signature: v1,K3Yq0b6p…HMAC-SHA256…= content-type: application/json {"type":"order.paid", "timestamp":"2026-10-20T08:46:40Z", "data":{"order_id":"981","amount":4999}}
kill -9s of the dispatcher, out of 100,000 sentHow it works
No Kafka, Redis or Kubernetes. The API and the dispatchers share nothing but Postgres, so you can run as many of each as you need.
The message and one delivery row per endpoint, saved together.
Rows locked with SKIP LOCKED and a 30 s lease.
Signed with HMAC-SHA256, through an SSRF guard.
Every attempt logged. Green rows are delivered.
Failure handling
Every request carries a stable webhook-id, so a receiver can safely drop a repeat.
endpoint.disabled event.webhook-id.Fairness
Each endpoint gets at most 4 sends at once. Switch the cap off to see the outage it prevents.
One dispatcher · 16 worker slots
The slow endpoint fills its 4 slots. The other 12 keep serving everyone else. The slow endpoint's backlog takes every slot. The noisy-neighbour test fails exactly like this.
The delivery row is the queue item. No outbox, no broker.
Caps and GCRA rate limits live in the endpoint row, shared by every replica.
A dispatcher frozen past its lease cannot overwrite a newer result.
Standard Webhooks signatures for receivers, and an SSRF guard that refuses private addresses.
A repeated Idempotency-Key returns the original. Reusing it with a different body gets a 422.
Attempts are partitioned by day, and finished messages are deleted after 30 days.
Benchmarks
20 endpoints per message, a receiver that answers in 20 ms, everything on one 4-CPU VM. Every run delivered every message with no duplicates.
| Dispatchers | Burst throughput (60,000 deliveries) | Steady lag at 400/s, p50 / p99 |
|---|---|---|
| 1 | 560 deliveries/s | 9 ms / 45 ms |
| 2 | 1,056 deliveries/s | 8 ms / 21 ms |
| 4 | 1,584 deliveries/s | 8 ms / 24 ms |
New messages wake dispatchers through LISTEN/NOTIFY. With the old 250 ms poll, steady p99 was 155 to 256 ms.
4 replicas gain less because everything shared 4 CPUs. scripts/bench.sh reproduces the table.
Quickstart
Needs Docker. Starts the stack, sends one event and waits for it to arrive.
git clone https://github.com/rishirajpal01/webhook-delivery-platform
cd webhook-delivery-platform
docker compose up --build -d --wait
ctl() { docker compose exec -T webhookd webhookctl "$@"; }
# An app, and an endpoint pointing at the mock receiver
APP=$(ctl -o '{{.id}}' apps create demo)
ctl endpoints create "$APP" http://mockreceiver:9000/hooks -events order.paid \
-secret whsec_ZGVtby1zZWNyZXQtZm9yLWxvY2FsLWRldi1vbmx5ISE=
# Send an event and wait until it is delivered
MSG=$(ctl -o '{{.id}}' send "$APP" order.paid '{"order_id":"981","amount":4999}')
ctl deliveries "$APP" "$MSG" -wait 5m
For developers
VerifyRequest for receivers.Next