0032 — Webhook triggers: third Trigger source variant¶
- Status: Accepted
- Date: 2026-05-18
- Deciders: @Sinidious
- Related issues / PRs: v1.7 milestone; extends ADR-0006 (FastAPI surface), ADR-0012 (new event types), ADR-0030 (Trigger discriminated union + ProactiveRunner), ADR-0031 ("matcher coarse, prompt smart" principle).
Context¶
v1.5 shipped scheduled triggers (cron). v1.6 shipped HA event triggers (Home Assistant WS subscription). The natural third source is HTTP webhooks — operators wire any external system (n8n, IFTTT, GitHub, calendar services, custom shell scripts) by POSTing JSON to a CAESAR endpoint.
Webhooks aren't novel — IFTTT and n8n have existed for years. The question for CAESAR isn't "should we accept HTTP POSTs" — that's trivial. The question is how to fit webhooks into the trigger model we already built and how to authenticate inbound POSTs without inventing PKI.
Webhooks also close a documented gap from ADR-0031 §7: the HA WS subscription drops events during reconnect. Webhooks have durable delivery semantics (the sender retries on failure), so operators who care about reliability for a specific event can route it through a webhook source instead of an HA event subscription.
Decision¶
CAESAR v1.7 will ship WebhookSource as the third variant of
TriggerSource, expose a single FastAPI route
POST /v1/hook/{trigger_id} authenticated by per-trigger bearer
token, dispatch matching POSTs to ProactiveRunner as a
fire-and-forget background task, and surface the POST body to the
brain prompt as additional user-message context.
1 — Trigger shape extension¶
class WebhookSource(BaseModel):
kind: Literal["webhook"] = "webhook"
bearer_token: SecretStr # required; ≥32 chars after the prefix
TriggerSource = Annotated[
ScheduleSource | HASource | WebhookSource,
Field(discriminator="kind"),
]
Trigger.cooldown_seconds is reused unchanged — the same field that
suppresses repeat HA-event fires also suppresses repeat webhook fires
within the cooldown window.
2 — Endpoint¶
A single static route, registered at app startup:
POST /v1/hook/{trigger_id}
Authorization: Bearer <token>
Content-Type: application/json
<free-form JSON body>
Response codes:
- 202 Accepted — auth checked, trigger fired (asynchronously). Empty body. The sender doesn't wait for the brain run.
- 401 Unauthorized — missing or wrong
Authorizationheader. Audited aswebhook.unauthorized(without the supplied token). - 404 Not Found —
trigger_iddoesn't match any armed webhook trigger. Audited aswebhook.unknown_trigger. - 413 Request Entity Too Large — body exceeds 64 KiB. The audit log clamps strings at 16 KiB anyway (SR-008); rejecting at the edge keeps a wayward sender from filling Praetor's memory.
- 429 Too Many Requests — only when
cooldown_secondsis set AND the trigger is in cooldown. The body is dropped; the suppression coalesces into atrigger.suppressedaudit row the same way HA cooldown does.
The route is registered even when no webhook triggers are armed. This is intentional: a 404 for an unknown trigger is a stable contract; surfacing it as "endpoint doesn't exist at all" depending on config is hostile to operators debugging at 2am.
3 — Fire-and-forget dispatch¶
The route returns 202 as soon as auth + trigger-id checks pass. The brain run happens in a background asyncio task:
async def _route_handler(trigger_id: str, body: bytes, auth: str | None):
trigger = registry.get(trigger_id)
if trigger is None:
await audit.record("webhook.unknown_trigger", {...})
return 404
if not _bearer_matches(auth, trigger):
await audit.record("webhook.unauthorized", {"trigger_id": ...})
return 401
if dispatcher.is_in_cooldown(trigger):
dispatcher.record_suppression(trigger, body)
return 429
await audit.record("webhook.received", {"trigger_id": ...})
asyncio.create_task(dispatcher.fire(trigger, body))
return 202
Why fire-and-forget:
- LLM calls take seconds-to-tens-of-seconds; a webhook sender that waits for 202 will time out and retry, causing duplicate fires.
- The dispatcher is the right owner of fire ordering and cooldown state, not the HTTP request handler.
- Errors inside the brain run land in
trigger.error/tool.erroraudit rows; the sender doesn't see them. If you want delivery confirmation, look at the audit log.
4 — Body in the prompt¶
The dispatcher constructs the brain's user-message context as:
The trigger.prompt is the operator's instructions; the body is data the brain reads. The "matcher coarse, prompt smart" rule from ADR-0031 §3 carries over: the matcher is just "POST to this URL with this bearer"; the brain prompt decides what to do with whatever arrived. There is no JSON-path filter, no field-level matching, no templating syntax. Operators who want a webhook to be picky write the prompt to bail out:
prompt: |
A GitHub event arrived. If it isn't a PR being opened against
the main branch, reply "nothing to report". Otherwise summarise
via notify.
The brain has the full body in its user message and can decide.
5 — Auth: per-trigger bearer token¶
Each WebhookSource carries a bearer_token (Pydantic SecretStr
so it doesn't leak into logs). Verification:
- Header
Authorization: Bearer <token>is required. <token>is compared withhmac.compare_digestfor constant-time equality.- Mismatches emit a
webhook.unauthorizedrow with thetrigger_id(NEVER the supplied token; that's how leaked tokens end up in dashboard screenshots).
caesar init generates one fresh token per webhook trigger using
secrets.token_urlsafe(36) (288 bits of entropy). Operators rotate
by editing triggers.yaml and restarting Praetor.
No HMAC body signing in v1.7. Bearer over HTTPS defends the realistic homelab threats (random scanners, hostile peers, casual forgery). HMAC's marginal benefit (defends against a leaked token reused without the body the sender originally signed) is real but not worth the per-sender format quirks. We add HMAC in v1.8 IF a specific sender forces our hand (e.g. an operator wires a real GitHub webhook and wants signature verification).
Rate limiting is per-trigger cooldown — same field as HA events. There's no global webhook rate limit; the loopback-bind default (SR-001) is the only ambient defence against random scanners. Operators who expose CAESAR to the Internet via Tailscale/Cloudflare/proxy must rely on that proxy for global rate limiting.
6 — triggers.yaml¶
Flat-form, consistent with v1.5/v1.6:
triggers:
- id: n8n_calendar_invite
enabled: true
cooldown_seconds: 30
bearer_token: "wht_<48-char-random>"
prompt: |
A calendar invite arrived via n8n. The body has the event
details — summarise time, attendees, and (if you can tell)
whether it's worth flagging via notify.
The disambiguator extends:
cron→ scheduleevent_type→ ha_eventbearer_token→ webhook
Having two of these in one entry is an error (existing v1.6 check, expanded).
7 — Audit log¶
New event types:
webhook.received— one row per accepted POST. Carriestrigger_id, body size, source IP (if available from FastAPI).webhook.unauthorized— bad/missing bearer. Carriestrigger_id, source IP. Not the supplied token.webhook.unknown_trigger— POST to an unknown id. Carries the path id, source IP.
trigger.fired / trigger.completed / trigger.error /
trigger.suppressed are reused unchanged.
8 — Reuse, not parallel implementation¶
ProactiveRunner (v1.5) handles the brain run unchanged — the
dispatcher constructs a Trigger with the body-augmented prompt and
hands it over. WebhookDispatcher shares the cooldown/suppression
shape from v1.6's HAEventDriver (per-trigger cooldown, coalesced
trigger.suppressed rows). All three drivers (Scheduler,
HAEventDriver, WebhookDispatcher) talk to the same runner and audit
log; they're independent on the input side and converge at the
brain.
Alternatives considered¶
- HMAC body signing as the default. Rejected for the homelab audience: per-sender format quirks make it gnarly to ship, and bearer over HTTPS defends the realistic threats. Reserved as a v1.8 add-on if a sender forces it.
- mTLS at a reverse proxy, no auth in CAESAR. Smallest CAESAR-side surface but hostile to operators without PKI. Most homelab operators don't run a CA. Reverse proxy can still do mTLS in front of CAESAR; the bearer in CAESAR is then belt-and-suspenders.
- JSON-path / JMESPath body matcher. Rejected for the same reason ADR-0031 rejected it for HA events: the brain prompt has full body access and can reason about it. The trigger should stay a coarse wake-up signal.
- Synchronous brain run inside the HTTP handler. Rejected: LLM latency would time out most webhook senders, causing duplicate deliveries. Fire-and-forget with the audit log as the diagnostics surface is the right shape.
- A queue (Redis / SQLite-backed) instead of
asyncio.create_task. Tempting for durability. Rejected for v1.7 because a Praetor restart only loses in-flight fires, not pending ones — webhook senders retry on failure, so durability lives in the sender, not in CAESAR. Reconsider if multi-Praetor HA ever lands. - Per-trigger URL paths the operator chooses (e.g.
/v1/hook/morning_brief_handledistinct from the trigger id). Rejected: trigger id is already a stable identifier, and adding a separate "URL slug" field is one more thing to keep in sync. - A "webhook payload schema" field that validates body shape. Rejected: bloats the YAML and shifts smarts away from the brain. If a sender posts garbage, the brain prompt can say "if this doesn't look like a calendar invite, reply nothing to report."
Consequences¶
Positive¶
- Closes the durability gap from ADR-0031 §7. Operators who want reliable delivery (water leak, smoke alarm) can route via webhooks where the sender owns retry.
- Single architectural shape across all three trigger sources; ProactiveRunner unchanged, audit log unchanged, policy engine unchanged.
caesar initgenerates a fresh token per trigger; the operator is never typing or pasting their own webhook secret. Reduces the "weak secret" failure mode.- The route exists even with no webhook triggers configured — consistent 404 contract for an operator probing during setup.
Negative¶
- The webhook endpoint is the first CAESAR surface explicitly intended for non-loopback exposure. Operators who expose CAESAR must understand SR-001 (the default is loopback-only). Documented loudly in the v1.7 operator guide.
- No replay protection in v1.7. A captured POST + bearer can be replayed forever. Acceptable for the homelab threat model; operators who care add timestamp + nonce signing in v1.8.
- Per-trigger bearer in
triggers.yamlmeans rotating one token needs a file edit + restart. Acceptable at homelab cadence.
Neutral¶
- One more YAML key (
bearer_token) and one more flat-form disambiguator. Same flat-vs-nested pattern as the existing variants. - 64 KiB body limit is a soft constant in the FastAPI route, not a configurable field. Operators who need bigger can raise an issue.
caesar initadds a third disabled example (webhook). Generated config now ships three examples — one per source kind — all disabled.
References¶
- ADR-0006 — FastAPI route surface.
- ADR-0012 —
webhook.*event types join the existingtrigger.*family. - ADR-0013 — proactive webhook fires hit the same policy engine as reactive runs.
- ADR-0028 —
notifyremains the obvious output for webhook-driven alerts. - ADR-0030 — Trigger discriminated union, ProactiveRunner, audit conventions.
- ADR-0031 — "matcher coarse, prompt smart" rule that v1.7 inherits.
- SR-001 — loopback-by-default bind is the default network exposure model.