# Webhooks

Register a webhook via [`POST /webhooks`](/api/fault) 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.

```bash
curl -X POST localhost:8080/fault/webhooks \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/baler", "secret": "a-shared-secret", "filter": {"rule_id": "e38a2a2a84404bb4ae50f1fa88535eda"}}'
```

`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`](/api/fault) — there's no scheduled
re-enable. If a webhook seems to have gone silent,
[`GET /webhooks`](/api/fault) 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](/gateway-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.
