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 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"
  }
}
  • data is a typed payload that references canonical schemas. 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. 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:

Venturi-Signature: t=1717433130,v1=5257a869e7e...

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:

  1. Respond to webhook.verification by echoing the challenge.
  2. Verify the Venturi-Signature on every request; reject on failure or skew.
  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 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.