hookstr
store-and-forward webhooks

Webhooks that wait for you.

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.

simulation
stored 0 waiting 0 replayed 0 lost 0

providers

POST to one URL

gitlab
stripe
acme

hookstrd

always on · 204 in milliseconds

up

    your laptop

    Close the lid and webhooks pile up as waiting. Open it and the drain catches up, oldest first, then keeps following.

    // the problem

    Providers don't wait. Laptops don't stay up.

    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.

    Point the provider at your tunnel

    Missed deliveries while offline, a disabled hook after a long weekend, and a new URL to paste into every provider when the tunnel rotates.

    Point the provider at hookstr

    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

    Two halves, joined by nostr.

    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.

    on the always-on box

    hookstrd

    • Takes POST /ingest/<token>/any/path behind your TLS proxy.
    • Persists each delivery as a signed kind 3003 event in nostrdb, then answers 204.
    • Serves the store over an embedded nostr relay bound to localhost or a private interface.
    • One token per provider, so each can be rotated on its own.
    wherever the consumer runs

    hookstr drain

    • Reaches the relay through ssh, Tailscale or WireGuard.
    • Negentropy-syncs (NIP-77) everything it missed while offline.
    • Replays each one as a real HTTP request, with the original body bytes and headers.
    • Then follows live, and reconnects and re-syncs on its own.

    // guarantees

    Built so your consumer can't tell the difference.

    A replayed webhook looks like the original: same body bytes, same signature headers, same delivery id.

    01

    Byte-exact bodies

    Providers HMAC the raw body. hookstr keeps it verbatim, binary included, so your existing signature check passes on replay.

    02

    Headers preserved

    Signature and delivery-id headers make the trip. Hop-by-hop and proxy headers are dropped.

    03

    At-least-once

    A crash mid-replay means a retry, never a gap. Dedupe on the provider's delivery id, which you should be doing anyway.

    04

    Fast 2xx

    Ingest waits only for the durable write, then answers 204. A 5xx means the write itself failed, so the provider retries.

    05

    Store everything

    Nothing is ever deleted. A webhook is a few KB, so a million deliveries fit in single-digit GB, and sync stays simple.

    06

    Private by address

    The relay binds to localhost or a VPN interface. Only your tunnel reaches it, and the drain replays only events it has verified.

    // quickstart

    Up in four steps.

    The docs cover every option. This is the short path.

    Make a server key

    hookstrd signs every stored webhook with it.

    on the server
    $ hookstr keygen
    nsec:   nsec1...
    pubkey: 8f2a...

    Run hookstrd

    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

    Register the URL

    Give each provider its token. Whatever follows the token becomes the replay path.

    in the provider's settings
    https://hooks.example.com/ingest/long-random-secret/gitlab/events

    Tunnel in and drain

    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

    Replay to a server, a script, or your terminal.

    Each drain names the providers it handles, so one hookstrd can feed several consumers, each with its own scoped drain.

    drain --target

    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

    drain --exec

    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

    peek

    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

    Every failure has a boring answer.

    The only failure a provider can ever see is the ingest box itself being down, and then it retries as usual.

    failurewhat happens
    laptop asleepWebhooks wait in the store. The drain syncs them on reconnect and replays oldest first.
    consumer downRetry with backoff. The events are already durable.
    crash mid-replayAt-least-once delivery plus consumer dedupe makes it safe.
    duplicate deliveriesStored as distinct events. The consumer dedupes on the delivery id.
    storage write failsIngest returns 500 and the provider retries.
    ingest URL leaksRotate that provider's token. The others are untouched.

    Stop babysitting your tunnel.

    Run hookstrd once on a box that stays up, and let your laptop come and go.