Webhooks
Register a webhook via POST /webhooks and
fault_detector.py will POST a JSON payload to it every time a fault
opens or resolves. Delivery runs in its own daemon thread per event, off
whichever thread detected the transition (the MQTT callback thread for
Cur-mode rules, the main thread for the His-mode sweep) — a slow or dead
webhook URL must never stall fault evaluation for every other point
behind it.
Code
filter.rule_id is the id POST /fault/rules returned when you created
the rule — not the name you chose for it. This is easy to get wrong
because the two examples on this site look like they'd pair up (a rule
named zone-temp-out-of-range, a webhook filtered to
"rule_id": "zone-temp-out-of-range") — they don't. A name-shaped value
here matches nothing: the webhook registers successfully (201, no
error) and then silently never fires, with no signal anywhere in the API
telling you why. Copy the actual id from the rule-creation response.
secret is returned once, in this creation response only — every later
GET /webhooks redacts it to null.
Signing
Every delivery carries an X-Baler-Signature header:
sha256={hmac-sha256 hex digest}, keyed by the secret you supplied at
registration. The critical detail if you're writing a receiver: the
signature is computed over the exact bytes sent on the wire, not a
re-serialization of the payload. The sender deliberately does
requests.post(..., data=body, ...) rather than json=payload, because
two independent json.dumps() calls on the same dict aren't guaranteed
to produce byte-identical output (key ordering, float formatting). Your
receiver has to make the same choice independently — verify the HMAC
against the raw request body as received, then parse it, not the
other way around. Verifying against a re-serialized copy is a common,
subtle signature-verification bug this design avoids on the sending side,
but only if the receiving side gets it right too.
Retries and failure handling
deliver() retries up to 3 times with exponential backoff (no wait, then
1s, then 2s) and a 5-second timeout per attempt. Any non-2xx response
counts as a failure — there's no distinction between "transient, worth
retrying" and "permanently dead, retrying is pointless." After 5
consecutive failures, a webhook is automatically disabled
(disabled=true in Postgres) and stays that way until a human calls
POST /webhooks/{id}/enable — there's no scheduled
re-enable. If a webhook seems to have gone silent,
GET /webhooks includes disabled, consecutive_failures,
and last_error for every registered webhook — check those before
assuming nothing is faulting. A webhook that's never had a delivery
attempt shows disabled: false, consecutive_failures: 0,
last_error: null.
The 5-failure counter increments once per delivery attempt recorded by
fault_detector.py — which is once per fault-transition event, not
once per HTTP request. deliver() already retries a single event's
delivery up to 3 times internally before reporting failure, so 5
consecutive events (not 5 raw HTTP attempts, which could be as many
as 15) is what actually trips the threshold.
Filtering
filter: { "rule_id": "..." } scopes a webhook to one rule's events —
rule_id here is the rule's generated id, not its name (see above).
Omit or set filter: null to receive every fault transition from every
rule. There's currently no OR-list or point-level filtering — if you need
a webhook that only cares about a subset of rules, register one webhook
per rule rather than expecting a single registration to filter across
several.
SSRF protection
url must use https:// and resolve to a public, non-reserved address.
webhooks.validate_url() rejects loopback, private (10.0.0.0/8,
172.16.0.0/12, 192.168.0.0/16), link-local, reserved, and multicast
targets — so a registered webhook can't point at internal infrastructure
like localhost:5433 (Postgres) or oxigraph:7878. This check runs
twice: once at POST /webhooks registration (fail fast), and again on
every delivery attempt in deliver() — a hostname that resolved to a
public address at registration time could resolve to 127.0.0.1 by the
time a retry fires (DNS rebinding), and only re-checking at send time
actually closes that gap.
This is enforced regardless of who's calling — the
gateway's JWT auth is a checkpoint at the network edge,
not what makes this safe. Any caller who can reach POST /fault/webhooks
gets the same validation.
Local testing: set BALER_ALLOW_PRIVATE_WEBHOOKS=1 in .env
for fault_api/fault_detector to disable this check entirely — both the
loopback/private-address rejection and the https://-only requirement
above (a plain http:// URL is accepted once this is set). It's a
deployment-level env var, not a per-webhook field, so it can't be toggled
through the API itself — never set it in production. If your receiver
runs on the host and fault_detector runs in a container (the default
docker compose setup), point the webhook at
http://host.docker.internal:<port>/, not http://127.0.0.1:<port>/ —
127.0.0.1 from inside the container is the container's own loopback,
not the host's.

