Back to portfolio

Hookie: Webhook Fan-out Relay for Local Development

Open-source relay: one stable ingest URL, gRPC fan-out to developer CLIs, HMAC-faithful passthrough, and explicit flow control. Built as a narrow slice next to full managed ingress products.

Live

Hookie: webhook fan-out relay for local development

Open-source relay: one stable ingest URL, gRPC fan-out to developer CLIs, HMAC-faithful passthrough, and explicit flow control. Built as a n...

hookie.sh

Hookie is an open-source webhook fan-out relay. Four moving parts: a Next.js app for orgs, apps, and topics (Clerk, Supabase), a Go HTTP ingest service, a Go gRPC relay, and a Go CLI (hookie listen) that reconstructs each event and forwards it to a local port.

The repo is fully self-hostable (your Supabase, Clerk, Redis, ingest, and relay). There is also a public reference deployment (ingest.hookie.sh, relay.hookie.sh, and the web app) so someone can try the CLI without provisioning anything first. That hosted path is shared infrastructure for adoption, not a launched commercial product on its own.

Payments are not implemented: no checkout or Stripe flow. Usage metering is: named tiers, daily burst and quota enforcement on ingest, subscription-backed tier resolution in Postgres, recorded ingest attempts, and a usage UI. Limits are product-shaped (fairness, upgrade path, visibility), not ad hoc ops throttling, but they are not wired to money.

Problem

The pain showed up when several developers needed to exercise the same webhooks at the same time during local work. Sources such as PagerDuty, Slack, and GitHub still expose one destination URL per registration, so the team cannot point the platform at five different ngrok URLs without multiplying configuration.

Giving each developer their own tunnel is the straightforward fix, but it is brittle operationally: one webhook registration per person per integration, secrets and URLs that drift across the team, and extra work when someone leaves (finding and revoking their registrations, re-pointing URLs, deciding who owns the credentials in each product). ngrok is a good tool for the solo case; it does not remove that configuration and offboarding tax at team scale.

Other constraints stack on top: HMAC binds the payload to the exact bytes and headers on the wire, so naive forwarding breaks verification. Replay is manual or impossible if you miss an event. Visibility is often per person, so debugging becomes screen sharing.

I wanted one stable, centrally managed ingest URL and independent delivery to every developer who connects.

Scope

I mapped hosted webhook ingress products: production pipelines, retries, operator dashboards, commercial surfaces. Hookie is deliberately narrower: open source work on fan-out to developer CLIs, HMAC-faithful passthrough, and streaming with explicit backpressure, not a head-on rebuild of a full managed ingress platform. That boundary is what kept the project shippable and legible in public.

Managed tools that help you send webhooks to your customers (Svix, Convoy, and similar) solve a different problem than “many humans need the same inbound event locally.”

Solution

Hookie inverts the ngrok model. Instead of tunneling a local server outward, a single stable public endpoint receives webhooks once; the relay broadcasts each event to every connected CLI. Configuration complexity goes from O(developers × platforms) to O(platforms). Developers run hookie listen and receive the full request on a local port as if the source had called them directly.

Anonymous mode

You can run hookie listen without an account and get an ephemeral URL that ends with the session. Rate-limited and non-durable, but the first run is not a sign-up wall.

Repo-level manifest (experiment)

I tried a checked-in file (hookie.yml / hookie.yaml) that acts as a small router: which topic this codebase cares about, and which local URL should receive the reconstructed HTTP request. The goal was zero-flag hookie listen in a clone and a config object the team can review in PRs like any other dev wiring.

Whether that is the right long-term shape is still an open question. It is strong when one service maps cleanly to one forward target and you want defaults colocated with the handler code. It is weaker if forwarding rules are personal (different ports per developer), if secrets ever tempt people into the file, or if the same repo needs several independent topic-to-port mappings without a clear schema. Flags and environment overrides may need to stay first-class so the manifest stays optional, not mandatory.

Scope in the CLI

Subscribe to one topic, all topics in an app, or all apps in an org. Same command, different noise profile for a single integration versus a cross-system incident.

CLI-first

The web UI manages metadata; the primary workflow is the terminal (method, path, headers, body formatted locally).

Technical design

The web app is not on the hot path. Ingest to relay is Go and Redis, which keeps latency and failure modes simpler than routing live traffic through Next.js.

Ingest accepts the raw HTTP request, validates the topic (Supabase lookup cached in Redis), applies tiered rate limits and payload limits, then XADDs to a stream keyed by topics:{topicID}. The handler reads the body from io.Reader before any parsing, and publishes method, path, query, headers, body (base64), content type, content length, remote addr, and a nanosecond timestamp.

Relay reads from streams (XREAD), tracks active gRPC streams, and pushes each message to every client subscribed to that topic (or a parent app or org). The relay is otherwise stateless; reconnect logic lives in the client.

CLI opens a bidirectional gRPC stream, sends a subscribe request (machine id and scope), then receives Event messages. It reconstructs http.Request and POSTs to the configured local URL. After handling, it sends a Ready message so the relay can send the next event. That is the application-level flow-control hook.

Go and gRPC. I picked Go because gRPC and cheap concurrency looked like the best fit for a long-lived relay and many streaming clients, plus a small static CLI binary with no runtime install story. gRPC beat WebSockets or SSE here because the contract is typed (a SubscribeMessage oneof for subscribe vs ready), flow control is explicit in the proto, and cmux serves gRPC and HTTP health on one port for simpler deploy config.

