Skip to content

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.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.

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.

JSON
{
  "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"
  }
}
  • data is 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 same interpretation evidence 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.

Bash
# 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
JSON
{
  "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_secret_configured: true. 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.verification 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 /api/v1/webhooks/{id}/verify and the body {"token": "<challenge>"} (scope webhooks:manage, with an Idempotency-Key). No production event is ever delivered to an unverified endpoint.

Python
# 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_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 a Venturi-Signature header:

HTTP
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.

Python
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
TypeScript
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:

Bash
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:

HTTP
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:

  1. Verify the Venturi-Signature on every request, including the verification event; reject on failure or skew.
  2. Respond to webhook.verification by echoing the challenge.
  3. Deduplicate on id; order with sequence where ordering matters.
  4. Return 2xx quickly 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.

Give feedback on this page

Sign in to send feedback. Opens in a new tab.

Copy page details
Docs feedback: Webhooks
Page: https://docs.venturi.systems/developers/webhooks/