The gateway POSTs a signed JSON envelope to subscriber URLs when domain events happen. This page is the public reference (event catalogue, payloads, signing, management API) and the technical reference (delivery semantics, package layout) for the webhook surface.
| Event type | Emitted when | Emitter (Step 3.9) |
|---|---|---|
ingest.completed |
a document finishes ingesting | gateway ingest route |
audit.policy_violation |
a policy decision denies an action | PolicyWriter |
drift.detected |
embedding / data drift detected | Phase 5 (event type defined now) |
eval.regression |
an eval run regresses | Phase 5 (event type defined now) |
Subscribers register a URL + the event types they want; every matching active subscription in the tenant receives the event.
{
"id": "evt_5f3c…",
"type": "ingest.completed",
"tenant_id": "acme",
"created_at": "2026-06-04T10:00:00Z",
"data": { "...": "event-specific payload" },
"request_id": "…",
"principal_id": "svc",
"trace_id": "…"
}data depends on type (e.g. an IngestResult dump for ingest.completed).
id is stable across retries — dedupe on it (see delivery semantics).
Each delivery carries these headers:
| Header | Value |
|---|---|
X-AgentContextOS-Signature |
t=<unix_ts>,v1=<hex_hmac_sha256> |
X-AgentContextOS-Timestamp |
the signed unix timestamp |
X-AgentContextOS-Event |
the event type |
X-AgentContextOS-Delivery |
the event id |
The HMAC is computed over <timestamp>.<raw_body> keyed by the subscription's
secret. Verify it (and reject replays) with the shipped helper:
from rag_webhooks import verify
ok = verify(raw_request_body, subscription_secret, signature_header, tolerance_s=300)tolerance_s rejects deliveries whose signed timestamp is outside the window
(replay protection); pass 0 to skip the freshness check.
At-least-once. A delivery is retried (default 3 attempts, exponential
backoff capped at 30s) until a 2xx or the ceiling, then marked failed and
logged. Because the event id is stable across retries, a subscriber may see
the same id more than once — make consumers idempotent and dedupe on id.
Emission is fire-and-forget: a slow or failing subscriber never delays or fails the originating request (an ingest still succeeds, a policy decision still returns) — delivery happens on a background task.
Header identity (X-Tenant-Id + X-Principal-Id, or Authorization: Bearer),
same as the other header-identity routes.
| Method + path | Purpose |
|---|---|
POST /v1/webhooks/subscriptions |
Create a subscription (secret returned once) |
GET /v1/webhooks/subscriptions |
List subscriptions (secrets masked) |
GET /v1/webhooks/subscriptions/{id} |
Describe one (secret masked) |
PATCH /v1/webhooks/subscriptions/{id} |
Update url / event types / description / active toggle (secret stays masked) |
DELETE /v1/webhooks/subscriptions/{id} |
Delete one |
POST /v1/webhooks/subscriptions/{id}/test |
Send a synthetic test event |
# create (returns the signing secret once)
curl -sX POST localhost:8000/v1/webhooks/subscriptions \
-H 'x-tenant-id: acme' -H 'x-principal-id: svc' \
-d '{"url":"https://example.test/hook","event_types":["ingest.completed"]}'
# fire a test delivery
curl -sX POST localhost:8000/v1/webhooks/subscriptions/whsub_…/test \
-H 'x-tenant-id: acme' -H 'x-principal-id: svc'event_types: [] means "all event types". The signing secret is generated
server-side if you don't supply one, returned in the create response, and masked
(whsec_••••) on every read thereafter.
webhooks:
enabled: true
max_attempts: 3
base_backoff_s: 0.5
max_backoff_s: 30.0
timeout_s: 10.0
subscriptions:
- tenant_id: acme
url: https://example.test/hook
event_types: [ingest.completed]
secret: ${ACME_WEBHOOK_SECRET}ragctl webhooks demo # sign + deliver a sample event in-process
ragctl webhooks demo --fail 2 # show the retry path
ragctl webhooks demo --event audit.policy_violationPrints the delivery result + the signed headers, so you can see the HMAC scheme without standing up a gateway.
- Wire types + SPIs (
rag-core):webhook_types.py(WebhookEvent/WebhookSubscription/WebhookDelivery/WebhookPublisherProtocol),spi/subscription_store.py,spi/webhook_sender.py, noop impls. - Delivery engine (
rag-webhooks):signing.py(HMAC),retry.py(RetryPolicy),sender.py(HttpWebhookSender),dispatcher.py(WebhookDispatcher— theWebhookPublisher),events.py(payload builders). - Emitters: the gateway ingest route schedules
ingest.completed;PolicyWriteremitsaudit.policy_violationon deny when a publisher is wired — both depend only on theWebhookPublisherProtocol.
- Add an event type — add a
WebhookEventTypevalue + anevents.pybuilder; emitters calldispatcher.publish(ctx, event). - Swap the transport — implement
WebhookSender(e.g. an SQS producer); inject it into the dispatcher. - Durable subscriptions — implement
SubscriptionStoreover Postgres; inject viabuild_app(subscription_store=…).
- architecture/webhooks.md — design + invariants.
- guides/webhooks-quickstart.md — 5-minute tour.
- ADR-0018 — the decision.