Skip to content

Latest commit

 

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

⛓ HookRelay

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

Live inspector — streaming feed with signature and delivery status

Event detail — payload capture and per-attempt timeline with retries Dead letters view — exhausted deliveries with one-click redelivery

Features

  • 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 → 15m by 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-Id for downstream dedupe. At-least-once, honestly documented (ADR-006).
  • Observability — Prometheus metrics from all services, pre-provisioned Grafana dashboard, optional Sentry error capture.

Quick start

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 --build

First 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

Two-minute demo

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 stripe

Open the inspector, watch events stream in, click one, watch the attempts pile up, then visit Dead letters and hit redeliver.

Architecture

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.

Development (outside Docker)

# 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 dev

Tests

cd services/api && mvn test            # signatures, backoff tiers, fan-out, consumer
cd services/transformer && npm test    # sandbox contract, timeouts, parse fallback

No local Maven? docker run --rm -v "$PWD/services/api":/app -w /app maven:3.9-eclipse-temurin-21 mvn test

Configuration

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).

Adding a new webhook provider

The extensibility path is deliberately small — one enum value, one class:

  1. Add the enum value — services/api/src/main/java/io/hookrelay/api/domain/Provider.java:
    public enum Provider { STRIPE, GITHUB, SHOPIFY, GENERIC }
  2. Implement SignatureVerifier as a Spring bean — e.g. Shopify signs the raw body with HMAC-SHA256, base64, in X-Shopify-Hmac-Sha256:
    @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");
        }
    }
    That's it — the SignatureVerifierRegistry discovers every SignatureVerifier bean automatically.
  3. (Optional) dedupe key — add a case in IngestService.extractDedupeKey if the provider ships a unique event id (Shopify: X-Shopify-Webhook-Id).
  4. (Optional) UI label — add the option in services/inspector/src/pages/EndpointForm.jsx.
  5. Test it — copy GitHubSignatureVerifierTest and 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.

Security notes (honest edition)

  • 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_basic in nginx/nginx.conf is a 3-line change). API keys are the top roadmap item.
  • Transforms run sandboxed but node:vm is 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).

Roadmap

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.

Why I built this

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.

License

MIT

About

Self-hostable webhook gateway with signature verification, retries, and a live inspector

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages