Ingress¶
The ingress is Hookaido's inbound HTTP listener that receives webhooks, authenticates them, and enqueues them into the durable queue.
Overview¶
When a webhook arrives:
- Path matching — find the first route whose path matches the request URL.
- Matcher evaluation — check additional match criteria (method, host, headers, etc.).
- Rate limit check — enforce global or per-route rate limits.
- Authentication — verify HMAC signature, basic credentials, or forward auth.
- Enqueue — durably persist the payload and headers into the queue.
- ACK — return
202 Acceptedto the webhook provider only after successful enqueue.
Route Matching¶
Routes are evaluated top-down, first match wins.
/webhooks/github { ... } # matches /webhooks/github and /webhooks/github/foo
/webhooks/stripe { ... } # matches /webhooks/stripe and /webhooks/stripe/events
Path matching rules:
- Matches the URL path only (query string is ignored).
"/path"matches/pathand/path/...at segment boundaries."/path"does not match/path-foo(segment boundary enforced).- Route paths must start with
/and must be unique. - Order matters: put the more specific path first.
/hookslisted before/hooks/githubswallows every request the latter was meant to handle, soconfig validaterejects that arrangement as unreachable. See Routing Semantics.
Additional Matchers¶
Matchers further narrow which requests a route accepts. All matchers within a route are ANDed:
/webhooks/github {
match {
method POST # HTTP method (case-insensitive; defaults to POST)
host "hooks.example.com" # exact host, "*", or "*.example.com"
header "X-GitHub-Event" "push" # exact header value match
header_exists "X-GitHub-Delivery" # header presence check
query "env" "production" # exact query parameter match
query_exists "token" # query parameter presence check
remote_ip "203.0.113.0/24" # source IP or CIDR
}
pull { path /pull/github }
}
| Matcher | Description |
|---|---|
method |
HTTP method (case-insensitive). Default: POST |
host |
Request host. Supports exact, * (any), *.example.com (subdomains only) |
header |
Exact header value (name is case-insensitive) |
header_exists |
Header must be present (any value) |
query |
Exact query parameter value |
query_exists |
Query parameter key must be present |
remote_ip |
Client IP or CIDR. IPv4 and IPv6 supported |
remote_ip behind a reverse proxy¶
remote_ip is compared against the transport peer address — what the socket
reports. X-Forwarded-For is ignored unless ingress.trusted_proxies is set.
That matters because Hookaido is very often deployed behind a TLS-terminating reverse proxy, including in the Docker examples in these docs. In that topology every request arrives with the proxy's address, so:
matches nothing and the route silently returns 404 for legitimate traffic.
Widening the range to the proxy's subnet makes traffic flow again — and now
matches every request the proxy forwards, from any origin. Both outcomes read
like a working IP allowlist in the config file; the second one is not.
Two ways out, in order of preference:
- Put the IP restriction in the proxy. It is the component that sees the real client address, and it is where the restriction belongs if the proxy is not yours to configure otherwise.
- Set
ingress.trusted_proxiesto the prefixes your proxy connects from. Hookaido then takes the right-mostX-Forwarded-Forentry that is not itself trusted as the client address forremote_ip. Requests from an untrusted peer keep their peer address and the header is ignored, so a direct client cannot spoof its way past the allowlist. Seeingress.trusted_proxies.
header and query compare their values in constant time. Matchers normally
only select a route — X-GitHub-Event: push is not a secret — but a query
matcher on a shared token was for a long time the only credential check available
to an event source whose entire configuration surface is a single URL field, so
the comparison is held to the same standard as the auth paths. Value lengths
remain distinguishable.
For that case, prefer auth query: it is an authenticator
rather than a matcher, so the route does not report as unauthenticated, and it
reaches secret_ref and the runtime secrets pool for rotation.
Named Matchers¶
Define reusable matchers at the top level:
@stripe-invoice {
method POST
header "Stripe-Event-Type" "invoice.paid"
}
/webhooks/stripe {
match @stripe-invoice
pull { path /pull/stripe }
}
Authentication¶
Each route can use one authentication method. Authentication runs before enqueue — rejected requests never enter the queue.
| Variant | What the source must be able to send |
|---|---|
auth hmac |
A signature header (plus timestamp/nonce depending on mode) |
auth basic |
An Authorization header |
auth forward |
Whatever the external checker expects |
auth query |
Nothing but the URL — the token is a query parameter |
HMAC Verification¶
Verifies webhook signatures with replay protection (timestamp + nonce + tolerance).
Shorthand:
With secret rotation:
Block form (full control):
/webhooks/github {
auth hmac {
secret env:HOOKAIDO_GITHUB_SECRET
# or: secret_ref "S1"
signature_header "X-Hub-Signature-256"
timestamp_header "X-Timestamp"
nonce_header "X-Nonce"
tolerance 5m
}
pull { path /pull/github }
}
String-to-sign: METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + hex(sha256(body))
Verification tries all secrets valid at the request timestamp (from the timestamp header), not just wall-clock time. This allows safe key rotation with overlapping validity windows.
A nonce is claimed when the signature verifies and becomes permanent once the request is durably enqueued. A request that is refused after verification — a 503 from queue backpressure, a 413 from oversized headers — releases the claim, so the sender's identical signed retry is accepted instead of being rejected as a replay for the rest of the tolerance window. A replay arriving while the first request is still in flight is rejected either way, and claims survive config reloads.
Provider mode (GitHub, Gitea/Forgejo, Stripe, Cituro):
For webhook providers with their own signature format, use provider mode. This verifies the provider's native signature:
/webhooks/github {
auth hmac {
provider github
secret env:GITHUB_WEBHOOK_SECRET
}
pull { path /pull/github }
}
/webhooks/gitea {
auth hmac {
provider gitea
secret env:GITEA_WEBHOOK_SECRET
}
pull { path /pull/gitea }
}
/webhooks/stripe {
auth hmac {
provider stripe
secret env:STRIPE_WEBHOOK_SECRET
}
pull { path /pull/stripe }
}
| Provider | Signature Header | Format | Signed payload | Replay protection |
|---|---|---|---|---|
github |
X-Hub-Signature-256 |
sha256=<hex> |
raw body | none (GitHub omits a timestamp) |
gitea |
X-Gitea-Signature |
<hex> |
raw body | none |
stripe |
Stripe-Signature |
t=<ts>,v1=<hex> |
<ts>.<body> |
5 min fixed tolerance |
cituro |
X-CITURO-SIGNATURE |
t=<ts>,s=<hex> |
<ts>.<body> |
5 min fixed tolerance |
stripe and cituro share the timestamped scheme Stripe invented; cituro differs only in header name and signature tag. Both accept several comma-separated <tag>=<hex> pairs, and any matching signature verifies the request — which is what makes Stripe's v0/v1 rotation work.
When provider is set, signature_header, timestamp_header, nonce_header, and tolerance are forbidden (compile error) — the format is fixed by the provider. Replay protection therefore applies to stripe and cituro only, and its 5-minute window is not configurable.
Basic Auth¶
Basic auth does not take secret references
Unlike auth token, auth hmac and secret blocks, basic-auth credentials
are compared literally. The env: / file: / vault: / raw: reference
syntax is not resolved here — use the {env.NAME} placeholder form shown
above, which is expanded at compile time.
Configs using reference syntax are rejected at compile time. Before that
check existed, auth basic "u" "env:PASSWORD" silently accepted the string
env:PASSWORD as the password.
Forward Auth¶
Delegates authentication to an external service:
With options:
/webhooks/custom {
auth forward "https://auth.example.com/check" {
timeout 5s
copy_headers "X-User-ID"
copy_headers "X-Org-ID"
body_limit 64kb
}
pull { path /pull/custom }
}
Behavior:
2xx→ allow, enqueue401or403→ deny- All other outcomes (transport errors, timeouts, 5xx) → fail closed with
503
Response headers specified in copy_headers are copied from the auth response into the stored envelope headers.
auth forwardis mutually exclusive withauth basicandauth hmac.
Query Token Auth¶
For event sources that can be given nothing but a URL:
Some sources have no header, no signing secret and no basic-auth field to configure — the UI has one "URL" input and that is the whole contract. This is common with telephony/PBX platforms, appliance webhooks and older ERP systems. None of the three variants above can be satisfied by such a source, so the token travels in a query parameter.
Shorthand and rotation:
auth query "t" "env:URL_TOKEN" # env / file / vault / raw ref
auth query "t" secret_ref "source-token" # pool-backed, rotatable
Repeating the directive with the same parameter name adds another accepted token, which is how a rotation gets an overlap window:
/webhooks/source {
auth query "t" secret_ref "token-v1"
auth query "t" secret_ref "token-v2"
pull { path /pull/source }
}
Behavior:
- A missing or wrong token →
404, the same answer an unmatched request gets. There is no realistic client here that benefits from a distinguishable auth error, and404does not confirm that the path exists. - Checked before the rate limiter and before the body is read, so a wrong token costs no queue work and cannot consume the route's token budget.
- The token is never written to the access log, the envelope, the queue, metrics labels or the delivery target. That is what makes a token in the query preferable to one in the path — the path is simultaneously queue key, access-log field, envelope trace and Prometheus label.
- Comparison is constant-time over SHA-256 digests, so neither the token length nor the number of configured tokens is observable.
- Rejections are reported with reason
authand status404in the ingress metrics, so an operator can still tell a wrong token from an unknown path. - A parameter with no usable secret fails compilation — commenting out the secret line during a rotation must not silently open the route.
auth queryis mutually exclusive withauth basic,auth hmacandauth forward. It also rejects amatch queryorquery_existson the same parameter: both would read it, the matcher runs first, and a wrong token would fall through to a later route instead of being rejected here.
Honest limitation. A URL token cannot be made replay-safe: there is no nonce
and no timestamp, so anyone who learns the URL can inject events. That is a
property of the source, not of Hookaido. Use auth hmac whenever the source can
sign — auth query is a gate against opportunistic traffic, not a signature.
Why not match query? Using the matcher as a credential check works and was
the only option before this variant existed. It reports the route as having no
authenticator at all to anyone auditing the config, though, and it cannot reach
secret_ref or the runtime secrets pool — so the token can only be rotated by
editing the config. Prefer auth query.
Why not a token in the path? The path is the queue key, an access-log field, an envelope trace value and a Prometheus label. A secret placed there ends up in all four.
Rate Limiting¶
Token-bucket rate limiting with global and per-route scopes.
Global (applies to all routes unless overridden):
Per-route override:
Over-limit requests receive 429 Too Many Requests.
Across a config reload, a bucket whose rps and burst are unchanged keeps its current token balance — it is not refilled. Only a limiter whose limits actually changed, or one belonging to a new route, starts full. This matters because reloads are frequent and not always deliberate: hookaido run --watch triggers one per config write, and every applied Admin API managed-endpoint mutation triggers one too. Refilling on each of those would make the effective limit unbounded at reload frequency.
Body and Header Limits¶
Ingress enforces size limits from defaults or per-route config:
max_body(default2mb) — payload size limit. Oversized →413.max_headers(default64kb) — total header size limit (including copied forward-auth headers). Oversized →413.
Enqueue Behavior¶
- Ingress ACKs the webhook provider (
202 Accepted) only after durable queue persistence. - If the queue is full (
queue_limits.max_depth), ingress returns429(withdrop_policy "reject") or silently drops the oldest item. - Queue key is the route path — all webhooks matching a route share the same queue.
Response Codes¶
| Status | Meaning |
|---|---|
202 |
Webhook received and enqueued |
400 |
Invalid request |
401 |
Authentication failed |
403 |
Forbidden (forward auth denied) |
404 |
No route matches the path |
413 |
Body or headers exceed size limits |
429 |
Rate limit exceeded or queue full |
503 |
Queue overload or forward auth failure |
On success the response body is {"status":"queued"} with Content-Type: application/json.