The Unix pipe for webhooks. Receive, verify, and stream webhook events to stdout.
webhookd is a single binary that listens for webhooks, verifies their signatures,
and prints one JSON line per verified event to stdout. Pipe it anywhere.
webhookd github | jq '.payload.pull_request.title'
webhookd stripe | grep payment_intent.succeeded | jq '.payload.amount'
webhookd shopify | tee -a orders.jsonlNo database. No dashboard. No queue. No retries. No interpretation of what an event means. Just verified events, on a stream, where Unix tools can reach them.
Webhook debugging usually means one of three things:
- A hosted service that stores your payloads and shows them in a web UI.
- A hand-rolled HTTP server you rewrite every time you add a provider.
ngrokplusncplus hope.
All three are heavier than the problem. A webhook is an HTTP POST with a signature header. Verify the signature, print the body, exit the process when you're done. That's a Unix filter. It should compose like one.
webhookd is that filter. It knows how to verify signatures for a set of providers,
it writes verified events as JSONL, and it gets out of the way.
brew install 0xProgress/tap/webhookdgo install github.com/0xProgress/webhookd@latestRequires Go 1.25 or later.
docker run --rm -p 8080:8080 \
-e GITHUB_WEBHOOK_SECRET=your_secret \
ghcr.io/0xprogress/webhookd:latest github --host 0.0.0.0Download the archive for your platform from the
releases page, extract, and put
webhookd on your PATH.
No provider, no secret, no network. The built-in mock provider accepts any request
with the header X-Mock-Signature: valid and rejects everything else. It exists so
you can see the pipeline end to end immediately.
Terminal 1 — start the server:
webhookd mockYou'll see a startup line on stderr:
webhookd v0.1.0 — listening on 127.0.0.1:8080, endpoint POST /mock
Terminal 2 — send a webhook:
curl -s -XPOST http://127.0.0.1:8080/mock \
-H 'Content-Type: application/json' \
-H 'X-Mock-Signature: valid' \
-d '{
"type": "order.created",
"id": "evt_abc123",
"data": {"order_id": "ord_999", "amount": 4200}
}'Terminal 1 — one JSON line appears on stdout:
{"provider":"mock","verified":true,"event":"order.created","id":"evt_abc123","delivery_id":"","received_at":"2026-09-15T19:42:13Z","payload":{"type":"order.created","id":"evt_abc123","data":{"order_id":"ord_999","amount":4200}}}Now pipe it:
webhookd mock | jq -c '{event, amount: .payload.data.amount}'{"event":"order.created","amount":4200}
That's the whole tool. Everything below is providers and flags.
All providers ship in this repository, under providers/<name>/. There is no
external provider mechanism — a new provider is a PR, not a separate module.
The set of providers a given binary supports is fixed at build time.
| Provider | Status | Signature | Timestamp check | Notes |
|---|---|---|---|---|
mock |
✅ built-in | X-Mock-Signature: valid |
No | Reference implementation. Not for production. |
github |
🚧 planned | X-Hub-Signature-256 HMAC-SHA256 |
No | Simplest real provider. Good first contribution. |
stripe |
🚧 planned | Stripe-Signature HMAC-SHA256 |
Yes, 300s | Signs {timestamp}.{body}. |
slack |
🚧 planned | X-Slack-Signature HMAC-SHA256 |
Yes, 300s | Signs v0:{timestamp}:{body}. |
shopify |
💡 wanted | X-Shopify-Hmac-Sha256 base64 |
No | Open an issue if you want it. |
Want a provider that isn't listed? Open a provider request. Implementing one is a well-scoped first contribution — see CONTRIBUTING.md.
webhookd <provider> [flags]
webhookd --list
webhookd --version
| Flag | Default | Description |
|---|---|---|
--secret-env |
"" |
Name of the environment variable holding the signing secret |
--port |
8080 |
HTTP port |
--host |
127.0.0.1 |
Bind address. Use 0.0.0.0 only behind a proxy you control. |
--path |
/<provider> |
Endpoint path |
--pretty |
off | Human-readable output instead of JSONL |
--max-body |
2097152 |
Max request body in bytes (2MB) |
--timeout |
10 |
Read and write timeout, seconds |
--list |
— | Print all registered providers and exit |
--version |
— | Print version and exit |
Secrets are never passed on the command line. --secret-env names the environment
variable that holds the secret; the value is read from the environment.
export STRIPE_WEBHOOK_SECRET=whsec_...
webhookd stripe --secret-env STRIPE_WEBHOOK_SECRETFor providers with a conventional variable name, the subcommand supplies the default, so the flag can be omitted:
export GITHUB_WEBHOOK_SECRET=...
webhookd github # reads GITHUB_WEBHOOK_SECRET automaticallyConfig resolution order is flag → environment → default, so an explicit
--secret-env always wins.
GitHub, pipe pull request titles into a file:
export GITHUB_WEBHOOK_SECRET=your_secret
webhookd github | jq -r 'select(.event=="pull_request") | .payload.pull_request.title' >> prs.txtStripe, filter for successful payments and sum them:
export STRIPE_WEBHOOK_SECRET=whsec_...
webhookd stripe \
| jq -c 'select(.event=="payment_intent.succeeded") | .payload.data.object.amount' \
| awk '{s+=$1} END {print s/100 " USD"}'Pretty mode for a live demo:
webhookd github --prettygithub
──────────────────────────────────────
✓ Signature verified
Event: pull_request
Delivery ID: 8a3f1b2c-...
Received: 2026-09-15T19:44:03Z
{
"action": "opened",
"number": 421
}
The header line is the provider's lowercase name. The core does not know how to
title-case github into GitHub, and it does not pretend to.
Custom path behind a reverse proxy:
webhookd stripe --path /webhooks/stripe --host 0.0.0.0 --port 9000Every verified event produces exactly one JSON object on stdout, one per line. The shape is identical across every provider:
{
"provider": "stripe",
"verified": true,
"event": "payment_intent.succeeded",
"id": "evt_123",
"delivery_id": "",
"received_at": "2026-09-15T19:42:13Z",
"payload": {}
}| Field | Type | Description |
|---|---|---|
provider |
string | Lowercase provider name |
verified |
bool | Always true on stdout — failures never reach output |
event |
string | Provider-specific event type |
id |
string | Event ID if the provider supplies one, else "" |
delivery_id |
string | Delivery or request ID if supplied, else "" |
received_at |
string | ISO 8601 UTC timestamp of receipt |
payload |
object | Full JSON body, unmodified in structure and values |
All seven fields are always present. Fields without a value are emitted as "",
never omitted.
payload preserves key order, duplicate keys, and numeric precision exactly as
the provider sent them. The only change is whitespace normalisation: the value
is emitted compact on one line with insignificant whitespace removed and any
embedded newlines escaped, which is what makes the output line-oriented.
| Stream | Content |
|---|---|
| stdout | JSONL event lines, one per verified webhook. Nothing else. |
| stderr | Startup banner, errors, diagnostics, signature failures. |
This is the contract that makes piping work. If you ever see a non-JSON line on stdout, that is a bug — please report it.
An invalid signature produces a 401 to the sender and a line on stderr. Nothing
is written to stdout. The process keeps running and keeps listening.
webhookd: github: signature mismatch — 203.0.113.4
| Condition | Status | Body |
|---|---|---|
| Verified | 200 OK |
{"ok": true} |
| Invalid signature | 401 Unauthorized |
{"error": "signature verification failed"} |
| Unknown provider | 404 Not Found |
{"error": "unknown provider"} |
| Wrong method | 405 Method Not Allowed |
{"error": "method not allowed"} |
| Body too large | 413 Payload Too Large |
{"error": "request body too large"} |
| Bad content type | 415 Unsupported Media Type |
{"error": "unsupported content type"} |
| Malformed JSON body | 500 Internal Server Error |
{"error": "internal error"} |
GET /health
{"status":"ok","version":"0.1.0"}Always 200. No dependency checks. No metrics.
webhookd is designed to be safe by default on a developer machine and explicit
when exposed.
- Bind address defaults to
127.0.0.1. Reaching it from outside requires--host 0.0.0.0, which is a deliberate choice. - Signature verification is constant-time. Every provider uses
hmac.Equal(). - Timestamps are enforced. Providers that send a signed timestamp reject requests older than 300 seconds.
- Body size is capped. Default 2MB, enforced during the read via
http.MaxBytesReader— an oversized body is never fully buffered. - Read and write timeouts are 10 seconds. Connections cannot hang the process.
- Secrets never appear on the command line. Only the name of the environment
variable is passed via
--secret-env.
If you run webhookd on a public host, put it behind a reverse proxy that
terminates TLS, and set --host 127.0.0.1 so only the proxy can reach it.
To report a vulnerability, see SECURITY.md. Do not open a public issue.
Requirements: Go 1.25+, make, optionally golangci-lint.
git clone https://github.com/0xProgress/webhookd
cd webhookd
make build # → bin/webhookd
make test # go test -race -count=1 ./...
make check # fmt + vet + lint + test — what CI runsCross-compile for all supported platforms with GoReleaser:
make releaseDocker image:
docker build -t webhookd .
docker run --rm -p 8080:8080 -e GITHUB_WEBHOOK_SECRET=... webhookd githubThe most impactful contribution is adding a provider. It is a well-scoped, well-documented process:
- Find or open the provider issue.
- Read CONTRIBUTING.md.
- Implement against the
Providerinterface underproviders/<name>/. - Add
cmd/<name>.go, write tests, write the doc file, runmake check. - Open a PR.
Read CONTRIBUTING.md before you start. It covers the provider process step by step, the commit message format, and the self-review checklist.
Small fixes — typos, doc clarifications, obvious bugs — can go straight to a PR. Everything else should start with an issue.
webhookd is deliberately small. The core knows nothing about any specific provider.
It captures the raw body, hands it to a registered provider, and writes the result.
Three decisions shape everything else:
- Raw body first. The bytes are captured before anything else touches the
request and are never re-encoded. Verification and parsing see the same bytes,
and
payloadpreserves them — key order, duplicate keys, and numeric precision all survive to the consumer. - stdout is data, stderr is everything else. This is what makes
| jqwork without filters or2>/dev/null. - Providers are leaf nodes. A provider verifies a signature and extracts four strings. It does not decide what an event means. That is the caller's job.
Read docs/architecture.md for the full picture.
MIT © 0xProgress