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 attribution 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. |
A new attribution result is materialized into the read model. |
attribution. |
An attribution is disputed. |
attribution. |
A manual override is applied to an attribution. |
coverage.degraded |
A pathway’s coverage degrades. |
coverage.restored |
Coverage is restored. |
degradation. |
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. |
A budget crosses a 75% / 100% / 150% threshold. |
connector. |
A connector’s health changes. |
apikey.rotated |
An API key is rotated. |
The catalog carries no enforcement event. An Active (Gate) signed deny or reroute recommendation reaches your enforcement point through the Active (Gate) enforcement integration you set up when you enable it, which defines its own delivery endpoint, payload fields, and signature verification key (see Governance modes).
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_01HF9DS262WSR4NB4T3TN2HGCM",
"type": "budget.threshold.crossed",
"created_at": "2026-06-03T17:42:10Z",
"tenant_id": "tnt_01HF774RGJ2YZB7V8TNEXESE0V",
"sequence": 48213,
"data": {
"budget_id": "bdg_01HF9EZAZACXFA3BXW3SQV1C5Y",
"threshold": 100,
"period_to_date_usd": "51230.44",
"ceiling_usd": "50000.00"
},
"interpretation": {
"stage_origin": "stage_a",
"confidence_band": "chargeback_ready",
"degradation_state": "none"
}
}
datais a typed payload that references canonical schemas. Money, energy, and carbon quantities are decimal strings, as in the REST API. 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 and supplying the endpoint’s signing secret as hmac_secret. Registration
requires the webhooks:manage scope. Generate the secret yourself from at least
32 random bytes and store it in your secret manager before you register: you
need it to verify every delivery, including the verification event.
# Generate the signing secret, then store it in your secret manager.
VENTURI_WEBHOOK_SECRET="$(openssl rand -hex 32)"
curl -X POST "https://<your-venturi-instance>/api/v1/webhooks" \
-H "Authorization: Bearer $VENTURI_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
--data-binary @- <<EOF
{
"url": "https://hooks.example.com/venturi",
"events": ["budget.threshold.crossed", "export.completed", "anomaly.detected"],
"hmac_secret": "$VENTURI_WEBHOOK_SECRET"
}
EOF
{
"id": "whk_01HF90SFQ6X0T6MTSYJ7X0JNZC",
"url": "https://hooks.example.com/venturi",
"status": "pending",
"events": ["budget.threshold.crossed", "export.completed", "anomaly.detected"],
"signing_secret_configured": true
}
Venturi keeps the signing secret in its secrets vault and never returns it: the
response confirms only signing_. A new endpoint starts
in pending and receives only the signed verification challenge until it passes
verification. Production events are delivered only after verification succeeds.
Ownership verification¶
To prove you control the endpoint, Venturi sends a signed webhook.
event carrying a one-time challenge. Verify the signature, then echo the
challenge back in the response body, as the handler below does, and the endpoint
transitions pending → verified. If your endpoint cannot echo it, confirm
out-of-band instead: submit the challenge value your endpoint received under
Settings / Admin / Notifications in the
admin console, or with
POST / and the body {"token": "<challenge>"}
(scope webhooks:manage, with an Idempotency-Key). No production event is
ever delivered to an unverified endpoint.
# Minimal Flask verification handler; define verify() from "Verifying signatures"
# above this handler in the same module.
import json
import os
from flask import Flask, request
app = Flask(__name__)
SECRETS = [os.environ["VENTURI_WEBHOOK_SECRET"]]
@app.post("/venturi")
def handle():
raw_body = request.get_data() # the exact bytes received
if not verify(SECRETS, request.headers.get("Venturi-Signature", ""), raw_body):
return "", 400
event = json.loads(raw_body)
if event["type"] == "webhook.verification":
return {"challenge": event["data"]["challenge"]}
return "", 200 # queue production events for asynchronous processing
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_ (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 a Venturi-Signature header:
Venturi-Signature: t=1717433130,v1=f9882f2ad730865ec0676f1249e80747417dd23bf2ad73702a1fd99d8775ac13
The signature is v1 = HMAC-SHA256(secret, "<t>.<raw_body>"), a lowercase
64-character hex digest computed with the per-endpoint signing secret you
supplied at registration. Verify it on every request and reject deliveries whose
timestamp t is outside your tolerance window; that rejects replays. During
secret rotation the header carries two v1 values, so both
examples collect every v1 value, accept a list of secrets, and succeed when any
signature matches any secret. Both return false instead of throwing on a
malformed header.
import hashlib
import hmac
import time
def verify(secrets: list[str], header: str, raw_body: bytes, tolerance: int = 300) -> bool:
timestamps, signatures = set(), []
for item in header.split(","):
key, separator, value = item.strip().partition("=")
if not separator or not value:
continue
if key == "t":
timestamps.add(value)
elif key == "v1":
signatures.append(value.encode()) # every v1, not only the last
if len(timestamps) != 1 or not signatures:
return False
timestamp = timestamps.pop()
if not (timestamp.isascii() and timestamp.isdigit()):
return False
try:
if abs(time.time() - int(timestamp)) > tolerance:
return False # reject replay
except (ValueError, OverflowError):
return False # reject timestamps outside the numeric conversion range
signed_payload = timestamp.encode() + b"." + raw_body
for secret in secrets:
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest().encode()
if any(hmac.compare_digest(expected, signature) for signature in signatures):
return True
return False
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(
secrets: string[],
header: string,
rawBody: Buffer,
toleranceSec = 300,
): boolean {
const timestamps = new Set<string>();
const received: Buffer[] = []; // every v1, not only the last
for (const item of header.split(",")) {
const separator = item.indexOf("=");
if (separator <= 0) continue;
const key = item.slice(0, separator).trim();
const value = item.slice(separator + 1).trim();
if (key === "t") timestamps.add(value);
else if (key === "v1" && /^[0-9a-f]{64}$/i.test(value)) received.push(Buffer.from(value, "hex"));
}
const timestamp = [...timestamps][0] ?? "";
if (timestamps.size !== 1 || received.length === 0 || !/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSec) return false;
return secrets.some((secret) => {
const expected = createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
return received.some(
(signature) => expected.length === signature.length && timingSafeEqual(expected, signature),
);
});
}
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¶
Rotate an endpoint’s signing secret under Settings / Admin / Credentials, or through the
API with a credential that holds webhooks:manage. Generate the new secret the
same way as the first one, then send it as hmac_secret together with the
endpoint’s current url and events, unchanged:
NEW_VENTURI_WEBHOOK_SECRET="$(openssl rand -hex 32)"
curl -X PATCH "https://<your-venturi-instance>/api/v1/webhooks/whk_01HF90SFQ6X0T6MTSYJ7X0JNZC" \
-H "Authorization: Bearer $VENTURI_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
--data-binary @- <<EOF
{
"url": "https://hooks.example.com/venturi",
"events": ["budget.threshold.crossed", "export.completed", "anomaly.detected"],
"hmac_secret": "$NEW_VENTURI_WEBHOOK_SECRET"
}
EOF
Venturi then opens an overlap window. During the window every delivery carries
two v1 signatures in its Venturi-Signature header, one computed with the
outgoing secret and one with the incoming secret:
Venturi-Signature: t=1717433130,v1=f35a4215d7501b33881b43d66ef98709f826596f3ec3d454374a01784dc238ac,v1=7cfccd3b4dcaaed3882192e3082ea1f1b58bac08b69cb9b9cba809871438641f
A receiver holding either secret verifies the delivery, so you can load the new
secret into your verifier at any point in the window. Keep both secrets in your
verifier’s list until deliveries arrive with a single v1 signature: that marks
the end of the window, after which the outgoing secret no longer verifies and
you can remove it. Rotation drops no 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). Delivery-log entries are retained for the audit
window of 5 years, the same retention as the
audit log, independently of your
tenant’s operational data-retention setting. Each entry shows its
retained_until timestamp. 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:
- Verify the
Venturi-Signatureon every request, including the verification event; reject on failure or skew. - Respond to
webhook.by echoing theverification challenge. - Deduplicate on
id; order withsequencewhere ordering matters. - Return
2xxquickly and process asynchronously; slow handlers trigger retries.
A reference webhook-receiver sample app ships with each SDK in your onboarding bundle; 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.
Sign in to send feedback. Opens in a new tab.
Copy page details
Docs feedback: Webhooks
Page: https://docs.venturi.systems/developers/webhooks/