Self-hostable webhook gateway — receive webhooks from any provider, inspect them live, verify their signatures, fan them out to multiple destinations with exponential-backoff retries, and never lose one to the void: exhausted deliveries land in a visible, replayable dead-letter queue.
Webhooks are the worst kind of distributed-systems problem hiding inside a mundane feature: unreliable senders, unreliable receivers, signatures over raw bytes, retries you can't see, and failures you can't reproduce. HookRelay puts a debuggable, durable relay in the middle.
Stripe / GitHub / anything ──► Nginx ──► Spring Boot API ──► RabbitMQ ──► destinations
│ ▲ │ (tiered TTL retry queues,
MongoDB ┘ └── Node.js ◄──┘ dead-letter exchange)
transform worker
React live inspector ◄── SSE
Grafana ◄── Prometheus · Sentry (SDK) · one docker compose up
- Ingest anything — per-endpoint URLs (
/ingest/{slug}) accepting any content type; raw bytes captured for inspection and signing. - Signature verification — Stripe (
Stripe-Signature, timestamp tolerance), GitHub (X-Hub-Signature-256), and a configurable generic HMAC-SHA256 scheme. Constant-time comparisons. Invalid events are quarantined and visible, not silently dropped (ADR-004). - Live inspector — SSE-streamed feed of incoming events; headers, payload, signature status, per-destination delivery attempts with latency and response snippets, retry countdowns.
- Fan-out — one event → N destinations, each with its own delivery lifecycle, optional custom headers, and optional payload transform.
- Retries with real backoff — tiered RabbitMQ TTL queues + dead-letter exchanges; the broker itself schedules redelivery (no pollers). Delays configurable (
5s → 15s → 1m → 5m → 15mby default). - Dead-letter queue — exhausted or transform-failed deliveries are marked, graphed, listed in the dashboard, and redeliverable in one click.
- Payload transforms — operator-authored JavaScript run in a sandboxed Node.js worker, decoupled from the delivery path.
- Idempotency — provider-derived dedupe keys enforced by a partial unique index; idempotent consumers;
X-HookRelay-Delivery-Idfor downstream dedupe. At-least-once, honestly documented (ADR-006). - Observability — Prometheus metrics from all services, pre-provisioned Grafana dashboard, optional Sentry error capture.
Prereqs: Docker with Compose v2. Nothing else — Java/Node toolchains only needed for development.
git clone <this repo> && cd HookRelay
cp .env.example .env # optional; defaults work for a local demo
docker compose -p hookrelay up -d --buildFirst build takes a few minutes (Maven + npm inside Docker). Then:
| URL | What |
|---|---|
| http://localhost:8080 | Live inspector (dashboard) |
| http://localhost:8080/ingest/{slug} | Webhook ingest endpoints |
| http://localhost:3000 | Grafana (admin / admin) → "HookRelay — Overview" |
| http://localhost:15672 | RabbitMQ management (hookrelay / hookrelay) |
| http://localhost:9090 | Prometheus |
The stack seeds three demo endpoints pointed at a built-in sandbox destination
(disable with HOOKRELAY_SEED_DEMO=false):
| Endpoint | Shows off |
|---|---|
demo-generic |
happy path + a 50%-flaky destination → watch retries with backoff |
demo-stripe |
signature verification + a JS payload transform |
demo-github |
destination that always fails → dead-letter queue in ~20s |
Send signed webhooks (secrets match the seeds):
# Windows
.\scripts\send-test-webhook.ps1 -Provider stripe
.\scripts\send-test-webhook.ps1 -Provider github # → watch it die into the DLQ
.\scripts\send-test-webhook.ps1 -Provider generic -Count 10
.\scripts\send-test-webhook.ps1 -Provider stripe -Tamper # → REJECTED, visible in inspector# Linux / macOS
./scripts/send-test-webhook.sh stripe
./scripts/send-test-webhook.sh github
./scripts/send-test-webhook.sh generic 10
TAMPER=1 ./scripts/send-test-webhook.sh stripeOpen the inspector, watch events stream in, click one, watch the attempts pile up, then visit Dead letters and hit redeliver.
Full details in docs/architecture.md (data flow, queue topology, reliability model), docs/data-model.md (MongoDB collections & indexes) and docs/decisions.md (ADRs — including why RabbitMQ and not Kafka, why a monorepo, why tiered retry queues).
The one-paragraph version: the Spring Boot API verifies and persists every
incoming webhook, then fans out one durable RabbitMQ message per destination.
A consumer (same service) performs the outbound HTTP call; failures republish
into per-tier TTL queues whose dead-letter exchange routes back to the
delivery queue — the broker is the retry scheduler. Destinations with a
transform detour through a Node.js worker that runs the operator's JS in a
node:vm sandbox. MongoDB is the system of record (events, deliveries,
attempt history); replay and redelivery are driven from it. The React
inspector gets everything live over SSE.
# infra only
docker compose -p hookrelay up -d mongodb rabbitmq
# API — http://localhost:8080 (needs guest access or RABBITMQ_* env vars)
cd services/api && mvn spring-boot:run
# worker
cd services/transformer && npm install && npm start
# inspector — http://localhost:5173 (proxies /api to :8080)
cd services/inspector && npm install && npm run devcd services/api && mvn test # signatures, backoff tiers, fan-out, consumer
cd services/transformer && npm test # sandbox contract, timeouts, parse fallbackNo local Maven? docker run --rm -v "$PWD/services/api":/app -w /app maven:3.9-eclipse-temurin-21 mvn test
All via environment (see .env.example). Highlights:
| Variable | Default | Meaning |
|---|---|---|
HOOKRELAY_RETRY_DELAYS_MS |
5000,15000,60000,300000,900000 |
Backoff tiers; count = number of retry queues. Applied at boot (queue TTLs are fixed at declaration — change requires empty retry queues or a broker reset). |
HOOKRELAY_MAX_ATTEMPTS |
4 (compose) |
Total attempts incl. the first; per-endpoint override in the UI. |
HOOKRELAY_RETENTION_DAYS |
0 (forever) |
TTL indexes on events/deliveries when > 0. |
HOOKRELAY_SEED_DEMO |
true (compose) |
Seed demo endpoints on first boot. |
HOOKRELAY_SANDBOX |
true |
Built-in fake destination /api/sandbox/target?failRate=…. |
SENTRY_DSN_API / SENTRY_DSN_WORKER |
empty | Enable Sentry error capture (ADR-005). |
The extensibility path is deliberately small — one enum value, one class:
- Add the enum value —
services/api/src/main/java/io/hookrelay/api/domain/Provider.java:public enum Provider { STRIPE, GITHUB, SHOPIFY, GENERIC }
- Implement
SignatureVerifieras a Spring bean — e.g. Shopify signs the raw body with HMAC-SHA256, base64, inX-Shopify-Hmac-Sha256:That's it — the@Component public class ShopifySignatureVerifier implements SignatureVerifier { @Override public Provider provider() { return Provider.SHOPIFY; } @Override public VerificationResult verify(byte[] rawBody, Map<String, String> headers, EndpointConfig endpoint) { if (endpoint.secret == null || endpoint.secret.isBlank()) return VerificationResult.noSecret(); String header = headers.get("x-shopify-hmac-sha256"); if (header == null) return VerificationResult.noSignature("missing x-shopify-hmac-sha256"); String expected = Base64.getEncoder().encodeToString( HmacUtil.hmacSha256(endpoint.secret, rawBody)); // add a bytes variant return HmacUtil.constantTimeEquals(expected, header) ? VerificationResult.valid() : VerificationResult.invalid("signature mismatch"); } }
SignatureVerifierRegistrydiscovers everySignatureVerifierbean automatically. - (Optional) dedupe key — add a case in
IngestService.extractDedupeKeyif the provider ships a unique event id (Shopify:X-Shopify-Webhook-Id). - (Optional) UI label — add the option in
services/inspector/src/pages/EndpointForm.jsx. - Test it — copy
GitHubSignatureVerifierTestand adapt (5 cases, no containers needed).
Rules that keep verifiers correct: verify over raw bytes (never
re-serialized JSON), compare constant-time (HmacUtil.constantTimeEquals),
and return NO_SECRET rather than throwing when unconfigured.
- Ingest is protected by HMAC verification — that's the boundary webhooks actually rely on. Rejected events are stored (quarantined) and reported 400.
- The management API/dashboard has no auth in the MVP (ADR-007):
run it on a private network or add auth at Nginx
(
auth_basicinnginx/nginx.confis a 3-line change). API keys are the top roadmap item. - Transforms run sandboxed but
node:vmis a fault boundary, not a jail (ADR-008) — fine for single-tenant self-hosting, not for untrusted multi-tenant code. - Secrets are write-only through the API (masked to last 4 chars on read).
API-key auth for the management plane · per-destination signing of forwarded requests · SSRF guards for destination URLs · multi-replica SSE via broker fan-out · isolated-vm/WASM transforms · provider presets (Shopify, Twilio, Slack) in the UI.
Every integration I've debugged has the same blind spot: a webhook fails somewhere between provider and consumer and there's nothing to look at — no payload, no attempt history, no way to replay it. I built HookRelay to be that missing middle, and to prove I could design a delivery pipeline with real guarantees instead of just wiring a queue to an HTTP call. The hardest part was the retry topology — RabbitMQ has no "redeliver this in 30s" primitive, and tiered TTL queues dead-lettering back into the delivery queue turned out to be the clean answer. The most satisfying moment: the end-to-end test caught a real bug (a stats endpoint silently overwriting its own total) before any user could.
MIT


