A fail-closed inbound webhook router. One ingress for every webhook you receive, with per-source verification declared in YAML, durable delivery, and a dead-letter queue.
Self-hosting a handful of services means receiving webhooks from a handful of vendors, and each one signs differently — or, like Grafana, does not sign at all. The path of least resistance is a small receiver per producer. Do that three times and you have three processes, three verification designs, and no way to answer "did that delivery actually arrive?"
The failure mode is not hypothetical. This project was written to replace three such receivers, one of which contained:
def verify_signature(body: bytes, signature: str) -> bool:
if not PLANE_WEBHOOK_SECRET:
return True # no secret configured, skip verificationThat endpoint was bound to 0.0.0.0. An unset environment variable turned signature
verification into an accept-anything endpoint, and nothing about the running service looked
wrong. "Verification skipped" is the outcome that must never be reachable — and it is what
this project is built to eliminate.
- Verifies every source, fail-closed. HMAC-SHA256 (hex or base64, any header, any prefix), bearer token, HTTP Basic. An unset secret disables that source and rejects — it never falls through to accepted.
- Config, not code. Adding a source is a YAML entry. Secrets are referenced by environment
variable name, never by value, so
config.ymlis safe to commit. - Never loses an event. SQLite in WAL mode: dedup on
(source, delivery_id), retry with exponential backoff, a dead-letter queue when attempts are exhausted, and re-queue of anything in flight when the process restarts. - One container, one volume. No Postgres, no Redis, no broker.
flowchart LR
GH[GitHub] --> IN
GR[Grafana] --> IN
OT[Anything else] --> IN
subgraph doorman [webhook-doorman]
IN[["POST /webhook/<source>"]] --> V{verify}
V -->|reject| R[401 / 503]
V -->|ok| P[parse + redact]
P --> DB[(SQLite WAL<br/>events · deliveries · dlq)]
DB --> W[delivery worker<br/>backoff + retry]
end
W --> S1[matrix]
W --> S2[ntfy]
W --> S3[vikunja_task]
W --> S4[http]
W --> S5[discord]
W --> S6[slack]
W --> S7[apprise]
The request handler's job ends at the database. Dispatch belongs to the worker, so a slow sink never holds a producer's connection open and a sink that is down does not turn into a lost event.
curl -O https://raw.githubusercontent.com/TadMSTR/webhook-doorman/main/config.example.yml
cp config.example.yml config.yml && $EDITOR config.yml # declare your sources and sinks
echo 'GITHUB_WEBHOOK_SECRET=...' > .env # secrets live here, never in YAML
docker run --rm -p 127.0.0.1:8080:8080 \
-v "$PWD/config.yml:/config/config.yml:ro" -v doorman-data:/data \
--env-file .env ghcr.io/tadmstr/webhook-doorman:0.1.1docker compose up -d with the bundled docker-compose.yml does the same with hardened
defaults. GET /health reports which sources are live and which are disabled, and why — and
answers 503 when no source is enabled or the store is unreachable, so the container's health
status means something. See Health.
Sources, sinks, verification strategies and the guard rails on unverified sources are
documented in docs/configuration.md. Worked configs are in
examples/, all of which CI parses on every pull request.
webhook-doorman --config config.yml --checkThe exposure model, the verification guarantees and the content-safety layer for agent-facing destinations are documented in docs/security.md. To report a vulnerability, see SECURITY.md.
Structured JSON logs to stdout have always been there. As of 0.3.0 there are numbers too.
Prometheus text format, no prometheus_client dependency. Unauthenticated by default — that is
the scrape convention, and requiring a token breaks a stock scrape_config. It exposes your
source and sink names and your traffic volume; it never exposes a payload, a header or a
secret. Deny it at the reverse proxy, alongside /admin/. Set metrics.token_env if you
want a bearer gate and can live with a non-standard scrape config.
| Metric | Type | Labels |
|---|---|---|
webhook_doorman_events_received_total |
counter | source |
webhook_doorman_events_deduplicated_total |
counter | source |
webhook_doorman_verification_failures_total |
counter | source, strategy |
webhook_doorman_requests_rejected_total |
counter | source, reason |
webhook_doorman_delivery_attempts_total |
counter | sink, outcome |
webhook_doorman_delivery_latency_seconds |
histogram | sink |
webhook_doorman_events_stored |
gauge | — |
webhook_doorman_deliveries |
gauge | status |
webhook_doorman_dlq_size |
gauge | — |
webhook_doorman_build_info |
gauge | version |
The gauges are point-in-time table counts and can go down when the retention sweep runs, which
is why none of them is named _total. The counters reset when the process restarts — correct
Prometheus semantics, and process_start_time_seconds is exported so a scraper can tell a reset
from a real drop.
webhook_doorman_verification_failures_total is the one to alert on. For a fail-closed router
the rejection rate is the security signal: a rise means something is probing an endpoint.
Lists dead-lettered deliveries, newest first, behind the same bearer token as replay. This is
how you find the event_id to hand to POST /admin/replay/{event_id}.
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
'http://localhost:8080/admin/dlq?limit=20'It returns failure metadata — event_id, source, sink, attempt, response_code, error,
exhausted_at — and not the payload. Pass the next_before_id from one page back as
before_id to get the next; that cursor is stable even while the retention sweep is deleting
rows underneath you.
Optional, off unless configured, and behind an extra:
pip install 'webhook-doorman[otel]'
export OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318
export OTEL_SERVICE_NAME=webhook-doorman # optionalThe published Docker image already includes the extra, so there it is just the environment variable. Setting the endpoint without the extra installed logs a warning at boot and the router runs normally — telemetry is not worth taking a router down for. Spans carry only config-derived and structural attributes; no payload content, headers or rendered template output are ever exported.
Enabling tracing also enables OTel's automatic httpx instrumentation, which records the full
destination URL. For Discord and Slack that URL is the credential, so every resolved secret is
scrubbed from span attributes before export. Declare any credential-bearing URL through *_env
(webhook_url_env, key_env, url_env) rather than inline — an inline url: is not a resolved
secret and is redacted nowhere.
The consumer-side projects worth knowing about, and where this one differs.
| Project | Stack | Verification | Persistence |
|---|---|---|---|
| webhook-doorman | Python, SQLite | Per-source, declarative, fail-closed | Event log, dedup, retry, DLQ, replay |
| event-bridge | Python, SQLite | None — its README says to do HMAC "at the application layer" | Persist, retry, DLQ, dashboard |
| notify-proxy | Python, SQLite | Per-destination filters, not per-source verification | SQLAlchemy store |
| adnanh/webhook | Go, stateless | Declarative trigger rules with HMAC | None — fire and forget |
| WebhookHub | — | — | Inspect, replay, forward |
| WebhookX | Go, Postgres + Redis | Multi-tenant, license-gated | Full |
The design borrows shapes from several of them — event-bridge's single-container SQLite
persist-retry-DLQ, adnanh/webhook's declarative hook definitions, WebhookHub's replay. What
none of them offer is the thing this exists for: every one either treats the ingest endpoint as
unauthenticated by design or ties verification to a specific vendor.
Sending webhooks rather than receiving them is a different problem — use Convoy or Svix.
- Outbound webhook delivery. This is a consumer-side router. If you need to send webhooks with delivery guarantees to your own users, use Convoy or Svix.
- A scripting engine. Templates are Jinja2 in a sandboxed, text-only environment. For a service whose entire value is fail-closed verification, an in-process script sandbox is the wrong attack surface to take on.
- Multi-tenancy. One operator, one config file.
Full index: docs/index.md.
| Document | Contents |
|---|---|
| docs/configuration.md | Sources, sinks, verification strategies, guard rails on unverified sources |
| docs/deployment.md | Exposure, file-permission traps, reverse proxy, migrating from an existing receiver |
| docs/security.md | Exposure model, verification guarantees, the content-safety layer |
| docs/security-audit.md | Audit history, findings and remediation across five reviews |
| ARCHITECTURE.md | Design decisions, extension points, what is protected and what is not |
| SECURITY.md | Reporting a vulnerability, threat model boundaries |
| CONTRIBUTING.md | Adding a source, sink or strategy; code style; tests |
| examples/ | Worked configs, all parsed by CI on every pull request |
| docs/verification-enforcement.mmd | Every path from a request to admitted or refused |
| docs/delivery-lifecycle.mmd | Delivery states, retry, DLQ, crash recovery |
MIT — see LICENSE.