ADR-0024: Outbound webhooks
Metadata
- Status: Accepted
- Date: 2026-08-08
- Deciders: EagraΓ Clainne Team
- Related: ADR-0002/0009 (authorization layers), ADR-0016 (API keys β the show-once secret pattern), system rule 3 (self-contained)
Context
The household runs automation next to EagraΓ Clainne β Home Assistant on the same LAN wants to react when a job completes or a meal reminder fires. Today nothing leaves the server except push notifications to family devices. There is no way for another household system to observe events.
The concrete consumer is LAN automation. A generic third-party tier (delivery logs, durable queues, arbitrary endpoints) may come later, so the design must not paint over that door. Per system rule 3, targets are household infrastructure that an admin configures β EagraΓ Clainne never calls out on its own initiative.
Decision
A WebhookService (admin-only CRUD plus a synchronous Test RPC) stores
webhook records β name, URL, event patterns, HMAC secret β and a
dispatch seam in internal/webhook POSTs signed JSON envelopes to
matching URLs when events occur.
Event vocabulary
Two families share one flat namespace:
notify.<kind>β the seven semantic notification emissions (notify.job_completed,notify.reward_claimed, ...). These carry the context automation wants (actor, points, item title, meal name).mutation.<entity_type>.<create|update|delete>β every audited mutation. Coverage is structural: the dispatcher taps the audit sink, so a new feature that writes through thesvcseams emits webhook events with no extra work. Thenotificationentity is excluded, exactly as the audit log excludes it.
A webhook subscribes with a list of patterns. A pattern matches exactly,
or by prefix when it ends in * (notify.*, mutation.item.*, bare
* for everything). An empty list delivers nothing β a half-configured
webhook stays silent instead of receiving a firehose.
Payload
The body is an audit-shaped envelope: event name, RFC3339 timestamp,
actor reference (uid and name), entity type, entity uid, entity label,
and a small context map of event-specific scalars. It never contains
an entity snapshot. This is a security decision, not a convenience: the
sanitize interceptor runs only on the Connect stack, so the webhook path
must be safe by construction β hand-picked scalar fields only.
Signing
Each webhook has a random 32-byte secret, generated at creation and returned exactly once on the create response (the ADR-0016 pattern). The record stores the raw secret because HMAC needs it at send time. The sanitize scrub map clears it from every other response.
Each delivery carries three headers:
X-Eagrai-Clainne-Timestampβ unix seconds at send timeX-Eagrai-Clainne-Eventβ the event nameX-Eagrai-Clainne-Signatureβsha256=+ hex HMAC-SHA256 overtimestamp + "." + body, keyed by the webhook secret
A receiver verifies by recomputing the HMAC and comparing with constant time, and should reject timestamps outside a tolerance window (five minutes is a reasonable default). Receivers that do not care β Home Assistant's plain webhook trigger β can ignore the headers.
Rotation is delete-and-recreate. A RotateSecret RPC is deferred.
Delivery semantics
Delivery is best-effort and asynchronous, the same contract as push
notifications: one attempt plus five retries at 1s/5s/25s/60s/120s
backoff, 5 seconds timeout per attempt, in a goroutine detached from
the request (context.WithoutCancel). A server restart drops pending
deliveries. Ordering across events is not guaranteed β deliveries to a
slow endpoint do not hold up later events. Consumers must treat events
as re-fetch triggers, not as a replayable log.
After the final attempt the dispatcher stamps last_delivery_status
and last_delivery_at on the webhook record so the admin UI can show
"failing since X". The stamp is a raw table write with no mutation
trail, no audit entry and no announce β the same shape as the API-key
last_used_at stamp. The precedent holds: operational stamps are not
household history, and auditing them would create write feedback from
every delivery.
Authorization and surfaces
All five RPCs are admin-only in the PermissionMatrix. There is no
global enable toggle β zero configured webhooks already means zero
outbound traffic, unlike /mcp where the endpoint itself is the
surface. Web Admin carries full CRUD plus Test; the CLI carries
list/test/create/delete as maintenance operations. Android (no Admin
tier) and MCP (not assistant-relevant) deliberately omit it.
Consequences
- Home Assistant automations key off signed events without polling.
- New audited entities are covered automatically; new notify kinds need one decorator method β the compiler enforces it via the interface.
- A missed event is possible (restart, endpoint down past the retry window). Acceptable for automation; not acceptable for the future generic tier, which is why the outbox upgrade is named below.
- Every mutation performs one indexed list of the webhook table. At
household scale this is noise; a cached subscription set invalidated
by
changesis the optimization if it ever shows up in traces.
Deferred (the (b)-tier upgrade path)
- Durable outbox β a deliveries table plus worker with dead-letter state, for consumers that cannot miss events.
- SSRF allowlist β URL restrictions are unnecessary while only admins can configure targets; required before any less-trusted role can create webhooks.
- Full-entity payloads β must be designed against the scrub map, not bolted on.
- RotateSecret RPC and per-delivery logs.
Alternatives considered
- Tap
changes.Announceβ rejected: the announcement is a payload-less doorbell (entity name only), too coarse to build the envelope from. - Fan every event to every webhook, filter client-side β rejected: mutation noise hits every endpoint and the LAN pays for it.
- Durable outbox now β rejected: a table, a worker and a dead-letter state machine for a consumer that re-syncs by polling anyway. The in-memory retry catches the real failure mode (endpoint restart blips).
- Static bearer token instead of HMAC β rejected: HMAC costs ~20 lines, gives replay resistance, and makes the future generic tier correct from day one.