webhook-delivery-platform

Open source · v1.0

Send an event once. Every endpoint gets it.

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.

  • Go 1.25
  • Postgres only
  • Standard Webhooks
  • OpenAPI 3.1
  • Apache-2.0
example deliveryorder.paid → 1 endpoint
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}}
  1. #1503retry in 5 s, ±25% jitter
  2. #2200delivered, same webhook-id
messages lost across 8 kill -9s of the dispatcher, out of 100,000 sent
0
burst throughput with 4 dispatchers, all on one 4-CPU VM
1,584 /s
healthy endpoints' p99 lag while a 10-second endpoint is backlogged
~30 ms
p99 lag at a steady 400 deliveries/s, from send call to arrival
24 ms

How it works

One Go binary, one Postgres database

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.

Your product sends once API 1 transaction Postgres Dispatcher claim · sign Endpoint A 200 OK Endpoint B 200 OK Endpoint C 200 OK
  1. Accept

    The message and one delivery row per endpoint, saved together.

  2. Claim

    Rows locked with SKIP LOCKED and a 30 s lease.

  3. Send

    Signed with HMAC-SHA256, through an SSRF guard.

  4. Record

    Every attempt logged. Green rows are delivered.

See the full architecture diagram
Architecture: your product sends to the webhookd API, which writes the message and its deliveries to Postgres in one transaction. Dispatchers claim deliveries with SKIP LOCKED and a lease, record each attempt in a day-partitioned log, and send signed requests through an SSRF guard to receiver endpoints.

The interactive version links every box to its source code.

Failure handling

Delivered at least once, even through crashes

Every request carries a stable webhook-id, so a receiver can safely drop a repeat.

10 s 1 min 10 min 1 h 6 h 24 h send 1 2+5 s 3+30 s 4+2 m 200 OK 5+10 m 6+30 m 7+1 h 8+3 h 9+6 h 10+12 h time since send (log scale)
Retries back off over about 23 hours. Here attempt 4 lands, so 5 to 10 never run. Each delay gets ±25% jitter.
Delivery row in flight pending succeeded lease lease expired → back to pending Dispatcher A claims, sends… ✕ kill -9 Dispatcher B picks it up Endpoint 200 OK
A crash mid-send loses nothing. The lease runs out and another dispatcher sends it. Tested with 100,000 messages and 8 kills: 0 lost.
Circuit breaker
10 straight failures pause an endpoint. A probe reopens it.
Retry-After
Honoured on 429 and 503, up to 24 hours.
Auto-disable
After 5 days of failure, with an endpoint.disabled event.
Pause
A paused endpoint keeps queueing and catches up on resume.
Replay
Resend any delivery with the same webhook-id.

Fairness

One slow customer can't stall everyone else

Each endpoint gets at most 4 sends at once. Switch the cap off to see the outage it prevents.

One dispatcher · 16 worker slots

Slow endpoint
answers in 10 s, 500 queued
9 healthy endpoints
p99 lag: about 30 ms waiting behind the backlog

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 queue is a table

The delivery row is the queue item. No outbox, no broker.

Limits without Redis

Caps and GCRA rate limits live in the endpoint row, shared by every replica.

The lease is a fencing token

A dispatcher frozen past its lease cannot overwrite a newer result.

Safe in both directions

Standard Webhooks signatures for receivers, and an SSRF guard that refuses private addresses.

Idempotent sends

A repeated Idempotency-Key returns the original. Reusing it with a different body gets a 422.

Bounded storage

Attempts are partitioned by day, and finished messages are deleted after 30 days.

Benchmarks

Measured, with the setup stated

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.

DispatchersBurst throughput (60,000 deliveries)Steady lag at 400/s, p50 / p99
1560 deliveries/s9 ms / 45 ms
21,056 deliveries/s8 ms / 21 ms
41,584 deliveries/s8 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

A verified delivery in under a minute

Needs Docker. Starts the stack, sends one event and waits for it to arrive.

sh
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

The spec is the source of truth

  • OpenAPI 3.1 with contract tests against the server and SDK.
  • Go SDK with VerifyRequest for receivers.
  • webhookctl CLI and Prometheus metrics.
  • Configurable: retries, circuit breaker, lease and connection pool are environment settings.

Next

What I would build next

  • Opt-in FIFO per endpoint.
  • Separate-machine benchmarks to find the real ceiling.