@chrisrivera/localhooks
v1.1.2
Published
Point a webhook at one stable URL forever, and forward it to whichever machine you are sitting at.
Maintainers
Readme
@chrisrivera/localhooks
One stable webhook URL, forwarded to whichever machine you are sitting at.
bunx @chrisrivera/localhooks create stripe-prod
# → https://hooks.chris-rivera.dev/stripe-prod-h4k2m9qw3z
bunx @chrisrivera/localhooks stripe-prod-h4k2m9qw3z --to 3000Point Stripe at that URL once, forever. Run the second command on your laptop, your desktop, or a machine you have not bought yet — whichever one is attached receives the webhooks.
What makes it different from a tunnel
The URL is unguessable. You choose the readable part; the server adds ten
random characters. Without them, anyone could type
hooks.chris-rivera.dev/stripe-prod and have it forwarded into your dev
machine — during exactly the window when your signature-checking code is not
finished yet.
The sender's connection is held open until your handler answers, and your handler's real status code, headers and body are what it receives. A 402 from your webhook route reaches Stripe as a 402, and Stripe retries it on its own schedule. You can exercise your failure paths for real.
Raw bytes are never touched. A webhook body is signed by whoever sent it —
Stripe computes its signature over the exact request bytes — so nothing between
the edge and your localhost parses, re-serialises or re-encodes a payload. If
your signature verifies in production, it verifies here.
Nobody attached is an honest 503. The sender's own retry schedule becomes your safety net, and the event is stored either way so you can look at it when you reconnect.
Commands
localhooks <public-name> --to <port|url> forward that endpoint here
localhooks create <slug> [--label <n>]
localhooks ls
localhooks rm <public-name>
localhooks login [--device] [--no-device] [--label <n>] [--timeout <s>]
localhooks logout [--all]
localhooks whoami--to 3000 is shorthand for http://localhost:3000, and the inbound path is
appended — so it behaves like a tunnel. Give it a path instead
(--to http://localhost:3000/api/webhooks/stripe) and everything goes there,
which is what a webhook target usually wants.
Signing in
localhooks login opens your browser, you approve the machine, and that is the
last time it asks. Over SSH it falls back to a device code automatically.
Its own login, separate from
@chrisrivera/envsync.
The two shared one token until this version, which meant a credential kept on a
laptop to forward webhooks could also read every secret in the env vault.
Nothing about forwarding a webhook needs that, so they are now two credentials
with two Revoke buttons — localhooks logout leaves envsync signed in.
Upgrading from a shared login: upgrade first, then sign in — in that order.
An older localhooks cannot be fixed by signing in again, because it asks the
old endpoint for a credential this version no longer accepts; it is refused with
a 426 rather than being handed a token that would not work.
bunx @chrisrivera/localhooks@latest loginThe @latest is deliberate: plain bunx @chrisrivera/localhooks may reuse a
version it has already cached. Your envsync login is untouched throughout.
Environment
| Variable | Meaning |
| ----------------------- | ---------------------------------------------------------------- |
| LOCALHOOKS_TOKEN | A machine token, used as given. Wins over the stored login. |
| LOCALHOOKS_HOST | The site to talk to. Defaults to https://www.chris-rivera.dev. |
| LOCALHOOKS_CONFIG_DIR | Where the login is kept. |
| LOCALHOOKS_NO_BROWSER | 1 forces the device-code flow. |
Requirements
Node 22 or newer, and nothing else — this package has zero dependencies.
License
MIT
