A self-hosted webhook receiver, inspector and relay. Point a provider at it, and every delivery is verified, stored whole, forwarded with retries, and visible in a live dashboard you can replay from.
git clone https://github.com/vinkurov/hookshelf.git
cd hookshelf
docker compose upThen open http://127.0.0.1:3000, create an endpoint, and send it something.
You are integrating a webhook and something is wrong. The usual tools each answer half the question:
- A public tunnel shows you the request, but it is a third party holding your payloads, it needs the internet, and it forgets everything when you close the tab.
- Your application's logs show what your code did with the request, but not the bytes that arrived — and the bytes are what the signature covers.
- Your provider's dashboard shows what it sent, some of the time, for a few days.
hookshelf sits in front of your service and keeps the evidence. It runs on your machine or your network, stores every delivery in a SQLite file you own, and answers the two questions that actually come up: what exactly arrived, and what happened to it afterwards.
It is useful in three shapes:
| Inspector | An endpoint with no destination. Deliveries are captured and shown, nothing is forwarded. This is the "why does my signature check fail" mode. |
| Relay | An endpoint with a destination. Deliveries are verified, deduplicated, then forwarded with retries and a dead-letter state. |
| Replay | Any stored delivery can be sent again as a new one, byte for byte. The original and its history are left untouched. |
docker compose up -dCreate an endpoint that verifies GitHub signatures and forwards to your app:
curl -X POST http://127.0.0.1:3000/api/endpoints \
-H 'content-type: application/json' \
-d '{
"name": "github-staging",
"provider": "github",
"secret": "your-webhook-secret",
"forwardUrl": "http://localhost:4000/hooks/github"
}'
# → {"id":"f4080sjvz3v6tfd5", ... ,"hasSecret":true}Point GitHub at http://your-host:3000/in/f4080sjvz3v6tfd5, or just send it something yourself:
curl -X POST http://127.0.0.1:3000/in/f4080sjvz3v6tfd5 \
-H 'content-type: application/json' \
-d '{"hello":"world"}'Both the dashboard and the API will show it immediately — including, in this case, that it failed verification, because that request was not signed. The delivery is stored anyway. That is the point: you cannot debug a request you threw away, and "my signature check fails and I don't know why" is the single most common reason to reach for a tool like this. The sender still gets the failure status, so hookshelf behaves correctly as a receiver — but the evidence survives.
Signature verification is done by webhook-kit, so hookshelf verifies everything it does:
stripe · github · shopify · slack · standard-webhooks · paddle · twilio · telegram
standard-webhooks covers every service implementing Standard Webhooks — Svix, Clerk, Resend and others. Omit provider and secret entirely to capture without verifying.
Everything below is a decision that could have gone the other way. If you are reading the code, these are the parts worth understanding first.
An inbound body is read as bytes and stored as bytes. It is never handed to JSON.parse, and never re-serialised.
This is not a performance choice. A signature covers the exact bytes on the wire, and JSON.stringify(JSON.parse(body)) is not byte-identical to body — key order, whitespace and number formatting all move. A payload that had been through a JSON round trip could not be re-verified on replay, which would make the replay feature quietly useless for the case it exists for.
The inbound handler stores the delivery and returns. A separate loop claims what is due and forwards it.
Triggering the forward from the inbound request would have been less code and is wrong twice over. A provider that waits while we forward counts our destination's slowness as our own and starts retrying — so a slow destination turns into duplicate deliveries. And a retry due in four minutes needs a mechanism that survives a restart, which an in-process timer is not.
A worker claims due deliveries by pushing next_attempt_at into the future, which hides the row from other workers without marking it as being worked on.
A dedicated claimed status would strand every in-flight delivery permanently if the process died between claiming and settling; someone would have to notice and reset them by hand. With a lease, the delivery simply becomes due again when it expires. The cost is honest and stated: if forwarding outlasts the lease, a second worker can pick it up and the destination sees the delivery twice. That is at-least-once delivery — what every webhook provider gives you anyway — so keep HOOKSHELF_LEASE_MS well above HOOKSHELF_FORWARD_TIMEOUT_MS. The service refuses to start if you get that backwards, because a lease expiring mid-request produces duplicates only under load, which is the hardest kind of bug to reproduce.
Exponential backoff from 1s to a 300s ceiling, 8 attempts total (about 8.5 minutes of wall clock), with the delay drawn uniformly from [0, backoff] rather than a narrow band around it.
Fixed-interval retries from many concurrent deliveries arrive in lockstep and keep a struggling destination down — precisely when it can least afford it. Bounded or "equal" jitter still leaves a spike at the edge of the window; full jitter spreads the load flat. A Retry-After from the destination wins over the computed delay, because it is the destination telling you what it can take.
A 4xx other than 429 is not retried. The destination understood the request and rejected it; retrying an unauthorized or malformed request only burns attempts and fills its logs.
Everything except hop-by-hop headers is forwarded, including the provider's original signature headers, and the body goes out byte for byte. So the destination can verify the signature itself — hookshelf is a relay, not a man in the middle.
An allow-list of known headers would have been the more cautious-looking choice and is the worse one: it silently drops the signature header of any provider it has not heard of, and the destination then rejects a delivery that was perfectly genuine. Two headers are added: X-Hookshelf-Delivery and X-Hookshelf-Attempt, so a destination can deduplicate and tell "sent twice" from "you were slow last time".
Three static files, no framework, no bundler — the repository contains nothing that must be compiled before it runs. Deliveries arrive over server-sent events rather than polling: traffic is one-directional, it needs no upgrade handshake, it works through any proxy that understands HTTP, and EventSource reconnects on its own, so the client carries no retry logic at all.
The page renders header names, header values and bodies written by whoever found your endpoint URL — hostile input, by design. Every node is built with textContent, and the Content-Security-Policy served with the page forbids inline script, which is why there is none. That claim is checkable:
curl -si http://127.0.0.1:3000/ | grep -i content-security-policyOn SIGTERM: stop accepting connections → close event streams → wait for the in-flight forwarding batch → close the database.
Closing the event streams is not tidiness. A stream is a socket, a socket keeps the Node event loop alive, and server.close() only stops new connections — so one browser tab left open on the dashboard used to turn a clean stop into a hang until the container runtime gave up and sent SIGKILL, mid-delivery. Measured on this machine, docker compose stop with a dashboard connected takes 0.3s and exits 0. CI asserts the exit code, so the regression cannot come back quietly.
Every setting is an environment variable prefixed HOOKSHELF_, parsed and validated once at startup. A malformed value stops the process with a message naming the variable, rather than surfacing as a surprise when a 40 MB payload arrives.
| Variable | Default | What it does |
|---|---|---|
HOOKSHELF_PORT |
3000 |
Port to listen on. |
HOOKSHELF_DB_PATH |
./data/hookshelf.sqlite |
SQLite file. :memory: works for a throwaway instance. |
HOOKSHELF_MAX_BODY_BYTES |
1048576 |
Largest inbound body accepted. Endpoint URLs are handed to third parties by design, so there has to be a limit. |
HOOKSHELF_DEDUPE_TTL_SECONDS |
259200 |
How long a provider's delivery id is remembered. Three days, sized from Stripe's retry schedule rather than from any signature tolerance. |
HOOKSHELF_LEASE_MS |
60000 |
How long a worker may hold a claimed delivery. Must exceed the forward timeout. |
HOOKSHELF_FORWARD_TIMEOUT_MS |
10000 |
How long one forwarding attempt may take. |
HOOKSHELF_POLL_INTERVAL_MS |
1000 |
How often the forwarder looks for due deliveries. |
HOOKSHELF_BATCH_SIZE |
10 |
Deliveries claimed per poll. Also the forwarding concurrency limit. |
HOOKSHELF_MAX_EVENT_STREAMS |
32 |
Dashboards that may watch at once. A route holding connections open needs a ceiling. |
HOOKSHELF_LOG_LEVEL |
info |
fatal … trace, or silent. |
| Status | Meaning |
|---|---|
captured |
Recorded for inspection. The endpoint forwards nowhere, or verification failed. |
pending |
Waiting to be forwarded, or waiting for its next retry. |
duplicate |
This provider event id was already seen, so it was recorded but not forwarded again. |
delivered |
Forwarded and accepted. |
failed |
Rejected with something not worth retrying. |
dead |
Retryable, but the attempt budget ran out. The dead-letter state. |
captured and duplicate are deliberately distinct. Both mean "recorded, not forwarded", but the reasons differ, and a tool whose whole job is explaining what happened to a webhook must not blur them.
POST /in/:endpointId |
The inbound endpoint. This is the URL you give a provider. |
POST /api/endpoints |
Create an endpoint. { name, provider?, secret?, forwardUrl? } |
GET /api/endpoints |
List endpoints. Secrets are never returned, only hasSecret. |
DELETE /api/endpoints/:id |
Delete an endpoint and everything it captured. |
GET /api/endpoints/:id/deliveries |
Deliveries, newest first. ?limit= and ?before= (a keyset cursor, not an offset). |
GET /api/deliveries/:id |
One delivery in full: headers, body as base64 and as text, and attemptHistory. |
POST /api/deliveries/:id/replay |
Queue the payload again as a new delivery. |
GET /api/events |
Server-sent events. ?endpoint= narrows it to one endpoint. |
GET /healthz |
Liveness, plus the provider list this build supports. |
A provider and a secret must be supplied together. Accepting one without the other would create an endpoint that looks configured for verification and silently verifies nothing — worse than one that plainly does not verify at all. The secret is handed to webhook-kit immediately, so malformed key material is a 400 while you are still at the keyboard rather than a surprise on the first live webhook.
Stated plainly, because the defaults matter more than the feature list:
- There is no authentication. Anyone who can reach the port can read every captured payload and create endpoints. This is why
docker-compose.ymlbinds to127.0.0.1rather than0.0.0.0. If you expose it, put it behind a reverse proxy that terminates TLS and requires auth. - Endpoint ids are the only thing protecting an endpoint with no secret. They are 80 bits of entropy from a CSPRNG, over an alphabet with
I,L,OandUremoved so they survive being read aloud. - Secrets are never returned by the API, including in the response to creating the endpoint.
- Captured payloads are real data.
data/,*.sqliteand.envare excluded from both git and the Docker build context, so neither a commit nor an image can carry them by accident. - Verification is done by
webhook-kit, which compares in constant time and enforces timestamp windows where the provider defines one.
npm install
npm run dev # tsx watch
npm test # 210 unit tests
npm run test:coverage
npm run test:e2e # 22 checks against a real process
npm run lint
npm run typecheck
npm run buildCoverage is ~97% of lines. That number is not the interesting part; the split between the two suites is. The unit tests inject a fake clock and a fake transport, which is right for them and leaves several things untouched: the real HTTP layer, real fetch, header handling by Node's client, the polling loop on real time, an event stream over a real socket, and shutdown on a real signal. npm run test:e2e spawns the actual process with a temporary database and a throwaway destination server, and every bug it has caught so far lived in exactly those seams.
Layout
src/domain/ retry scheduling, strict HTTP-date parsing, id generation
src/db/ SQLite connection, pragmas and migrations
src/store/ delivery repository, dedupe store
src/http/ inbound endpoint, management API, event stream, dashboard assets
src/forward/ the outbound forwarder and its header rules
public/ the dashboard: three files, no build step
webhook-kit— signature verification for eight providers- Hono — the HTTP layer
- better-sqlite3 — storage, in WAL mode
MIT — see LICENSE.
