How outbound webhook delivery is structured, signed, and kept decoupled from the emitters. Public usage lives in reference/webhooks.md; this page is the design rationale + invariants for contributors.
emitter (ingest route / PolicyWriter)
│ depends only on the WebhookPublisher Protocol (rag-core)
▼
WebhookDispatcher (rag-webhooks) ──list_for_event──▶ SubscriptionStore SPI (rag-core)
│ sign body per subscription (HMAC + timestamp)
│ retry with backoff
▼
WebhookSender SPI (rag-core) ──▶ HttpWebhookSender (rag-webhooks) ──▶ subscriber URL
Three packages, one acyclic graph:
rag-coreowns the wire types (WebhookEvent,WebhookSubscription,WebhookDelivery), the two SPIs (SubscriptionStore,WebhookSender) + their noop impls, and the structuralWebhookPublisherProtocol.rag-webhooksowns the delivery engine: signing, retry policy, the HTTP sender, and theWebhookDispatcher(the concreteWebhookPublisher). Depends only onrag-core+httpx.- emitters (the gateway ingest route,
rag-policy'sPolicyWriter) depend only on theWebhookPublisherProtocol — never onrag-webhooks.
Events originate in several packages (ingest, policy, later eval). If each
imported rag-webhooks, the dependency graph would fan out and those packages
would carry a transport dependency. Instead they depend on the
WebhookPublisher Protocol in rag-core and receive a publisher by injection;
the gateway is the one place that wires the concrete WebhookDispatcher. This
mirrors the Retriever / GraphAdapter structural-Protocol pattern used
elsewhere, and lets an emitter be tested with a recording publisher.
A webhook must never delay or break the work that produced the event. So emission is fire-and-forget:
- the ingest route calls
dispatcher.schedule(ctx, event)— a backgroundasynciotask whose errors are swallowed + logged; PolicyWriterschedules its own background task on deny.
A failing subscriber, a timeout, a delivery exception — none of these surface to the ingest response or the policy decision.
signing.py computes HMAC-SHA256(secret, "<timestamp>.<body>") and ships it as
X-AgentContextOS-Signature: t=<ts>,v1=<hex>. Binding the timestamp into the
signed string (not signing the body alone) lets verify() reject deliveries
outside a freshness window — the replay defence. Verification uses a
constant-time compare. The same module is used by the dispatcher (build_headers)
and handed to subscribers (verify) so there's one definition of the scheme.
WebhookDispatcher._deliver runs the attempt loop per subscription: sign →
WebhookSender.send → record a WebhookDeliveryAttempt; on a non-2xx /
transport error, back off (RetryPolicy.backoff_for) and retry up to
max_attempts. The sender never raises — a timeout or refused connection is
captured in the WebhookSendOutcome so the dispatcher owns the retry decision.
Delivery is at-least-once with a stable event id (ADR-0018).
The dispatcher owns no transport or persistence — both are injected SPIs — so
its fan-out / retry / signing logic is unit-tested against the in-memory noop
SPIs with an injected clock (stable signatures) and a no-op sleep (no real
backoff delay). No network, no database, deterministic.
WebhookSubscription stores the HMAC secret, but redacted() masks it. The
management route returns the unmasked secret only in the create response
(generating one if the caller didn't supply it); list/detail return the masked
copy. Subscribers copy the secret once at creation (the Stripe model).
SubscriptionStore is tenant-scoped via ctx: list_for_event only returns
subscriptions in ctx.tenant_id, and get returns None for a cross-tenant id
(→ 404 at the route). A tenant can neither receive another tenant's events nor
probe for their subscriptions.
- New emitter depends on the
WebhookPublisherProtocol, notrag-webhooks? - Emission is fire-and-forget (never blocks / fails the originating call)?
- New event type has an
events.pybuilder with a documenteddatashape? - New transport implements
WebhookSenderand never raises on a transport error? - Signing changes keep
build_headersandverifyin lock-step? - Tenant scoping preserved (no cross-tenant delivery / probing)?
- reference/webhooks.md — usage + API + config.
- ADR-0018 — the decision + deferrals.