A small, self-hosted relay that durably captures inbound webhooks (Render to start), verifies their signatures, and re-delivers them to one or more developer environments — either pulled over Server-Sent Events or pushed to a registered URL — including replay of anything missed while disconnected.
To get started: hooks init.
Walks you from a fresh checkout to a real Render webhook landing on your laptop. About ten minutes. For the production-deployment version of this flow, see docs/quickstart.md.
- Go 1.25+ (for
make build). - A Render account with a service or other resource that emits webhooks (a deploy is the easiest trigger).
- A tunnel that gives you a public HTTPS URL pointing at
localhost:8080. Render refuses plain HTTP and will not POST to a non-public address. Pick one:cloudflared tunnel --url http://localhost:8080ngrok http 8080- any reverse proxy you already have on a real domain
git clone https://github.com/onebusaway/hooks
cd hooks
make buildYou now have ./bin/hooks (the relay) and ./bin/hooksctl (the developer CLI).
./bin/hooks initThis writes hooks.yaml, creates hooks.db, and prints an admin token once. Copy it now — there is no way to recover it later. On a fresh DB it also prints a one-time signup URL (24-hour TTL) so the first human can claim an admin account through /signup.
admin token (shown ONCE): <long base64 string>
signup: http://localhost:8080/signup?code=<code> (24h, single-use)
Export it for hooksctl:
export HOOKS_TOKEN=<paste the admin token>In the Render dashboard, create a new webhook. For the URL, put a placeholder for now (e.g. https://example.invalid/ingest/render) — you will update it in step 5 once the tunnel is running. Render will display a signing secret; copy it.
export RENDER_WEBHOOK_SECRET=<the signing secret Render showed you>The default hooks.yaml already references this env var:
sources:
render:
verifier: render
secret: ${RENDER_WEBHOOK_SECRET}
retention: 30d./bin/hooks --dev--dev enables verbose logging, opens the inspector in your browser, and prints the URLs you'll need:
inspector: http://localhost:8080/
ingest: http://localhost:8080/ingest/render
forward: hooksctl forward render --to http://localhost:3000/webhooks/render
The inspector tab will land on /login. To claim your admin account, open the signup URL from step 2 (http://localhost:8080/signup?code=...) in the same browser, pick an email + password, and you'll be signed in to the inspector. The HOOKS_TOKEN you exported is for hooksctl, not the browser.
Leave the relay running.
In a second terminal:
cloudflared tunnel --url http://localhost:8080Copy the https://<random>.trycloudflare.com URL it prints. Back in the Render dashboard, edit your webhook and set its URL to:
https://<random>.trycloudflare.com/ingest/render
In a third terminal, point hooksctl forward at whichever local service you're developing:
./bin/hooksctl forward render --to http://localhost:3000/webhooks/renderforward first replays any events you missed (none on first run), then tails live. Bytes hitting your local app are byte-for-byte identical to what Render sent — original headers preserved.
The fastest trigger is a redeploy of any Render service: Manual Deploy → Deploy latest commit. Other event types (suspends, scaling) work too.
You should see, in order:
- A
POST /ingest/renderlog line in thehooks --devterminal. - A new row in the inspector at
http://localhost:8080/(sign in with the admin email/password you set duringhooks init). - A
POST /webhooks/renderarriving at your local app via thehooksctl forwardterminal.
If you want a permanent consumer instead of an SSE pull session:
./bin/hooksctl push add --source render --to https://my-svc.example.com/hooks --name productionThis prints a per-subscription signing secret once — store it on your consumer. The relay will sign every push with X-Hooks-Signature: t=<unix>,v1=<hmac-sha256(secret, "<unix>.<body>")>. See docs/consumer-verification.md for verification snippets in several languages.
A Dockerfile and render.yaml Blueprint are checked into the repo. The image is a multi-stage build (Go builder → small Alpine runtime), runs as a non-root user, and exposes /data as a volume for the SQLite database.
make docker-build # builds hooks:dev locally
mkdir -p ./hooks-data
docker run --rm -v $(pwd)/hooks-data:/data hooks:dev init
docker run --rm -p 8080:8080 \
-v $(pwd)/hooks-data:/data \
-e RENDER_WEBHOOK_SECRET \
hooks:devFor a Render Blueprint deploy, push the repo and point Render at render.yaml — it provisions a 1 GiB persistent disk at /data and wires /readyz as the health check. See docs/quickstart.md for the full container walkthrough.
If your team already runs a hooks instance (Render or anywhere else) and you just need a CLI on your laptop, skip the first six steps. Either an admin sends you a signup URL (https://hooks.example.com/signup?code=...), or your relay was just deployed and an admin used the bootstrap link to create their account first. Then:
hooksctl login --server https://hooks.example.com
hooksctl forward render --to http://localhost:3000/webhooks/renderlogin prints a short user code, opens the relay's /device page in your browser, asks you to log in and re-enter your password to approve the pairing, then writes a PAT to ~/.config/hooks/credentials.default. forward uses that PAT — no further token plumbing.
See docs/accounts.md for the full walkthrough (scopes, admin operations, multiple profiles, ephemeral vs long-lived listener tokens, deactivation semantics).
- HTTP 401 in the relay logs — secret mismatch between
RENDER_WEBHOOK_SECRETand what Render is signing with. Re-copy the signing secret from the Render dashboard. - HTTP 404 — the URL path is wrong; it must end in
/ingest/render. - No request reaches the relay at all — confirm the tunnel URL works in your browser (
/healthzshould returnok), and that you saved the updated URL in the Render dashboard. - The
--devbrowser tab won't authenticate — open the signup URL printed byhooks initto claim an admin account first; the inspector signs you in with email/password, not the admin token.
(c) Open Transit Software Foundation and made available under the Apache 2.0 license.