In day-to-day work I am primarily a TypeScript developer; Hookie is where I went deep on Go idioms, tooling, and failure modes. The control-plane app stays in the stack I already ship (Next.js).

Constraints I accepted

  • Single Redis region in the reference setup. Multi-region fan-out was not a design goal.
  • At-least-once at the stream, not an end-to-end delivery guarantee to a disconnected CLI. Consumer groups for stricter semantics are not fully leaned on; 48-hour retention helps recovery but is not a productized replay surface yet.
  • CLI-shaped consumers. The relay is not aimed at fan-out to many server-side HTTP endpoints.

Hard Parts

Preserving HMAC signatures through the relay

Providers sign the raw payload. If the relay re-encodes JSON, tweaks headers, or changes content length, local verification fails.

I treated this as an invariant from the start: capture bytes and headers on ingest, forward them without normalization, reconstruct on the CLI. No JSON parsing on the hot path for signature-sensitive bodies. The CLI sees the same octets the source signed.

Backpressure and the Ready signal

A busy source can outpace a slow local handler. Unbounded buffering in the CLI would hide the problem until memory blows up.

The proto models explicit readiness: the relay does not assume the client can take another event until it receives Ready. That pushes backpressure to a clear boundary and leaves room for relay-side policies (caps, leaky bucket, per-consumer credit) without changing the wire shape.

Redis Streams still matter for durability and ordering between ingest and relay. I underestimated how deep that surface area is: consumer groups, pending entry lists, trimming, and behavior under relay restart each have edge cases I would map earlier next time.

Tradeoffs: what I did not build

Production-grade delivery policy (DLQ, retry schedules, exponential backoff). For a dev tool where someone is watching the terminal, a missed event while disconnected is acceptable scope; full operator semantics would have ballooned the relay.

Payment-backed commercialization. Plans and metering exist as data and enforcement; the missing piece is a processor and priced SKUs, not “counting requests.”

Low-friction self-host of the dashboard. Clerk plus Supabase was the fastest path to a credible multi-user control plane with RLS-backed Postgres. It is not the smallest OSS footprint; a slimmer control plane would be the tradeoff if anonymous self-host were the primary adoption path.

Rich observability beyond structured logs (JSON for hosted log parsers): no metrics, tracing, or replay UI yet.

Outcome

I dogfooded the fan-out model with an incident response team at a major telco. My friend Cody collaborated on Hookie; I did the bulk of the work and owned it end to end (design through operating the relay for that rollout). The team used real PagerDuty and Slack traffic: one ingest URL per source, and every developer who ran the CLI received the same events without per-person tunnel URLs or extra webhook registrations in those products. That matched the operational claim that configuration stays O(platforms) as more people join instead of O(developers × platforms). I did not run formal benchmarks or keep publishable metrics for that rollout; the evidence was how we worked (shared registrations, less URL churn).

Here is what that looked like in practice:

Webhook configuration

One URL per source

PagerDuty and Slack each pointed at a single stable ingest URL. Developers ran the CLI locally instead of maintaining parallel tunnel URLs or separate registrations per person.

What we saw

Same events at every CLI

Everyone who connected received the same payloads and headers the sources sent, so local verification behaved like a direct delivery.

Open source, with a public instance, lowers the cost of the first working URL. It is approaching a broader public launch; the interesting slice remains the relay and CLI mechanics in code, not a proprietary clone of mature hosted ingress.

What I would do differently

I would spend more time up front on Redis Streams failure modes (consumer groups, acknowledgment semantics, trimming, restart under load) before the durability story hardened in code.

Replay alongside listen. Streams already retain roughly 48 hours with timestamp-leaning IDs; the data model supports replay, but the CLI command to replay a range is not shipped yet. Wiring that next to listen would have answered “what if I missed an event?” with a product answer instead of a roadmap answer.

Backpressure as a first-class design doc, not only a protocol hook. The Ready signal was the right interface; I would still document end-to-end behavior (relay vs Redis vs CLI) earlier to reduce rework.

What I would do the same

The architectural inversion: pull events through one ingest URL and broadcast down, instead of multiplying tunnels and webhook registrations per developer.

HMAC preservation as a hard rule: raw bytes and headers through ingest and relay, reconstruct locally. No “fix signatures later.”

gRPC with bidirectional streaming and an explicit Ready signal so flow control is part of the contract, not ad hoc JSON over a raw WebSocket.

Anonymous first run so evaluation starts with a working URL, not a mandatory sign-up.

What I would do next

Ship replay from retained stream data so missed events are recoverable without re-triggering the source.

Commit the Redis consumer story after stress tests: caps when consumers lag, clearer semantics on restart, and how that interacts with Ready at the CLI.

Add observability: event history, payload inspection, latency per consumer, and eventually a small UI for operators.

Explore agent-friendly CLI flows (listen, capture, replay) so automated helpers can scaffold handlers from real payloads.


  • Developer tools
  • gRPC
  • Open source
  • Webhooks
Made with ❤️ in 🇨🇦 · Copyright © 2026 Valentin Prugnaud
Foxy seeing you here!
Wondering if I'd fit your role?
Logo