App reference

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 the svc seams emits webhook events with no extra work. The notification entity 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 time
  • X-Eagrai-Clainne-Event β€” the event name
  • X-Eagrai-Clainne-Signature β€” sha256= + hex HMAC-SHA256 over timestamp + "." + 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 changes is 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.