Running in production
Testhooks has to be reachable from the internet for webhook providers to deliver to it. Lock down everything except the capture URLs.
Checklist
Section titled “Checklist”- Set
AUTH_TOKEN. Without it, anyone who finds the server can read every captured webhook, change endpoint settings (including where requests are forwarded) and delete everything. See Authentication. - Set
ALLOWED_ORIGINSto the origin you serve the UI from, such ashttps://hooks.example.com. - Terminate TLS in a reverse proxy or load balancer in front of Testhooks.
- Set
TRUSTED_PROXIESto that proxy’s CIDR so rate limiting and the captured client IP use the real client address. - Use PostgreSQL, managed or backed up, if you need to keep server-mode requests. In-memory storage (the default) is for local testing: one process, data in RAM, no shared access.
- Prefer one long-running server. Live updates go from the process that received a webhook to the browsers connected to that same process, and in-memory storage exists only inside one process. A VPS or a single-instance container host (Fly.io, Railway, Render and similar) avoids both limits. On platforms that run several instances, such as Vercel, use PostgreSQL and expect live updates and browser mode to be unreliable; see Deploy on Vercel.
Authentication
Section titled “Authentication”AUTH_TOKEN is one shared password for the whole instance. It decides who can look at and change things, not who can send webhooks.
Without it
Section titled “Without it”Anyone who can reach the server has the same access you do. They can:
- list every endpoint and read every captured request: bodies, headers and anything else a sender included;
- change endpoint settings, for example point forwarding at their own server to receive copies of future webhooks;
- delete endpoints and requests;
- watch webhooks arrive live.
Leave it unset only for local use, or for a deliberately public demo with nothing sensitive in it.
With it
Section titled “With it”Every path that reads or changes data needs the token:
| Path | Access |
|---|---|
/api/* |
Authorization: Bearer <token> |
/ws/* |
WebSocket subprotocol auth.<token>, since browsers can’t set headers on WebSockets |
/h/* (capture) |
Public, since providers can’t authenticate |
/healthz |
Public, for orchestrator probes |
| Everything else (the UI) | Public. The UI bundle holds no data, and it has to load to show the sign-in prompt |
A missing or wrong token gets 401 Unauthorized, and the attempt is logged with the client IP. Tokens are compared in constant time. Generate one with openssl rand -hex 32.
When the UI gets a 401, it asks for the token, checks it against the server and stores it in that browser’s localStorage. Requests that were waiting retry automatically and the live view reconnects. If the token changes on the server, the UI says the saved token was rejected and asks again. Use the sign-out button in the header to forget the token.
What it doesn’t do
Section titled “What it doesn’t do”- It doesn’t stop anyone sending webhooks. Capture URLs stay open, so anyone who knows a slug can post to it. They can’t see anything back. To reject forged deliveries, turn on signature verification with Reject invalid signatures.
- It doesn’t separate users. Everyone with the token sees every endpoint. Changing it signs everyone out until they enter the new one.
- It doesn’t encrypt anything. Captured requests are stored as they arrived. Only signature secrets are encrypted at rest, with
ENCRYPTION_KEYS. - It’s only as private as the browser. Anyone using that browser profile can read the saved token. Sign out on shared machines.
Behind a reverse proxy
Section titled “Behind a reverse proxy”Testhooks reads X-Forwarded-For and X-Real-IP only from peers listed in TRUSTED_PROXIES. Otherwise it uses the connection’s address, so clients can’t spoof their IP.
TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12If every captured request shows 127.0.0.1 (or ::1) as its IP, your platform’s proxy connects to Testhooks over loopback. On Vercel this is handled automatically: when VERCEL=1 is set and TRUSTED_PROXIES is empty, loopback is trusted and the startup log says so. On other platforms, trust loopback yourself:
TRUSTED_PROXIES=127.0.0.1/32,::1/128Without this, rate limiting also sees every visitor as one IP, so they all share a single per-IP budget.
Make sure the proxy supports WebSocket upgrades on /ws/* and doesn’t time out idle WebSockets too quickly. Testhooks pings every 30 seconds.
Health checks
Section titled “Health checks”GET /healthz returns 200 {"status":"ok"} when the database is reachable and 503 when it isn’t. Use it for liveness and readiness probes.
Limits
Section titled “Limits”| Limit | Default | Setting |
|---|---|---|
| Request body | 512 KB, larger gets 413 |
MAX_BODY_SIZE |
| Rate per client IP | 20 req/s, burst 40, on capture and API routes | RATE_LIMIT_RPS, RATE_LIMIT_BURST |
| Stored requests per endpoint | 500 | MAX_REQUESTS_PER_ENDPOINT |
| Stored bytes per endpoint | 10 MB | MAX_ENDPOINT_STORAGE_BYTES |
| WebSocket viewers per endpoint | 50 | MAX_SUBSCRIBERS_PER_SLUG |
| API request timeout | 30 s | API_TIMEOUT_SECONDS |
The pruner deletes the oldest requests beyond these limits every PRUNE_INTERVAL_SECONDS.
Startup and shutdown
Section titled “Startup and shutdown”- On startup, the server retries the database connection for up to 60 seconds (20 on Vercel), so it tolerates the database starting at the same time.
- Each instance keeps up to 20 database connections (3 on Vercel). Set
pool_max_connsinDATABASE_URLto change it. - On
SIGTERMorSIGINTit stops accepting connections and gives in-flight requests and background forwards up to 10 seconds to finish. Give your orchestrator a termination grace period above that.
Database failures
Section titled “Database failures”If PostgreSQL becomes unreachable, capture requests get 503 Service Unavailable, not 404. Most webhook senders retry 5xx responses, so deliveries made during an outage are redelivered once the database is back.