Try it live
Paste a URL from webhook.site or any inbox you control. The server signs your payload with HMAC-SHA256, dispatches it, and retries with jittered exponential backoff up to 3 times. Rate-limited to 3 sends per 10 minutes per IP.
Try it tip. Open webhook.site in another tab, copy your unique URL, paste it above, hit Dispatch. You'll see a real signed POST land in the inbox with
Webhook-Id,Webhook-Timestamp, andWebhook-Signatureheaders. Verify the signature using the secret returned below.
How it works (deployed TS demo)
Canonical Go architecture
Why this exists
Webhooks are one of those features that look easy until you read about it. "POST when something happens" is the trivial 5% — the other 95% is what every Stripe/GitHub/Twilio integration eventually needs: signing so receivers know it's really you, atomic delivery dequeue so two workers don't process the same event, retry with backoff so a flapping receiver doesn't make the queue grow forever, replay so you can resend after a customer's outage.
webhookd is the smallest version of that I'd actually drop into a backend.
What it does
import (
wh "github.com/PS-safe/webhookd"
"github.com/PS-safe/webhookd/memory"
"github.com/PS-safe/webhookd/dispatcher"
)
store := memory.New()
_ = store.RegisterEndpoint(ctx, wh.Endpoint{
ID: "ep1", URL: "https://customer.example.com/hook", Secret: "shh",
})
// Application side:
ev := wh.Event{ID: "evt_42", Type: "order.created", Payload: orderJSON}
_, _ = store.EnqueueDelivery(ctx, ev, ep)
// Worker side:
go dispatcher.New(store).Run(ctx)
Receivers verify with sign.Verify(...) — constant-time compare, freshness check included.
Two implementations, on purpose (again)
The Go library at PS-safe/webhookd is the canonical one:
- memory and postgres backends behind one
Storeinterface - Postgres queue uses
FOR UPDATE SKIP LOCKEDso multiple workers can run safely in parallel - Dispatcher loop: claim → sign → POST → record attempt → reschedule via retry policy or mark done
- HMAC-SHA256 with Stripe-flavored headers:
Webhook-Id,Webhook-Timestamp,Webhook-Signature: v1=<hex> - Decorrelated-jitter retry math (
retry.Defaultis 5 attempts, ~1s → 16s with jitter)
The demo above runs a synchronous TS port. Why not a real queue? Vercel serverless has no long-running workers — every request runs in isolation, then dies. Real async would need an external queue (Vercel Cron is too slow for a demo; QStash needs a new account). For a "click and see" demo, synchronous dispatch with 3 immediate retries and jittered backoff demonstrates the engineering without the infrastructure tax.
Design decisions worth noting
- Postgres queue, not Redis. Two reasons:
FOR UPDATE SKIP LOCKEDis genuinely good at this, and Redis-as-a-queue forces you into another piece of infra. If you already have Postgres, you already have a queue. - Signed payload includes id + timestamp + body, in that order, joined by
.. Receivers reconstruct exactly that and HMAC-verify. Order matters — appending the body raw without a separator means an attacker can shift bytes between fields. - Constant-time signature compare.
hmac.Equalin Go;crypto.timingSafeEqualin Node. Timing attacks on HMAC outputs are mostly theoretical, but it's the kind of habit that matters when secrets get longer. - Decorrelated jitter, not full jitter. Decorrelated jitter (random in
[base, prev*mult]) gives a better spread under load than full jitter (random in[0, exponential]) — important when you're retrying thousands of deliveries against the same downstream service. - Delivery ID is deterministic from event + endpoint.
dlv_<event_id>_<endpoint_id>— receivers can use it for idempotency dedup keys without coordinating with you. - Response body is truncated and stored for forensics. 4096 bytes is enough for most error pages without filling up Postgres.
- SSRF defense at the demo boundary. The TS port rejects
localhost,127.0.0.1,169.254.*, RFC1918 ranges, and non-http(s) schemes before dispatching. A real production system would also resolve DNS and re-check the IP — that's noted below.
Future work
- [ ] Dockerfile + Fly/Render deploy config for the Go service
- [ ] DNS-resolved SSRF guard (block dispatches that resolve to private IPs even if hostname is public)
- [ ] Dead-letter queue with manual replay
- [ ] Subscription filters (per-endpoint event-type allowlist)
- [ ] OpenTelemetry traces on every attempt
- [ ] testcontainers-go integration tests against real Postgres