Webhooks¶
Webhooks push Venturi events to your endpoints the moment they happen — so you react to attribution, coverage, budget, and connector changes without polling the REST API. Every delivery is HMAC-signed, every endpoint is ownership-verified and egress-guarded at registration, and failed deliveries are retried on a bounded backoff before dead-lettering. The webhook subsystem runs fully off the synchronous gateway path, so delivery never blocks attribution.
Event catalog¶
Venturi publishes a versioned event catalog (in OpenAPI/AsyncAPI form). Each event type is independently subscribable:
| Event type | Fires when |
|---|---|
attribution.materialized |
A new attribution result is materialized into the read model. |
attribution.disputed |
An attribution is disputed. |
attribution.override.applied |
A manual override is applied to an attribution. |
coverage.degraded |
A pathway's coverage degrades. |
coverage.restored |
Coverage is restored. |
degradation.state.changed |
The serving degradation_state changes (e.g. heuristic fallback engaged or cleared). |
export.completed |
An export job finishes; the result is ready to download. |
export.failed |
An export job fails. |
anomaly.detected |
An anomaly is detected in attributed consumption. |
budget.threshold.crossed |
A budget crosses a 75% / 100% / 150% threshold. |
connector.health.changed |
A connector's health changes. |
apikey.rotated |
An API key is rotated. |
Event envelope¶
Every event shares a stable envelope. The envelope keys
id, type, created_at, tenant_id, and sequence are frozen for the life
of a major event-schema version.
{
"id": "evt_01HF9XYZ...",
"type": "budget.threshold.crossed",
"created_at": "2026-06-03T17:42:10Z",
"tenant_id": "tnt_01H...",
"sequence": 48213,
"data": {
"budget_id": "bdg_01H...",
"threshold": 100,
"period_to_date_usd": 51230.44,
"ceiling_usd": 50000.00
},
"interpretation": {
"stage_origin": "stage_a",
"confidence_band": "high",
"degradation_state": "none"
}
}
datais a typed payload that references canonical schemas. Where the payload is a customer-visible attribution result, it carries the sameinterpretationevidence card as the REST API.- No event payload ever contains prompt or completion content. Payloads are scrubbed of content keys.
Treat id as the idempotency key
Delivery is at-least-once, so the same event may arrive more than once.
Deduplicate on id. Strict global ordering does not apply because
attribution materialization is concurrent; instead, events within one
subscription stream carry a monotonically increasing sequence (and
created_at) so you can order deterministically.
Registering an endpoint¶
Register an endpoint with POST /api/v1/webhooks, selecting the event types you
want. Registration requires the admin:config scope.
curl -X POST https://<your-venturi-instance>/api/v1/webhooks \
-H "Authorization: Bearer $ARGMIN_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/venturi",
"events": ["budget.threshold.crossed", "export.completed", "anomaly.detected"]
}'
{
"id": "whk_01HF9...",
"url": "https://hooks.example.com/venturi",
"status": "pending",
"events": ["budget.threshold.crossed", "export.completed", "anomaly.detected"]
}
A new endpoint starts in pending and receives zero deliveries until it
passes verification.
Ownership verification¶
To prove you control the endpoint, Venturi sends a signed webhook.verification
event carrying a one-time challenge. Echo the challenge back (or confirm
out-of-band in the admin console) and the endpoint transitions
pending → verified. No production event is ever delivered to an unverified
endpoint.
# Minimal verification handler
@app.post("/venturi")
def handle(request):
body = request.json()
if body["type"] == "webhook.verification":
return {"challenge": body["data"]["challenge"]}
# ... handle production events
Egress safety (SSRF protection)¶
Your endpoint URL must be https and must not resolve to a private,
loopback, link-local, or cloud-metadata address (for example
169.254.169.254, 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12,
192.168.0.0/16, IPv6 ::1, fc00::/7, fe80::/10). A non-conformant URL is
rejected WEBHOOK_URL_REJECTED (422) before it is stored, and the attempt is
audited. Venturi re-resolves the URL at every delivery, so an endpoint that later
re-resolves to a private address is refused with no connection (DNS-rebind
defense) and the event is dead-lettered. Redirects to disallowed ranges are not
followed.
Verifying signatures¶
Every delivery carries an Venturi-Signature header:
The signature is v1 = HMAC-SHA256(secret, "<t>.<raw_body>"), computed with the
per-endpoint signing secret Venturi returns at registration. Verify it on every
request and reject deliveries whose timestamp t is outside your tolerance
window — that rejects replays.
import hmac, hashlib, time
def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, sig = parts["t"], parts["v1"]
if abs(time.time() - int(t)) > tolerance:
return False # reject replay
expected = hmac.new(
secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, sig)
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(
secret: string,
header: string,
rawBody: string,
toleranceSec = 300,
): boolean {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=") as [string, string]),
);
const t = Number(parts.t);
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
Verify the raw body
Compute the HMAC over the exact bytes you received, before any JSON parsing or reserialization. The SDKs ship a verification helper so you do not reimplement this.
Secret rotation¶
Rotating an endpoint's signing secret uses an overlap window, so deliveries in flight verify under either the old or new secret. Rotate without dropping events.
Retries and dead-lettering¶
A delivery that returns a non-2xx status or times out is retried with
exponential backoff and full jitter over a bounded schedule (up to roughly 24
hours). Retries preserve the original event id, so your deduplication still
holds. After the final attempt the event is dead-lettered and surfaced in the
delivery log.
| Behavior | Contract |
|---|---|
| Success | A 2xx response acknowledges the delivery. |
| Transient failure | Non-2xx or timeout → retried on backoff with the same id. |
| Exhausted | Dead-lettered after the final attempt; visible in the delivery log. |
| Isolation | Webhook delivery is async and never blocks attribution materialization. |
Delivery log and replay¶
Every delivery attempt is logged — event id, endpoint, attempt count, response
status, latency, and outcome — and retained for the audit window. Admins can
inspect the log and replay any event or range to an endpoint on demand.
Replayed deliveries are flagged replayed=true and re-signed with a current
timestamp. Replay requires the webhooks:manage scope and is audit-logged.
Building a receiver¶
A minimal, production-shaped receiver:
- Respond to
webhook.verificationby echoing thechallenge. - Verify the
Venturi-Signatureon every request; reject on failure or skew. - Deduplicate on
id; order withsequencewhere ordering matters. - Return
2xxquickly and process asynchronously — slow handlers trigger retries.
A reference webhook-receiver sample app is published per SDK; see SDKs and build it first against the sandbox.
Stability¶
The event catalog is governed by the same backward-compatibility discipline as
the REST contract: within a major event-schema version, no event payload field
is removed, renamed, retyped, or narrowed, and no event type is removed. New
event types and new optional payload fields are additive — design your handlers
to tolerate unknown fields. See
versioning and rate limits.