# Outbound webhooks

Receive an HTTP callback when a session or trace matures, signed with the Standard Webhooks spec.

Outbound webhooks push you an event the moment Brizz finishes materializing a session, or settles a trace — instead of you polling the API. Deliveries are signed with [Standard Webhooks](https://www.standardwebhooks.com), retried on failure, and idempotent by design.

**Outbound** means Brizz calls *your* endpoint. If you're looking for the other direction — an external system like Segment pushing events *into* Brizz, at a URL Brizz gives you — that's an inbound webhook, set up under **Integrations**. See [Segment](/docs/integrations/segment.md).

:::info
Outbound webhooks are off by default per organization. If creating or updating a subscription returns `403`, ask Brizz support to enable them for your account. Listing, deleting, and disabling subscriptions keep working either way — you can always see what exists and switch it off.
:::

## What "matured" means

A session or trace **matures** once it has gone quiet for longer than its *maturation window* — Brizz takes the idle gap as the conversation being finished, and runs its analysis pass over the whole thing. That pass is what materializes the transcript and computes metrics and detected problems, so maturation is the moment the data is worth reading. It's the reason these events exist: they tell you a session is ready, rather than making you poll for it.

The window is a few minutes of inactivity. It's tuned per environment and can change, so build against the behavior — "you'll hear from us shortly after the conversation goes quiet" — rather than against a specific number of minutes.

A session can mature **more than once**. If a user comes back after an idle gap, that's a new stretch of conversation on the same `session_id`, and Brizz reprocesses the session and matures it again. Each pass is a separate delivery with its own `webhook-id` and an incremented `maturation_count`. Traces have no idle-gap concept, so `trace.matured` fires once with `maturation_count` of `1`.

:::warning
`maturation_count` is **not** a retry counter. A value above `1` means the conversation genuinely grew and was reanalyzed — the newest delivery has the most complete picture of the session, and earlier ones are now stale. Retries of a *single* delivery reuse the same `webhook-id` and never change `maturation_count`. Dedupe on `webhook-id`; read `maturation_count` as a version number.
:::

## Managing subscriptions

Subscriptions are managed through the platform API, or from **Service settings > Webhooks** in the dashboard. Creating, updating, and deleting a subscription requires **admin** or higher.

API calls take a **Platform API key** — created under [**User Settings > Platform API Keys**](/app/settings/platform-api-keys) — sent as `Token`, not `Bearer`. A telemetry key or DSN will not work here, and a Platform API key sent as `Bearer` returns `401`; see [Authentication](/docs/api/overview.md#authentication).

**Create a subscription** — `POST https://platform.brizz.dev/api/v1/webhooks`

```bash
curl -X POST "https://platform.brizz.dev/api/v1/webhooks" \
  -H "Authorization: Token $BRIZZ_PLATFORM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "trace_service_id": "SERVICE_ID",
    "name": "Production callback",
    "url": "https://example.com/hooks/brizz",
    "event_types": ["session.matured", "trace.matured"]
  }'
```

The response includes a `secret` field — the signing secret, shown **exactly once**. Store it now; every other read of this subscription omits it.

```json
{
  "id": "8f3e...",
  "trace_service_id": "SERVICE_ID",
  "name": "Production callback",
  "url": "https://example.com/hooks/brizz",
  "event_types": ["session.matured", "trace.matured"],
  "enabled": true,
  "consecutive_failures": 0,
  "created_at": "2026-01-01T00:00:00Z",
  "updated_at": "2026-01-01T00:00:00Z",
  "secret": "whsec_..."
}
```

Other operations, all under `https://platform.brizz.dev/api/v1/webhooks/:id`:

| Method | Path | Notes |
|---|---|---|
| `GET` | `/webhooks` | List every subscription for your tenant. |
| `GET` | `/webhooks/:id` | One subscription, including its health rollup (`last_delivery_status`, `consecutive_failures`, …). |
| `PUT` | `/webhooks/:id` | Update `name`, `url`, or `event_types`. |
| `DELETE` | `/webhooks/:id` | Permanently remove the subscription and its delivery history. |
| `POST` | `/webhooks/:id/enable` | Turn a subscription back on and clear any platform-set failure state. |
| `POST` | `/webhooks/:id/disable` | Turn a subscription off. Always available, even if your organization has lost the entitlement — you can always stop your own traffic. |
| `POST` | `/webhooks/:id/rotate-secret` | See [Rotating your signing secret](#rotating-your-signing-secret). |
| `GET` | `/webhooks/:id/deliveries` | The 10 most recent delivery attempts, newest first — useful for debugging. |

Each trace service may have up to **5 subscriptions in total**. The cap counts every subscription registered against the service, including disabled ones — disabling a subscription does not free a slot; delete it to do that. The registered URL must be HTTPS, resolve publicly (no private/internal address), and carry no embedded credentials.

## Event types

| Event | Fires when | `maturation_count` |
|---|---|---|
| `session.matured` | A session [matures](#what-matured-means) — analytics has finished its pass and the conversation is materialized. | Starts at 1; increments each time the session re-opens after an idle gap and matures again. |
| `trace.matured` | A trace matures. | **Always 1.** A trace has no idle-gap concept, so unlike a session it never re-matures — if the same trace is re-selected by a later run, that's treated as a duplicate of the same delivery, not a new one. |

## Payload

Every delivery body is a single JSON object — never an array, even if multiple subscriptions matched the same event.

```json
{
  "id": "01978a3e-...",
  "type": "session.matured",
  "timestamp": "2026-01-15T09:00:03Z",
  "data": {
    "tenant_id": "3f9c...",
    "trace_service_id": "8f3e...",
    "service_name": "my-agent",
    "maturation_count": 1,
    "occurred_at": "2026-01-15T08:59:58Z",
    "session_id": "sess_abc123"
  }
}
```

`trace.matured` is identical except `type` and `data` carry `trace_id` instead of `session_id`.

| Field | Description |
|---|---|
| `id` | This delivery's id — same value as the `webhook-id` header. Stable across every retry of this delivery. |
| `type` | `session.matured` or `trace.matured`. |
| `timestamp` | When the event was enqueued. Fixed for the life of the delivery — it does not change between retries. Use `data.occurred_at` for when the event actually happened. |
| `data.occurred_at` | When the entity actually matured (the analytics batch window end). This is the field to use for ordering or freshness checks. |
| `data.maturation_count` | The version field — see [What "matured" means](#what-matured-means) and [Idempotency](#idempotency). |

The example above is formatted for reading. On the wire, keys are serialized in **alphabetical order** at every level — `data`, `id`, `timestamp`, `type` at the top. That makes no difference to a JSON parser, but it does mean you cannot reconstruct the signed bytes by re-serializing a parsed object. See [signature verification](#headers--signature-verification).

## Act on the event

The event carries identifiers, not conversation content, by design: a payload small enough to deliver reliably, and you fetch exactly as much as you need. Every call below goes to the platform API with a **Platform API key** as `Token` — a different host and credential from telemetry ingestion, covered in [Reading data](/docs/api/reading-data.md).

After a `session.matured`, `data.service_name` and `data.session_id` are the two path parameters you need:

| What you want | Endpoint |
|---|---|
| The conversation *(start here)* | `GET /telemetry/otel/sessions/{serviceName}/{sessionId}/transcript` |
| One transcript item, in full | `GET …/transcript/items/{itemId}` |
| The session's traces | `GET …/traces` |
| Raw spans | `GET …/spans` |
| Events emitted during the session | `GET …/events` |
| AI-written summary | `GET …/tldr` |
| Problems Brizz detected | `GET …/problem-analysis` |
| Cost | `GET …/cost-by-model`, `GET …/tool-costs` |
| Intents matched to the session | `GET /telemetry/otel/sessions/{sessionId}/intents?trace_service_name={serviceName}` |

After a `trace.matured`, you have `data.service_name` and `data.trace_id`:

| What you want | Endpoint |
|---|---|
| The trace | `GET /telemetry/otel/trace/{traceId}?serviceName={serviceName}` |
| Its spans | `GET /telemetry/otel/spans/{serviceName}/{traceId}` |
| Its events | `GET /telemetry/otel/events/{serviceName}/{traceId}` |
| Metrics computed for it | `GET /telemetry/metrics/trace/{traceId}` |

Two easy mistakes on that first one: the path is **singular** `trace` (there is no `GET /traces/{traceId}` — it `404`s), and it takes the service name as a **query parameter** rather than a path segment like the two below it. Omitting it returns `400 — serviceName query parameter is required`.

### End to end

Verify, then fetch the transcript, then handle the case where Brizz hasn't finished writing it yet:

```python
from svix.webhooks import Webhook, WebhookVerificationError
import requests

@app.post("/hooks/brizz")
def receive(request):
    try:
        event = Webhook(WEBHOOK_SECRET).verify(request.body, request.headers)
    except WebhookVerificationError:
        return Response(status=400)

    if event["type"] != "session.matured":
        return Response(status=200)

    if already_processed(request.headers["webhook-id"]):
        return Response(status=200)

    data = event["data"]
    r = requests.get(
        f"{API_URL}/api/v1/telemetry/otel/sessions"
        f"/{data['service_name']}/{data['session_id']}/transcript",
        headers={"Authorization": f"Token {PLATFORM_API_KEY}"},
        params={"limit": 200},
    ).json()

    # The transcript is written by the same pass that fired this event, so a fast
    # receiver can arrive first. This is "wait", not "no data".
    if r.get("status") == "processing":
        retry_later(request.headers["webhook-id"], after=r["retryAfterSeconds"])
        return Response(status=200)

    analyze(r["items"], version=data["maturation_count"])
    return Response(status=200)
```

The example fetches inline to keep it readable. In production, acknowledge with `2xx` first and move the fetch and analysis onto your own queue keyed by `webhook-id` — a handler slow enough to time out becomes a retried delivery, and you'll do the work twice.

Brizz also sends several deliveries to your endpoint at once, so your handler has to be safe to run concurrently. How many arrive together is set platform-side and isn't tunable per subscription — answering `429` defers the delivery that received it, per the [retry curve](#retries), but the next batch fans out just as wide. Treat `429` as a retry signal rather than a throttle, and note that repeated failures of any kind, `429` included, eventually open the circuit described under [Retries](#retries).

## Headers & signature verification

Every delivery carries three headers, per the [Standard Webhooks](https://www.standardwebhooks.com) spec:

| Header | Description |
|---|---|
| `webhook-id` | This delivery's unique id. |
| `webhook-timestamp` | Unix timestamp (seconds) the request was signed at. |
| `webhook-signature` | One or more `v1,<base64 hmac>` signatures, space-separated. |

Because this is the Standard Webhooks spec, verify with an off-the-shelf client rather than hand-rolling HMAC comparison — it also handles timestamp tolerance and constant-time comparison for you:

:::tabs
:::tab[Python]
```python
from svix.webhooks import Webhook, WebhookVerificationError

wh = Webhook(WEBHOOK_SECRET)  # the "whsec_..." value from creation or rotation

try:
    payload = wh.verify(request.body, request.headers)
except WebhookVerificationError:
    return Response(status=400)
```
:::tab[Node.js]
```typescript
import { Webhook } from 'svix';

const wh = new Webhook(WEBHOOK_SECRET); // the "whsec_..." value from creation or rotation

try {
  const payload = wh.verify(rawBody, headers);
} catch (err) {
  return res.status(400).end();
}
```
:::

`svix`'s client library implements the same spec Brizz signs with; any other Standard Webhooks-compatible library works too.

:::warning
Verify against the **raw request body** — the exact bytes received. Brizz serializes the payload with keys in alphabetical order, and most JSON libraries won't reproduce that byte-for-byte when you re-serialize a parsed object. Parse-then-re-serialize is the classic way to end up with signatures that never validate. Capture the raw body before your framework parses it.
:::

## Idempotency

Dedupe on the `webhook-id` header (equivalently, the top-level `id` in the body): it is stable across every retry of one delivery, so a retried attempt you've already processed is safe to acknowledge and drop.

`maturation_count` is the version field for genuinely distinct maturations of the same entity — a session that re-opens and matures again arrives as a new delivery with a new `webhook-id` and an incremented count, not a retry of the old one.

**Arrival order is not guaranteed.** Deliveries go out in parallel, so a session's second maturation can reach you before its first, and events for different sessions arrive in no particular order. Compare `maturation_count` with the value you've already stored for that entity and keep the highest rather than overwriting with whatever landed last; use `data.occurred_at` when you need to order events by when they actually happened.

:::info
Delivery history — and with it the idempotency record — is retained for 30 days. If the same entity matures again after that window, the new delivery's `maturation_count` restarts at 1. Don't assume `maturation_count` is monotonic forever; treat each delivery's `webhook-id` as authoritative for dedup, and `maturation_count` as informational beyond the retention window.
:::

## Retries

If your endpoint doesn't respond `2xx`, Brizz retries on a fixed backoff curve, for up to **9 attempts over roughly 52 hours**:

| Attempt | Delay before it |
|---|---|
| 1 | — (first attempt) |
| 2 | 5 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |
| 8 | 14 hours |
| 9 | 20 hours |

Each delay is jittered by **±10%**, centered on the tabled value — so an attempt can land slightly earlier than the table says as well as later. The jitter matters because maturation happens in batches: many sessions settle at once, and unjittered retries from that burst would all hit your endpoint at the same instant on every subsequent attempt too. Don't build timing assumptions tighter than the table's own resolution.

If your endpoint was down for longer than the curve above, use [`GET /webhooks/:id/deliveries`](#managing-subscriptions) or your own polling to catch up on anything that failed permanently — don't rely on a webhook you missed re-arriving on its own.

What a response means:

- **`2xx`** — delivered. Retry state resets.
- **`429` or `408`** — retried per the curve above. If you send `Retry-After`, Brizz honors it when it's *later* than the curve's own next step (never sooner).
- **Any other `4xx`/`5xx`, or a connection failure/timeout** — retried per the curve.
- **A `3xx` redirect** — **not retried.** Brizz never follows redirects; a redirect means your registered URL is handing delivery to a second, unverified address, so the subscription is disabled immediately.
- **`410 Gone`** — **not retried.** Treated as your own signal to stop sending; the subscription is disabled immediately.

If every attempt in the curve fails, the delivery is marked permanently failed (no further retries for that event), and if failures continue across separate deliveries for **5 days straight**, the subscription itself is disabled — check `GET /webhooks/:id` for `disabled_reason` and re-enable it once your endpoint is healthy again. The same field also reports `private_address` if your URL's DNS record ever changes to resolve inside a private network — Brizz re-validates the destination on every attempt, not only at registration.

## Rotating your signing secret

`POST https://platform.brizz.dev/api/v1/webhooks/:id/rotate-secret`

```bash
curl -X POST "https://platform.brizz.dev/api/v1/webhooks/ID/rotate-secret" \
  -H "Authorization: Token $BRIZZ_PLATFORM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"previous_secret_ttl_hours": 24}'
```

Returns a new `secret`, shown once, same as creation. During the TTL you specify, every delivery is signed with **both** the old and new secret, space-joined in one `webhook-signature` header — so you can swap your verifier over to the new secret at any point inside that window without a single delivery failing to verify in between.

- `previous_secret_ttl_hours` is optional, **0–168** (7 days), default **24**.
- `0` means an immediate cutover — use this if you're rotating because the old secret leaked, not on a routine schedule.

## See also

- [API overview](/docs/api/overview.md) — base URLs, the two credentials, general error model.
- [Reading data](/docs/api/reading-data.md) — full reference for the follow-up calls above.
- [Services & configuration](/docs/admin/services-and-configuration.md) — managing subscriptions from the dashboard instead of the API.
- [Segment](/docs/integrations/segment.md) — the *inbound* webhook, where an external system pushes events *into* Brizz. Different direction, unrelated feature.

Support: support@brizz.ai
