Inspect webhooks and HTTP requests in real time.
webhookgo.dev
⭐ Star · Fork · Report an issue · Self-host
Get a unique URL, point any service at it, and watch every request arrive instantly: headers, query string, form fields, files and body, pretty-printed.
A modern take on webhook.site: a single Go binary with the UI embedded, Redis for storage, and live updates over Server-Sent Events. No PHP, no Node at runtime, no socket.io server.
- Quick start
- Features
- Configuration
- Hosting: Cloudflare Tunnel, ngrok, Caddy, nginx
- Signature verification
- API
- End-to-end tests
- Development
- Contributing
- License
docker compose up -d
open http://localhost:8080This builds the image from source. With a .env copied from
.env.example, it pulls the published latest image instead.
To put it online with HTTPS, see
Hosting. You can use a tunnel (Cloudflare, ngrok) or a reverse
proxy (Caddy, nginx), each switched on from .env.
You can run the published image from GitHub Container Registry (amd64 and arm64) with just the Compose file:
mkdir webhookgo && cd webhookgo
curl -fsSLO https://raw.githubusercontent.com/dbaio/webhookgo/main/docker-compose.yml
printf 'IMAGE=ghcr.io/dbaio/webhookgo:latest\nPULL_POLICY=always\n' > .env
docker compose up -dlatest is the newest build of main, and PULL_POLICY=always gets it on
every docker compose up -d. To stay on a release, use a version tag such as
ghcr.io/dbaio/webhookgo:1.
- Unique URLs that accept any method and any path:
/<uuid>,/<uuid>/anything/here?x=1. - Live: requests appear the moment they arrive (SSE + Redis pub/sub), across tabs and app replicas. Where something in between holds back streaming (e.g. Cloudflare Quick Tunnels), the UI notices and polls instead.
- Custom responses per URL: status code, body,
Content-Type, extra headers, delay (0–30 s) and CORS (with real preflight handling). - Signature verification for GitHub, Stripe, Standard Webhooks/Svix, Shopify, Slack, Malga or any HMAC header, with an option to answer 401 when it fails.
- Status from the path:
/<uuid>/404answers 404,/<uuid>/503/retryanswers 503. - Body viewer: collapsible JSON tree (lossless — 64-bit IDs and key order survive), highlighted JSON/XML, form tables, hex dump for binary, image preview; download the raw body.
- Copy as cURL, HTTPie, JavaScript
fetch, Pythonrequests, Gonet/httpor raw HTTP. - Forwarding from the browser to any target — including
http://localhoston your machine — on demand or automatically for each new request. - Search across bodies, headers, query strings and IPs; filter by method.
- Wait for a webhook from scripts and end-to-end tests: one API call returns as soon as a matching request arrives.
- Responsive UI with light/dark themes, keyboard shortcuts (
?), desktop notifications, unread markers and a history of your URLs. - Safe defaults: per-IP rate limits, body and per-URL storage caps, custom responses sandboxed with CSP, strict CSP on the app, distroless non-root read-only container (~12 MB image).
Everything is set through environment variables. With Docker Compose, put
overrides in a .env file next to docker-compose.yml (see .env.example).
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
Host port (Compose only); 127.0.0.1:8080 to accept local connections only. |
IMAGE / PULL_POLICY |
build from source | Compose only. Runs a published image instead, e.g. ghcr.io/dbaio/webhookgo:latest with always. |
PUBLIC_URL |
browser origin | Base URL shown for webhook URLs, e.g. https://hooks.example.com. |
APP_NAME |
webhookgo |
Name shown in the UI. |
MAX_REQUESTS |
500 |
Requests kept per URL; the oldest are dropped as new ones arrive. |
MAX_URL_STORAGE |
64MB |
Bytes kept per URL; the oldest requests are dropped beyond it (0 = unlimited). |
TOKEN_TTL |
7d |
A URL and its requests expire after this long without use (72h, 30d…). |
MAX_BODY_SIZE |
2MB |
Stored bytes per body; larger bodies are truncated (the real size is still reported). |
MAX_DELAY |
30 |
Maximum response delay, in seconds. |
RATE_LIMIT |
600 |
Captured requests per minute per client IP (0 = unlimited). |
TOKEN_RATE_LIMIT |
30 |
New URLs per minute per client IP (0 = unlimited). |
API_RATE_LIMIT |
1200 |
API calls (/token/…) per minute per client IP (0 = unlimited). |
TRUST_PROXY |
false |
Honour X-Forwarded-* from your reverse proxies: true for one, or how many. The client IP is read from the right of X-Forwarded-For, so it can't be spoofed. Behind Cloudflare's proxy, see Behind Cloudflare. |
REDIS_URL |
redis://localhost:6379/0 |
Redis connection (rediss:// for TLS). |
LISTEN_ADDR |
:8080 |
Address the server listens on. |
LOG_LEVEL / LOG_FORMAT |
info / text |
debug…error; text or json. |
REDIS_MAXMEMORY |
512mb |
Redis memory cap (Compose only). |
All of this is optional. A plain docker compose up -d runs only the app and
Redis, with no third-party service involved.
Every option below is a Compose override in deploy/. You switch it
on with COMPOSE_FILE in .env, so the command stays the same:
docker compose up -d. Each override also sets PUBLIC_URL and TRUST_PROXY
for you.
| Option | You need | Open ports | Notes |
|---|---|---|---|
| Cloudflare Quick Tunnel | nothing | none | Random *.trycloudflare.com URL that changes on restart. For trying things out. |
| Cloudflare Tunnel | Cloudflare account, domain on Cloudflare | none | Stable URL on your domain. |
| ngrok | free ngrok account | none | Stable *.ngrok-free.app URL. |
| Caddy | server, domain | 80, 443 | Automatic Let's Encrypt certificate. |
| nginx | server, domain, certbot | 80, 443 | Most faithful: passes every header. |
The commands below assume Linux or macOS. On Windows, separate the files in
COMPOSE_FILE with ; instead of :.
# .env
COMPOSE_FILE=docker-compose.yml:deploy/cloudflare/quick.ymldocker compose up -d
make tunnel-url # or: docker compose logs cloudflared | grep trycloudflare.comThe app is still available on http://localhost:8080. Quick Tunnels do not
stream Server-Sent Events, so the UI polls every 3 s instead. They are also
limited to 200 requests in flight.
- In the Cloudflare dashboard, go to Zero Trust → Networks → Tunnels, create a tunnel of type Cloudflared and copy its token.
- Add a public hostname to the tunnel, e.g.
hooks.example.com, with serviceHTTPand URLapp:8080. - Add this to
.env, then rundocker compose up -d:
# .env
COMPOSE_FILE=docker-compose.yml:deploy/cloudflare/compose.yml
CLOUDFLARE_TUNNEL_TOKEN=eyJhIjoi...
DOMAIN=hooks.example.comWhat changes behind Cloudflare: captured requests also show Cloudflare's
headers (cf-connecting-ip, cf-ray, …), and only standard HTTP methods get
through. A method such as PURGE is answered 400 by Cloudflare itself.
- Sign up at ngrok.com and copy your authtoken. Then copy your free static domain from Domains in the dashboard.
- Add this to
.env, then rundocker compose up -d:
# .env
COMPOSE_FILE=docker-compose.yml:deploy/ngrok/compose.yml
NGROK_AUTHTOKEN=2abc...
NGROK_DOMAIN=example-name.ngrok-free.appThe app is still available on http://localhost:8080. On free accounts, ngrok
shows browsers a warning page the first time. Webhook senders are not
affected.
Point the domain's DNS at the server and open ports 80 and 443. Then add this
to .env and run docker compose up -d:
# .env
COMPOSE_FILE=docker-compose.yml:deploy/caddy/compose.yml
DOMAIN=hooks.example.comCaddy gets a Let's Encrypt certificate on first start and renews it by itself.
Certificates are kept in the caddy-data volume. The app port is no longer
published, so the only way in is through Caddy.
Webhook URLs also answer over plain HTTP, like on webhook.site, for senders that
cannot do TLS. The UI and API redirect to HTTPS. The configuration is in
deploy/caddy/Caddyfile.
Caddy 2.11.4+ drops request headers whose names contain
_or.(a fix for PHP/CGI backends, GHSA-f59h-q822-g45g), so they never show up in captured requests. You can let specific names through in the Caddyfile. When every header matters, use nginx.
deploy/nginx/webhookgo.conf is a site for nginx
installed on the host, with the certificate from certbot:
# .env: publish the app on localhost only; nginx is the way in
PORT=127.0.0.1:8080
PUBLIC_URL=https://hooks.example.com
TRUST_PROXY=1docker compose up -d
sudo certbot certonly --nginx -d hooks.example.com
sudo cp deploy/nginx/webhookgo.conf /etc/nginx/conf.d/ # then replace hooks.example.com in it
sudo nginx -t && sudo systemctl reload nginxThe site does the same as the Caddy setup: HTTP/2, webhooks also over plain HTTP, and the UI and API redirect to HTTPS. On top of that it keeps:
- headers with underscores, which nginx drops by default
- long headers
- bodies up to 32 MB
- responses byte for byte, with no gzip added
It needs nginx 1.25.1 or later. The file has a note for older versions.
When the domain's DNS record is proxied by Cloudflare (orange cloud) in front
of Caddy or nginx, every request reaches the proxy from a Cloudflare address.
To show the real client, let the proxy take it from CF-Connecting-IP, but
only on connections from Cloudflare's ranges,
so a request sent straight to your server can't forge it. TRUST_PROXY stays
1.
- Caddy: uncomment
trusted_proxiesandclient_ip_headersindeploy/caddy/Caddyfile. - nginx: write the ranges to a file with the command at the top of
deploy/nginx/webhookgo.confand uncomment itsincludeline.
A Cloudflare Tunnel needs none of this: there the app reads the client from
X-Forwarded-For, which Cloudflare fills in.
- Proxy settings: set
PUBLIC_URLandTRUST_PROXY(the number of proxies in front of the app). - App port: keep it private. Bind it with
PORT=127.0.0.1:8080, or don't publish it at all. If it can be reached directly, a client can forgeX-Forwarded-For. - Live stream: don't buffer
text/event-streamresponses. The app already sendsX-Accel-Buffering: no. - Request size: allow request bodies of at least
MAX_BODY_SIZE. - Timeouts: use a read timeout longer than
MAX_DELAY.
A URL can check the signatures that providers put on their webhooks. Pick the provider in Response settings → Signature verification and paste the secret. Each request is then marked with one of these statuses:
| Status | Meaning |
|---|---|
valid |
The signature matches. |
invalid |
The signature does not match, or the header is malformed. |
expired |
The signature matches, but its timestamp is further from now than the tolerance (5 minutes by default). |
missing |
The request has no signature headers. |
unverifiable |
The body is too large to check. |
| Scheme | Headers | What is signed |
|---|---|---|
github |
X-Hub-Signature-256 |
HMAC-SHA256 of the body |
stripe |
Stripe-Signature |
HMAC-SHA256 of <t>.<body> |
standard (Standard Webhooks, Svix) |
webhook-id, webhook-timestamp, webhook-signature, or the svix- ones |
HMAC-SHA256 of <id>.<timestamp>.<body>, keyed with the whsec_ secret |
shopify |
X-Shopify-Hmac-Sha256 |
HMAC-SHA256 of the body, in base64 |
slack |
X-Slack-Signature, X-Slack-Request-Timestamp |
HMAC-SHA256 of v0:<timestamp>:<body> |
malga (Malga webhooks v1.1) |
X-Plug-Signature, X-Plug-Date |
Ed25519 signature of <X-Plug-Date>, a line break and the body, checked with the webhook's public key |
hmac |
a header you choose | HMAC-SHA1, SHA-256 or SHA-512 of the body, in hex or base64, after an optional prefix |
HMAC signatures are checked against the whole body, even the part past
MAX_BODY_SIZE that is not stored. Ed25519 needs the stored body, so an
malga request larger than MAX_BODY_SIZE is unverifiable.
With Answer 401 on, requests that are not valid get 401 instead of the
URL's response. They are still recorded.
In the API, POST /token and PATCH /token/<token> take the same settings in
signature:
- The secret is never returned, and leaving it out keeps the saved one.
- Changing
schemeneeds a new secret. nullturns verification off.
curl -s -X PATCH localhost:8080/token/$TOKEN -H 'Content-Type: application/json' \
-d '{"signature": {"scheme": "stripe", "secret": "whsec_...", "tolerance": 300, "reject": false}}'
# In a test: the latest request must carry a valid signature
curl -s localhost:8080/token/$TOKEN/request/latest | jq -e '.signature.status == "valid"'Routes mirror webhook.site's open-source API. Differences: query and form are
ordered [{name, value}] lists (repeated names are kept), header names are lower-case,
and an unknown URL answers 404.
| Method | Path | |
|---|---|---|
ANY |
/<token>[/<status>][/<path>] |
Capture a request. |
POST |
/token |
Create a URL. JSON or form body: default_status, default_content, default_content_type, timeout, cors, description, response_headers ([{name, value}]), signature (JSON only, see Signature verification). |
GET / PUT / PATCH / DELETE |
/token/<token> |
Read, update (partial), delete a URL. |
PUT |
/token/<token>/cors/toggle |
Toggle CORS. |
GET |
/token/<token>/requests |
List requests: page, per_page, sorting=newest|oldest, q (search), method=GET,POST, summary=1. |
GET |
/token/<token>/request/latest |
Most recent request. |
GET |
/token/<token>/wait |
Wait for a matching request: since, timeout, match, q, method. See End-to-end tests. |
GET / DELETE |
/token/<token>/request/<id> |
Read or delete a request. |
GET |
/token/<token>/request/<id>/raw |
The body as received (?download=1 to download). |
DELETE |
/token/<token>/request |
Delete all requests. |
GET |
/token/<token>/stream |
Live events (Server-Sent Events). |
GET |
/healthz |
Health check (pings Redis). |
URL=$(curl -s -X POST localhost:8080/token -H 'Content-Type: application/json' \
-d '{"default_status": 202, "default_content": "{\"ok\":true}", "default_content_type": "application/json"}' | jq -r .url)
curl -s -X POST "$URL/orders" -d 'id=42'
curl -s "localhost:8080/token/${URL##*/}/request/latest" | jq .A test can trigger something that sends a webhook, then wait for that webhook
to arrive. GET /token/<token>/wait answers with the oldest matching request
that arrived at or after since. It answers as soon as there is one, or with
408 after timeout seconds.
| Parameter | Default | |
|---|---|---|
since |
when the call starts | Unix time in seconds or milliseconds, or an RFC 3339 date. Pass the time you triggered the webhook, so a webhook that arrives before the wait starts still counts. 0 searches everything stored. |
timeout |
30 |
Seconds to wait, from 0 to 60. 0 only checks what is stored. To wait longer, call again. |
match |
<field>=<value>, an exact match. Repeat it to require several. Fields: body.<path> for JSON bodies (body.data.id, body.items.0.sku), header.<name>, query.<name>, form.<name> and path. |
|
q, method |
Text search and methods, as in the request list. |
HOOKS=https://hooks.example.com
TOKEN=... # a URL you created once and registered with the provider
T0=$(date +%s)
ID=$(curl -fsS -X POST https://api.example.com/orders -d '...' | jq -r .id)
curl -fsS -G "$HOOKS/token/$TOKEN/wait" \
--data-urlencode "since=$T0" \
--data-urlencode "match=body.data.id=$ID" \
--data-urlencode "timeout=60" | jq .With -f, curl fails on the 408, so the script fails when the webhook is
late. The response is the full request, as in /token/<token>/request/<id>.
Its created_at gives the delivery delay.
How match compares values:
- JSON strings must be equal.
- Numbers compare as written in the body, so
body.amount=10does not match10.0. true,falseandnullmatch those JSON values.- Header values must be equal, but header names ignore case.
A URL expires after TOKEN_TTL without use. For a URL that stays registered
with a provider, raise it, for example TOKEN_TTL=365d.
Requirements: Go 1.26+, Node 22+, and Redis (or Docker).
make dev-redis # terminal 1 — Redis on :6379
make dev-server # terminal 2 — Go server on :8080
make dev-web # terminal 3 — Vite with hot reload on :5173 (proxies to :8080)
make test # go test -race ./... and svelte-check
make build # bin/webhookgo with the UI embeddedThe CI workflow (.github/workflows/ci.yml) builds
the image for amd64 and arm64. Once the tests pass, it publishes the image to
GitHub Container Registry:
| You push | Image tags |
|---|---|
a commit to main |
latest |
a tag such as v1.2.3 |
1.2.3, 1.2, 1 |
Push the tag on its own, after the commit it points to:
git push origin main
git tag v1.2.3 && git push origin v1.2.3New packages start out private. After the first publish, go to the repository's Packages → webhookgo → Package settings → Change visibility and choose Public. You only need to do this once.
cmd/webhookgo/ entry point (also `webhookgo healthcheck` for Docker)
internal/config/ environment configuration
internal/store/ Redis storage: atomic Lua scripts, hash-tagged keys, rate limiting
internal/hub/ one Redis pub/sub subscription fanned out to SSE clients
internal/signature/ signature checks for webhook providers
internal/server/ capture, JSON API, live stream, embedded static files
web/ Svelte 5 + TypeScript + Vite UI, embedded via go:embed
deploy/ Compose overrides and configs for tunnels and reverse proxies
Each URL owns five Redis keys sharing a hash tag (Cluster-friendly) and one
TTL that is refreshed on every use: wh:{id}:token (settings), wh:{id}:index
(sorted set of request IDs by time), wh:{id}:sum (small summaries for lists),
wh:{id}:data (request metadata) and wh:{id}:body (raw body bytes — no JSON
escaping or base64 overhead). Request IDs are UUIDv7, so they sort by arrival
time. Storing a request, evicting the oldest beyond MAX_REQUESTS /
MAX_URL_STORAGE and refreshing the TTL happen in one Lua script. Searches
scan requests in small chunks, so memory stays bounded.
Issues and pull requests are welcome. Run make test before sending a pull
request. It runs the Go tests with the race detector and the Svelte type
check.
If webhookgo is useful to you, give it a star. It helps other people find it.
BSD 2-Clause © 2026 Danilo G. Baio
