# Webhooks


Webhooks are the push half of the [async model](/concepts/async-model): instead
of polling a resource until its status is terminal, register an HTTPS endpoint
and Intuizi calls you the moment an audience, activation or cohort completes
or fails. The payload carries the same object the matching `GET` endpoint
returns, so no follow-up read is needed.

## Registration is in the console

Webhook endpoints are created, edited, rotated, paused and deleted in the
Intuizi console only, under **My Organization > Webhooks**. There is no write
API: a webhook endpoint is durable organization configuration holding a signing
secret, and an API token must never be able to silently retarget where your
completion events are pushed.

The API exposes exactly one read,
[`GET /api/v2/webhooks/index`](/api/v2/webhooks#get-apiv2webhooksindex), so an
integration can confirm from code that a subscription exists and which event
types it covers. The signing secret never appears in any API response.

When you create an endpoint you choose:

- the **URL** - must be `https://`, on the default port, using a public
  hostname (IP addresses, private hosts and redirecting URLs are rejected),
- the **event types** it subscribes to (see the catalog below),
- and you receive the **signing secret** exactly once, at creation. Store it
  immediately; afterwards only a masked fragment is shown. Rotating generates
  a new secret (also shown once) and keeps the old one valid for 24 hours so
  you can switch without dropping events.

## Event catalog

Six event types, each sent once a resource has finished. `104` Completed is
the only successful lifecycle state - `105` DataStreaming happens **before**
it, so there is deliberately no "delivering" event, and no progress events.

| Event type | Fires when | `data` is the object returned by |
| --- | --- | --- |
| `audience.completed` | an audience or lookalike reaches lifecycle status `104` Completed | [Get Audience](/api/v2/audiences#get-apiv2analysesaudiencesid) |
| `audience.failed` | an audience or lookalike fails: it stops at `107` Additional Info or reaches a `4xx` lifecycle error status, including the `400` a cancelled lookalike ends at | [Get Audience](/api/v2/audiences#get-apiv2analysesaudiencesid) |
| `activation.completed` | an activation reaches lifecycle status `104` Completed | [Get Activation](/api/v2/activations#get-apiv2analysesactivationsid) |
| `activation.failed` | an activation fails: it stops at `107` Additional Info or reaches a `4xx` lifecycle error status | [Get Activation](/api/v2/activations#get-apiv2analysesactivationsid) |
| `cohort.completed` | a cohort reaches status `4` Completed | [Get Cohort](/api/v2/cohorts#get-apiv2analysescohortsid) |
| `cohort.failed` | a cohort reaches status `5` Not Available: the import failed | [Get Cohort](/api/v2/cohorts#get-apiv2analysescohortsid) |

Four catalog notes:

- **Lookalikes are audiences.** A completed lookalike arrives as
  `audience.completed` with `data.is_lookalike` set to `true` - filter on that
  field if you handle them differently.
- **`107` Additional Info is a failure.** A build that stops at `107` sends
  the same `audience.failed` or `activation.failed` event as a `4xx` error
  state. Read `data.status.id` to tell them apart. The payload carries only
  the status id and name, and Audience Manager shows the reason.
- **Cohorts use their own status scale** (`1`-`5`), not the `1xx` lifecycle.
  See [Status Codes](/concepts/status-codes).
- **Every failure sends an event.** Besides `107` and the `4xx` states, that
  covers a Lookalike Model run you cancel, which ends at `400` Error and sends
  `audience.failed`, and a failed cohort import, which ends at `5` Not
  Available and sends `cohort.failed`. A poll is only the fallback for a
  delivery that exhausts its retries - see
  [Webhooks or polling?](#webhooks-or-polling).

A `webhook.test` event can also be sent from the console at any time to
exercise your receiver; it is delivered regardless of your subscriptions and
its `data` is a small fixed object, not a resource.

## The delivery

Every delivery is an HTTPS `POST` to your URL with these headers:

```
Content-Type: application/json
User-Agent: Intuizi-Webhooks/1
Intuizi-Event-Id: evt_01K9Z4F0J6R8QW3T5Y7B2N4M6C
Intuizi-Event-Type: audience.completed
Intuizi-Delivery-Id: dlv_01K9Z4F0J8H2V5X7C9E1G3J5L7
Intuizi-Delivery-Attempt: 1
Intuizi-Signature: t=1774102711,v1=8f3c...e91a
```

and this body:

```json
{
  "schema_version": 1,
  "id": "evt_01K9Z4F0J6R8QW3T5Y7B2N4M6C",
  "type": "audience.completed",
  "created_at": "2026-07-21T14:05:11Z",
  "company_id": 55,
  "data": { }
}
```

- `data` is the resource object exactly as the matching `GET` returns it - the
  object itself, not the single-element `data` array the GET envelope wraps it
  in. Field-level schemas are on the
  [Webhooks API page](/api/v2/webhooks#event-payloads).
- Payloads are **additive**: new fields can appear in `data` without a
  `schema_version` bump, so your consumer must tolerate and ignore fields it
  does not recognize rather than rejecting the delivery. `eligibility` and
  `totals` were added to the audience object on 2026-08-23.
- `id` identifies the logical event. It is identical across retries and across
  all of your endpoints that receive the same event.
- The envelope `created_at` is RFC 3339 UTC (`2026-07-21T14:05:11Z`). Note the
  timestamps **inside** `data` use the API's `Y-m-d H:i:s` format - they are
  different fields with different formats.

Respond with any `2xx` status within 10 seconds. The response body is ignored
(at most a short snippet is kept for the console delivery log). Redirects are
**not** followed - a `3xx` counts as a failed delivery.

## Verifying the signature

Every delivery is signed with your endpoint's secret so you can prove it came
from Intuizi and was not altered. The `Intuizi-Signature` header carries a unix
timestamp and one or more HMAC-SHA256 signatures over `"{t}.{raw_body}"`:

```
Intuizi-Signature: t=1774102711,v1=8f3c...e91a
```

To verify:

1. Read `t` and each `v1` value from the header.
2. Compute `HMAC_SHA256(secret, t + "." + raw_body)` over the **raw** request
   body bytes (before any JSON parsing).
3. Compare against each `v1` using a constant-time comparison. Accept if any
   matches.
4. Reject if `t` is more than 5 minutes from your current time - this bounds
   replay of a captured delivery.

During the 24 hours after a secret rotation the header carries **two** `v1`
values (new secret first, then old), so a receiver holding either secret keeps
verifying. Code samples in four languages are on the
[Webhooks API page](/api/v2/webhooks#verifying-the-signature).

## Delivery semantics

- **At-least-once.** A delivery can arrive more than once (for example when
  your server processed it but the response was lost). Deduplicate on
  `Intuizi-Event-Id` and make your handler idempotent.
- **Ordering is not guaranteed** across events. Use the resource id and status
  inside `data`, not arrival order.
- **Retries.** A failed delivery (non-`2xx`, timeout, or connection error) is
  retried up to 6 times over roughly 8.5 hours, with growing gaps (about 1
  minute, then 5, 25, 2 hours, 6 hours after the first attempt).
  `Intuizi-Delivery-Attempt` tells you which attempt you are seeing.
- **Auto-disable.** After 20 consecutive exhausted deliveries the endpoint is
  disabled automatically and its creator is notified in the console.
  Re-enabling is a console action and resets the failure counter.
- **The delivery log** for each endpoint (status, attempts, response code,
  response snippet) is visible in the console next to the endpoint.

## Webhooks or polling?

Use webhooks as the primary completion signal - they remove the wasted request
budget and the discovery latency of polling. Keep a low-frequency poll (or an
on-demand `GET`) as the fallback for the rare delivery that exhausts its
retries: webhooks are a notification channel, not a source of truth. The
resource itself, read via `GET`, is always the source of truth.
