A developer-first webhook gateway: verify → transform → filter → reliably fan-out.
Every SaaS sends webhooks. Almost none make them pleasant to receive: payload shapes differ per provider, signature schemes vary, there are no retries, and a single downstream hiccup drops events silently. HookForge sits in front of your service and fixes all of that with a small, transparent, fully-tested core.
Provider ──▶ HookForge ──▶ your service(s)
│ 1. verify HMAC signature (replay-safe)
│ 2. filter (declarative predicates)
│ 3. transform (declarative, no code-exec)
│ 4. fan-out with retries + idempotency
Status: Production-hardening (Phase 7 + review-driven bugfix pass). 208 passing tests, ~92% statement coverage, lint clean. Pure-logic core with zero network dependencies in the test suite. PostgreSQL persistence, a durable delivery queue (202 + background retries), API-key auth, provider signature presets (Stripe / GitHub / Shopify), CORS, and a React dashboard. Hardened against prototype pollution and filter ReDoS, with release-on-failure idempotency, guarded async routes, bounded memory, paginated delivery logs, and graceful shutdown.
- Reliable delivery — exponential backoff + full jitter, capped, with smart retry classification (5xx/429/408 retry; other 4xx are permanent).
- Idempotent fan-out — the same logical event is never delivered twice to the same destination, even across retries.
- Safe transforms — a declarative, whitelisted spec (map/rename/pick/omit/
template/…). There is no
eval. User-supplied rules can't execute code. - Signature verification — Stripe-style HMAC-SHA256 over
${timestamp}.${body}, timing-safe comparison, and a replay window. - Portable & self-hostable — one runtime dependency (Express). Core is plain, side-effect-free JavaScript.
npm install
npm test # 131 tests
npm start # boots the gateway on :3000curl -X POST localhost:3000/endpoints -H 'content-type: application/json' -d '{
"name": "stripe-prod",
"secret": "whsec_...",
"filter": { "field": "type", "op": "eq", "value": "charge.succeeded" },
"transform": [
{ "op": "rename", "from": "data.object.amount", "to": "total" },
{ "op": "set", "to": "source", "value": "stripe" }
],
"destinations": ["https://api.myapp.com/webhooks/payments"]
}'POST /ingest/:endpointId
x-hookforge-timestamp: <unix seconds>
x-hookforge-signature: <hex HMAC-SHA256 of `${timestamp}.${rawBody}`>
HookForge verifies the signature, applies the filter and transform, then delivers to every destination with retries — recording each attempt.
Zero network. Sign, verify, transform, and test filters locally:
node bin/hookforge.js sign --secret sec --body '{"id":"evt_1","amount":500}'
node bin/hookforge.js transform --spec '[{"op":"rename","from":"amount","to":"total"}]' --body '{"amount":500}'
node bin/hookforge.js filter --filter '{"field":"amount","op":"gt","value":100}' --body '{"amount":500}'
node bin/hookforge.js verify --secret sec --body @event.json --timestamp 1700000000 --signature <sig>Ops run as a pipeline over a single running document (deep-cloned from the payload):
| op | shape | effect |
|---|---|---|
pick |
{ op, fields: ["a","b.c"] } |
keep only these paths |
omit |
{ op, fields: ["secret"] } |
drop these paths |
rename |
{ op, from: "a.b", to: "x" } |
move a value |
copy |
{ op, from: "a", to: "b" } |
duplicate a value |
set |
{ op, to: "meta.src", value: "stripe" } |
write a static value |
default |
{ op, to: "currency", value: "usd" } |
write only if missing |
template |
{ op, to: "msg", template: "Order {{id}}" } |
interpolate {{path}} tokens |
Leaf: { field, op, value } with ops eq, neq, gt, gte, lt, lte, in, nin, exists, contains, startsWith, endsWith, matches. Compose with { and: [...] },
{ or: [...] }, { not: <node> }. A null filter matches everything.
| Method | Path | Purpose |
|---|---|---|
| GET | /health |
liveness + stats |
| POST | /endpoints |
create endpoint |
| GET | /endpoints |
list (secrets redacted) |
| GET | /endpoints/:id |
fetch one |
| PATCH | /endpoints/:id |
update |
| DELETE | /endpoints/:id |
remove |
| GET | /endpoints/:id/deliveries |
delivery log |
| POST | /ingest/:id |
receive a webhook |
- Persistence —
PgStore(PostgreSQL) behind the same interface as the in-memory store; the server awaits every store call so they are interchangeable. SetDATABASE_URLto switch on Postgres mode. - Durable delivery —
/ingestverifies/filters/transforms synchronously, then enqueues delivery jobs and returns 202 Accepted. A worker delivers with durable retry/backoff and dead-letters exhausted jobs.InMemoryQueue(dev/tests) andPgBossQueue(prod) share oneenqueue()surface. - API-key auth — management routes require
x-api-key(orAuthorization: Bearer);/healthand/ingest/*are exempt (webhooks self-authenticate). Configure withHOOKFORGE_API_KEYS=key1,key2. - Provider presets — per-endpoint
provider:hookforge(default),stripe,github,shopify— each with its correct signature scheme + headers. - CORS — configurable via
HOOKFORGE_CORS_ORIGINS(default*). - Dashboard —
frontend/Vite + React SPA: manage endpoints and browse the delivery log.cd frontend && npm install && npm run build.
src/core/ pure logic (no network): signature, providers, transform, filter,
retry, idempotency, delivery, pipeline, httpTransport
src/store/ pluggable storage: MemoryStore + PgStore (shared validators)
src/queue/ durable queue: InMemoryQueue + Worker, PgBossQueue (prod)
src/api/ Express glue (thin): server, auth, cors
bin/ CLI playground
frontend/ Vite + React dashboard
test/ 179 Jest + Supertest tests
Design notes and every autonomous decision are recorded in
decision_log.md; the studio pipeline that produced this
lives in docs/ and project_board.md.
MIT © Igor Zommer