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
- Accounts: optional sign-in, backed by Postgres
- Privacy: what is kept, who sees it, for how long
- Abuse handling
- 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 menu of your URLs at the start of the URL bar (h). - Abuse handling: an
/abusepage where anyone can look up and delete reported URLs, with records kept for investigations. - Accounts (optional): sign in with an email and password, invite people with one-time links, no SMTP needed. Off by default; see Accounts.
- 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/…, /api/…) per minute per client IP (0 = unlimited). |
AUDIT_RETENTION |
180d |
How long the record of a URL is kept after it expires or is deleted (0 = none). See Abuse handling. |
ABUSE_CONTACT |
none | Email or URL shown on the /abuse page. |
ACCOUNTS |
off |
on enables sign-in; needs Postgres. See Accounts. |
DATABASE_URL |
none | Postgres connection, e.g. postgres://user:pass@host/db?sslmode=require. Empty = the standard PGHOST, PGUSER, PGPASSWORD, PGDATABASE and PGSSLMODE variables, which Compose sets for its own Postgres. |
POSTGRES_PASSWORD |
webhookgo |
Password of the Compose Postgres (Compose only). Set it before the first start. |
SESSION_TTL |
30d |
Signed-in sessions end after this long without activity. |
LOGIN_RATE_LIMIT |
10 |
Login attempts per minute per client IP (0 = unlimited); three times that per account and client network (/24, or /56 for IPv6), so nobody can lock you out of your account from elsewhere. |
ANONYMOUS_URLS |
on |
With accounts on, off lets only signed-in people create URLs. See Private URLs. |
ACCOUNT_MAX_URLS |
100 |
Private URLs each account may own (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.
Off by default: a plain docker compose up -d has no sign-in and nothing
changes. Switched on, people sign in with an email and a password, and the
app keeps users and sessions in Postgres. There is no open sign-up and no
SMTP to configure: people join through one-time links that an admin hands
them.
Two lines in .env, then docker compose up -d:
# .env
COMPOSE_PROFILES=accounts # also start Postgres
ACCOUNTS=on # and use it
POSTGRES_PASSWORD=change-meCOMPOSE_PROFILES=accounts adds a postgres service to the stack, with no
published port and its data in the postgres-data volume. Set
POSTGRES_PASSWORD before the first start: Postgres reads it only when it
creates the volume. To change it later, run ALTER USER webhookgo PASSWORD '...' in Postgres and update .env. COMPOSE_PROFILES and the
COMPOSE_FILE of Hosting are independent.
The schema is created and upgraded by the app at startup, so a new version
just needs docker compose up -d. Without Postgres the app refuses to start,
with a message that says so.
While the instance has no admin, each start logs a one-time setup link:
docker compose logs app | grep setup
# create the first admin: https://hooks.example.com/setup#AbC… (valid 1h)Open it, fill in your email, name and password, and you are signed in as
admin. Without PUBLIC_URL the log shows the path only; open it on the
address you use for the app. The token sits in the URL fragment (after #),
which browsers never send, so it does not reach proxies or access logs.
Links are made from the command line and are valid for 72 hours, once:
docker compose exec app webhookgo users add you@example.com # invite a user
docker compose exec app webhookgo users add --admin you@example.com # invite an admin
docker compose exec app webhookgo users reset you@example.com # password reset link
docker compose exec app webhookgo users listSend the link through a channel you trust: whoever opens it first gets the
account. A reset link also signs the person out everywhere else. The same
commands work with an external Postgres, as long as the environment has
ACCOUNTS=on and the connection settings.
Signed in, Account in the top-right menu lets you change your name and password, and sign out of your other devices. Changing the password ends every other session.
Admins also have Admin in that menu, at /admin:
- Overview: how many users, admins and disabled accounts there are and
how many private and public URLs; Redis memory (used against
maxmemory) and evicted keys; the database size; the version. - Users: search; invite (the link is shown once, valid 72 hours); make someone an admin or a user; disable (their sessions end and they cannot sign in until enabled again); hand out a password reset link; delete an account together with its private URLs. The last active admin cannot be demoted, disabled or deleted, and admins cannot do any of these to their own account.
- URLs: every private URL with its owner and activity, and a lookup that
takes any URL or UUID, anonymous or not, and shows what the instance knows
about it, including the record
webhookgo auditprints, with a delete button. - Reports: the URLs reported from the
/abusepage, with their owner, record and each report's message; delete the URL, disable its owner or dismiss the reports. See Abuse handling. - Log: every admin action, with who did it, from where and when. That includes opening someone else's URL, private or anonymous (noted once an hour per URL), links made from the command line, and the creation of the first admin. The log is only ever appended to.
URLs → Public lists the anonymous URLs as well, with the IP each was
created from and when it expires; both lists search by name, exact ID or
creator IP. Opening one is logged, as opening someone's private URL is.
To look for abuse, the public list marks the URLs that answer with their
own HTML, XML or SVG page (the usual phishing) and can show only those, can
put the URLs holding the most requests first, and Top IPs counts the
addresses that created the most. These read every anonymous URL, so they
take their turn with the other heavy reads.
Anonymous URLs live in Redis only, so with accounts on they are
also indexed in one more Redis key (wh:anonymous): at startup the instance
adds those created while accounts were off, and every 10 minutes it drops
those that expired.
The panel runs on its own JSON API, open only to admins signed in with a session (an API key never reaches it):
| Method | Path | |
|---|---|---|
GET |
/api/admin/stats, /api/admin/log |
Instance numbers; the admin log (page, per_page, action, actor). |
GET / POST |
/api/admin/users |
List accounts (q, page, per_page); invite one: {"email", "role"} → {"link", "expires_at"}. |
PATCH / DELETE |
/api/admin/users/<id> |
Change the role ({"role"}) or disable ({"disabled"}); delete the account and its URLs. |
POST |
/api/admin/users/<id>/reset |
A password reset link: {"link", "expires_at"}. |
GET |
/api/admin/urls |
Every private URL with its owner (q, owner, page, per_page); kind=anonymous lists the anonymous ones instead (q, pages=1 for those that serve a page, sort=requests, page, per_page). Each has serves_page. |
GET |
/api/admin/ips |
The 20 addresses that created the most live anonymous URLs: {"data": [{"ip", "urls", "pages", "requests", "latest_created_at"}], "urls"}. |
GET / DELETE |
/api/admin/urls/<id> |
What is known about any UUID: {"token", "owner", "record"}; delete the URL. |
GET |
/api/admin/reports |
Abuse reports grouped by URL, newest first (status=open|actioned|dismissed|all, page, per_page). |
POST |
/api/admin/reports/resolve |
Close a URL's open reports: {"token_id", "action": "delete_url"|"disable_owner"|"dismiss", "note"} → {"updated"}. |
A URL created while signed in is private: only its owner and the admins can
open it in the app or through the API (anyone else gets 403), and it does
not expire. Its settings live in Postgres, so it survives Redis losing them;
the requests it captures stay in Redis and follow TOKEN_TTL,
MAX_REQUESTS and MAX_URL_STORAGE as always. Webhooks can still be sent
to it by anyone: the capture side is public, as it has to be. Each account
may own up to ACCOUNT_MAX_URLS. Your URLs in the menu lists them, on
any browser, and so does the account page, with each one's requests and a
delete button.
Anonymous URLs still work as before, with one addition: with accounts on,
creating one returns a claim_key, once. The app keeps it in the browser's
history, and the URL can be saved to an account with it
(POST /token/<token>/claim with the key). The key is what keeps someone
who merely knows a URL's ID from taking it over. Signing in from the tab
that created a URL less than an hour before saves it at once, with an undo;
any other URL on the browser is saved only on request, after a warning, as
on a shared computer it may be someone else's (whoever else uses it can
still send requests to it, but no longer see them). In the other direction,
Make anonymous in a private URL's settings (POST /token/<token>/release)
hands it back with a new claim_key, and it expires like any other from
then on.
For a private instance, set ANONYMOUS_URLS=off: creating a URL then needs
a sign-in. URLs that already exist keep working.
Scripts and CI jobs use an API key instead of a session. Account → API
keys makes one, named after where it will be used, and shows it once; the
app keeps only its hash. Send it as Api-Key: wgo_… or
Authorization: Bearer wgo_… on the /token… routes: POST /token then
creates a private URL that belongs to you, GET /token lists your URLs, and
everything under /token/<token> works on the URLs you own. A CI job that
creates a URL, points a service at it and waits for the call:
ID=$(curl -s -X POST -H "Api-Key: $KEY" "$BASE/token" -d '{"description":"CI run 42"}' | jq -r .uuid)
# ... trigger the webhook at $BASE/$ID ...
curl -s -H "Api-Key: $KEY" "$BASE/token/$ID/wait?timeout=30" | jq .
curl -s -X DELETE -H "Api-Key: $KEY" "$BASE/token/$ID"A key acts as its user with the plain user role, even for an admin, so a
leaked key never opens other people's URLs; and it never works on the
/api/… routes, so it cannot manage the account, make keys or reach the
admin side. A bad or revoked key gets 401, never anonymous access. Each
account may hold 20 keys; Revoke ends one at once, open live streams
included. Keys start with wgo_, so a secret scanner can spot one that
leaked into a repository.
In Postgres: each user's email, name, role, password (as an argon2id hash),
when it was created and last signed in; each session's hash, IP address,
user agent and times, kept until it expires after SESSION_TTL without use;
the hashes of unused links; each API key's name, first characters, hash and
when it was last used; and the settings of each private URL, with its
owner. Secrets themselves are never stored.
Captured requests stay in Redis, as before, and the session cookie is
stripped from every captured request before it is recorded, since the UI
itself can call capture URLs. The cookie is HttpOnly, SameSite=Strict,
and Secure with the __Host- prefix when the app is served over HTTPS
(PUBLIC_URL starts with https://, or TRUST_PROXY is set and the proxy
says so).
docker compose exec postgres pg_dump -U webhookgo webhookgo > backup.sql
docker compose exec -T postgres psql -U webhookgo webhookgo < backup.sql # restore, on an empty database
docker compose down -vdeletes the volumes, and with them every user and session.docker compose downkeeps them.
Any Postgres 14 or later works, managed ones included (no extensions are
needed). Leave the profile off and point DATABASE_URL at it:
# .env
ACCOUNTS=on
DATABASE_URL=postgres://webhookgo:secret@db.example.com/webhookgo?sslmode=requireA password with special characters is easier to pass through the standard
PGHOST, PGUSER, PGPASSWORD, PGDATABASE and PGSSLMODE variables
instead, which the app reads when DATABASE_URL is empty.
What an instance keeps, who sees it and for how long. Each instance also
shows this at /privacy (in the menu), written from its own settings.
- Captured requests hold whatever the sender sent and its IP address, up
to
MAX_REQUESTSper URL, and goTOKEN_TTLafter the URL was last used, or at once when it is deleted. - Anonymous URLs are public: whoever has the link sees and changes them. Private URLs are for their owner and the admins.
- Who created a URL (IP address and user agent) is kept with it. For an
anonymous URL, the abuse page shows it to anyone who looks the URL up
there, so that abuse can be reported to the network it came from.
Otherwise only the URL's owner and the admins see it: the API leaves
ipanduser_agentempty for anyone else. - Records for abuse investigations last
AUDIT_RETENTIONafter the URL is gone, and hold no request content. - Admins can list and open every URL to look for abuse. Opening one that is not theirs goes into the admin log, which cannot be edited and which every admin sees, so keep the admins few.
- Accounts keep what What is stored lists. Expired sessions, and links nobody used in time, are deleted every hour. Abuse reports keep the reporter's IP address, message and contact.
- No analytics or third-party scripts: the UI's Content Security Policy loads nothing from anywhere else.
A public instance will be abused, like any webhook service: phishing kits post
the credentials they collect to a URL, or a URL serves the phishing page
itself. As on webhook.site, the people who report it can act on their own:
/abuse (Report abuse in the app's menu) takes URLs, IDs or a whole report,
shows when and from which IP each URL was created, how many requests it got and
what it answers, and deletes it. A deleted URL answers 410 Gone from then on.
Set ABUSE_CONTACT to show where to report anything else.
Each URL also gets a small record that outlives it by AUDIT_RETENTION (180
days by default; 0 keeps none): when and from which IP and user agent it was
created, how many requests it got and when the first and last arrived, and when
and from where it was deleted. Captured requests are never part of it. To read
records:
docker compose exec app webhookgo audit https://hooks.example.com/<uuid>
docker compose exec -T app webhookgo audit < report.eml # every ID in the textRecords live in Redis, next to the URLs:
- They survive restarts and
docker compose down: Redis runs withappendonly yeson theredis-datavolume, and a crash loses about a second of writes at most.docker compose down -vdeletes the volume, and everything with it. - Each takes a few hundred bytes of
REDIS_MAXMEMORY. When Redis runs out, it evicts the least recently used keys, which can include old records. Give it room (watchevicted_keysinredis-cli INFO stats), and keep the output ofwebhookgo auditwith each report you act on. - IP addresses are personal data under laws such as the GDPR and Brazil's LGPD: say in your terms what you keep and for how long.
To block an abuser's IP address, use your firewall, or Cloudflare's IP Access Rules when it is in front.
With accounts on, a URL that belongs to an account shows on
/abuse as private, with none of its details and no delete button: only the
admins can act on it. The page then offers a form that reports the looked-up
URLs, private and active anonymous ones alike, to the admins, who see them in
the panel's Reports tab and delete the URL, disable its owner or dismiss
the reports, with a note; each of these is logged. A report keeps the
reporter's IP address, their message and, if they left one, a contact
address. URLs that do not exist are accepted but not stored, an address's
report of a URL is kept once a day, and each address may send ten reports an
hour.
The form posts {"ids": [...], "message", "contact"} (up to 50 IDs) to
POST /api/abuse/reports, which answers 202 whether or not the IDs exist.
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,
an unknown URL answers 404, a deleted one 410, and a private one
403 to anyone but its owner and the admins (every /token/<token>… route; capture
stays public). A URL's ip and user_agent, those of whoever created it, are empty
but for its owner and the admins; for an anonymous URL, the abuse page reads them
from GET /api/abuse/urls/<token> ({"ip", "user_agent", "created_at"}, 403 for a
private URL), with or without accounts.
| 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). Signed in, the URL is private (owner_id, expires_at: null); anonymous with accounts on, the answer carries a one-time claim_key. |
GET |
/token |
The signed-in user's URLs, newest first: page, per_page (max 100), q (search in descriptions). Each item also has latest_request_at. Accounts only. |
GET / PUT / PATCH / DELETE |
/token/<token> |
Read, update (partial), delete a URL. |
POST |
/token/<token>/claim |
Make an anonymous URL yours: {"claim_key"}. Accounts only. |
POST |
/token/<token>/release |
Make a private URL anonymous again; the answer carries a new claim_key. Owner or admin. Accounts only. |
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). |
GET |
/api/config |
Instance settings for the UI, including accounts. |
GET |
/api/me |
The signed-in user, or {"user": null}. |
POST |
/api/login, /api/logout |
Sign in with {"email", "password"} (sets the session cookie) and out. Accounts only. |
POST |
/api/links/inspect, /api/links/redeem |
Look at and use a one-time link: {"token"}, plus email, name and password as the link's kind needs. Accounts only. |
PATCH / POST |
/api/account, /api/account/password, /api/account/logout-others |
Rename ({"name"}), change the password ({"current_password", "new_password"}), end the other sessions. Accounts only. |
GET / POST |
/api/keys |
List the signed-in user's API keys, or make one: {"name"}; the answer carries the key this once. Session only. |
DELETE |
/api/keys/<id> |
Revoke a key. Session only. |
With accounts on, the /token… routes also take an API key, as Api-Key: wgo_… or Authorization: Bearer wgo_…, in place of the session; a session
cookie wins when both are present. The /api/… and /token… routes refuse
state-changing requests that browsers mark as coming from another site.
Requests from scripts, which carry no Origin or Sec-Fetch-Site header,
are not affected.
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 tests of Accounts need a Postgres and are skipped without
one. Point TEST_DATABASE_URL at any database you can create schemas in
(each test uses its own, dropped afterwards), as the CI workflow does:
docker run -d --rm --name pg -e POSTGRES_PASSWORD=test -p 127.0.0.1:5432:5432 postgres:18-alpine
TEST_DATABASE_URL='postgres://postgres:test@127.0.0.1:5432/postgres?sslmode=disable' go test -race ./...The 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`, `audit` and `users`)
internal/config/ environment configuration
internal/store/ Redis storage: atomic Lua scripts, hash-tagged keys, rate limiting
internal/accounts/ Postgres storage for users, sessions and one-time links; embedded SQL migrations
internal/auth/ argon2id passwords, secrets and the session cookie
internal/hub/ one Redis pub/sub subscription fanned out to SSE clients
internal/signature/ signature checks for webhook providers
internal/server/ capture, JSON API, accounts 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. A sixth key,
wh:{id}:audit, is the URL's record for abuse handling: it
expires AUDIT_RETENTION after the others. With accounts on, a
private URL has its settings in Postgres and wh:{id}:token
is a cache of them, restored on a miss; wh:{id}:none remembers for a minute
an ID that exists nowhere, so guesses do not reach Postgres.
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
