Providers want an HTTPS endpoint that never goes down. Your consumer lives on a laptop behind a tunnel that sleeps, roams and rotates. hookstr sits between them: it takes every delivery on always-on infra, keeps it as a signed nostr event, and replays it to your machine, byte-exact, whenever you're back.
POST to one URL
always on · 204 in milliseconds
● draining over tunnel
Close the lid and webhooks pile up as waiting. Open it and the drain catches up, oldest first, then keeps following.
// the problem
GitHub, GitLab, Stripe and friends deliver to an endpoint, retry for a while, then give up, and many will switch the hook off after enough failures. Pointing them at a dev tunnel means losing that race whenever the lid closes or the URL changes.
Missed deliveries while offline, a disabled hook after a long weekend, and a new URL to paste into every provider when the tunnel rotates.
One stable URL that answers in milliseconds. Every delivery is stored durably, then synced to your machine and replayed when it's reachable.
// how it works
hookstr splits accepting a webhook from consuming it. The server never talks to your consumer, and the consumer never has to be reachable from the internet.
POST /ingest/<token>/any/path behind your TLS proxy.3003 event in nostrdb, then answers 204.// guarantees
A replayed webhook looks like the original: same body bytes, same signature headers, same delivery id.
Providers HMAC the raw body. hookstr keeps it verbatim, binary included, so your existing signature check passes on replay.
Signature and delivery-id headers make the trip. Hop-by-hop and proxy headers are dropped.
A crash mid-replay means a retry, never a gap. Dedupe on the provider's delivery id, which you should be doing anyway.
Ingest waits only for the durable write, then answers 204. A 5xx means the write itself failed, so the provider retries.
Nothing is ever deleted. A webhook is a few KB, so a million deliveries fit in single-digit GB, and sync stays simple.
The relay binds to localhost or a VPN interface. Only your tunnel reaches it, and the drain replays only events it has verified.
// quickstart
The docs cover every option. This is the short path.
hookstrd signs every stored webhook with it.
on the server$ hookstr keygen nsec: nsec1... pubkey: 8f2a...
Proxy only the ingest port. Keep the relay on localhost.
on the server# hookstrd.toml db_path = "/var/lib/hookstr/ndb" ingest_addr = "127.0.0.1:8080" # behind your TLS proxy relay_addr = "127.0.0.1:8081" # never proxied nsec_path = "/var/lib/hookstr/server.nsec" [ingest_tokens] gitlab = "long-random-secret" $ hookstrd --config hookstrd.toml
Give each provider its token. Whatever follows the token becomes the replay path.
in the provider's settingshttps://hooks.example.com/ingest/long-random-secret/gitlab/events
Catches up on the backlog, then follows live.
on your laptop$ ssh -NL 8081:127.0.0.1:8081 hooks.example.com $ hookstr init ws://127.0.0.1:8081 $ hookstr drain --target http://localhost:3000/webhooks gitlab → POST /webhooks/gitlab/events 204
// consuming
Each drain names the providers it handles, so one hookstrd can feed several consumers, each with its own scoped drain.
Replays each webhook as a real HTTP request to your local app. Retries with backoff while the app is down.
$ hookstr drain \
--target http://localhost:3000 stripe
No server needed. Batches of NDJSON go to a command on stdin, and exit 0 acks the batch. HOOKSTR_PHASE says whether it's backfill or live.
$ hookstr drain --exec ./notify.py gitlab
Prints stored webhooks without delivering anything or touching replay state. --exec runs a consumer the same way, with acks kept in memory.
$ hookstr peek --follow gitlab
// when things break
The only failure a provider can ever see is the ingest box itself being down, and then it retries as usual.
| failure | what happens |
|---|---|
| laptop asleep | Webhooks wait in the store. The drain syncs them on reconnect and replays oldest first. |
| consumer down | Retry with backoff. The events are already durable. |
| crash mid-replay | At-least-once delivery plus consumer dedupe makes it safe. |
| duplicate deliveries | Stored as distinct events. The consumer dedupes on the delivery id. |
| storage write fails | Ingest returns 500 and the provider retries. |
| ingest URL leaks | Rotate that provider's token. The others are untouched. |
Run hookstrd once on a box that stays up, and let your laptop come and go.