# Webhooks


Read-only listing of your organization's webhook endpoints, plus the payload
schema of every webhook event type. Concepts, signature verification rules and
delivery semantics are on the [Webhooks concepts page](/concepts/webhooks).

Webhook endpoints are created, edited, rotated, paused and deleted in the
Intuizi console (**My Organization > Webhooks**), not over the API. The signing
secret is shown once, in the console, at creation or rotation - it never
appears in any API response.

The endpoint is authenticated and JSON-only. Send `Authorization: Bearer
<token>` and `Accept: application/json` on the call. It uses the read rate
bucket (120 requests/min).

## List Webhook Endpoints {#get-apiv2webhooksindex}

`GET /api/v2/webhooks/index`

Lists the webhook endpoints registered for your company, newest first. Use it
to confirm from code that a subscription exists and which event types it
covers.

**Auth:** bearer token + `Accept: application/json`. Rate limit: read bucket
(120 requests/min).

{{< tabs >}}

  {{< tab name="cURL" >}}
  ```bash
  curl "https://console.intuizi.com/api/v2/webhooks/index" \
    -H "Authorization: Bearer <YOUR_TOKEN>" \
    -H "Accept: application/json"
  ```
  {{< /tab >}}

  {{< tab name="Python" >}}
  ```python
  import requests

  res = requests.get(
      "https://console.intuizi.com/api/v2/webhooks/index",
      headers={
          "Authorization": "Bearer <YOUR_TOKEN>",
          "Accept": "application/json",
      },
  )
  endpoints = res.json()["data"]
  ```
  {{< /tab >}}

  {{< tab name="JavaScript" >}}
  ```javascript
  const res = await fetch("https://console.intuizi.com/api/v2/webhooks/index", {
    headers: {
      Authorization: "Bearer <YOUR_TOKEN>",
      Accept: "application/json",
    },
  });
  const endpoints = (await res.json()).data;
  ```
  {{< /tab >}}

  {{< tab name="PHP" >}}
  ```php
  $res = Http::withToken('<YOUR_TOKEN>')->acceptJson()
      ->get('https://console.intuizi.com/api/v2/webhooks/index');
  $endpoints = $res->json('data');
  ```
  {{< /tab >}}

{{< /tabs >}}

### Response

```json
{
  "status": "success",
  "code": 200,
  "message": "Resources fetched successfully.",
  "data": [
    {
      "id": 3,
      "name": "Ops receiver",
      "url": "https://hooks.example.com/intuizi",
      "events": ["audience.completed", "activation.completed"],
      "schema_version": 1,
      "is_active": true,
      "last_delivery_at": "2026-07-21 09:14:02",
      "created_at": "2026-07-02 11:20:41",
      "updated_at": "2026-07-19 16:05:00"
    }
  ]
}
```

### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `id` | integer | The endpoint id. |
| `name` | string | The label given in the console. |
| `url` | string | The receiver URL deliveries are POSTed to. |
| `events` | array | The event types this endpoint subscribes to. |
| `schema_version` | integer | The payload envelope version this endpoint receives (currently `1`). |
| `is_active` | boolean | Whether the endpoint currently receives deliveries. `false` after a manual pause or an automatic disable. |
| `last_delivery_at` | string or null | When the most recent delivery attempt (successful or not) finished. `null` if nothing was ever delivered. |
| `created_at` | string | When the endpoint was registered. |
| `updated_at` | string | When the endpoint was last changed. |

The signing secret is never present in any field, in any form.

## Event payloads {#event-payloads}

Every delivery body is the versioned envelope described on the
[Webhooks concepts page](/concepts/webhooks#the-delivery):

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

| Field | Type | Description |
| --- | --- | --- |
| `schema_version` | integer | Envelope version. Currently `1`. |
| `id` | string | The logical event id. Identical across retries and across all of your endpoints. Deduplicate on it. |
| `type` | string | One of the six event types below. |
| `created_at` | string | When the event was recorded, RFC 3339 UTC. Note: timestamps inside `data` use the API's `Y-m-d H:i:s` format instead - they are different fields. |
| `company_id` | integer | Your company id. |
| `data` | object | The resource object, exactly as the matching `GET` returns it (the object itself, not the one-element `data` array of the GET envelope). |

### audience.completed / audience.failed {#audience-events}

`data` is the audience object of
[Get Audience](/api/v2/audiences#get-apiv2analysesaudiencesid) - the same
fields, same formats. `audience.completed` fires when the audience reaches
lifecycle status `104` Completed. `audience.failed` fires when the build
fails: at `107` Additional Info, where Intuizi stopped the build because it
cannot be built as defined, or at a `4xx` lifecycle error status.
`data.status.id` tells them apart. The payload carries only the status id and
name, and Audience Manager shows the reason for a `107`. Lookalikes arrive
through these same two events with `data.is_lookalike` set to `true`.
A lookalike you [cancel](/api/v2/audiences#post-apiv2analysesaudiencescancel-lookalike)
sends `audience.failed` with status `400` once the run stops, unless the
cancel arrived during publishing, in which case the run completes and sends
`audience.completed`.

```json
{
  "schema_version": 1,
  "id": "evt_01K9Z4F0J6R8QW3T5Y7B2N4M6C",
  "type": "audience.completed",
  "created_at": "2026-07-21T14:05:11Z",
  "company_id": 55,
  "data": {
    "id": 88,
    "name": "Coffee lovers UK",
    "status": { "id": 104, "name": "Completed" },
    "is_cohort": false,
    "is_lookalike": false,
    "results_count": 431202,
    "is_activation_allowed": 1,
    "eligibility": {
      "allowed": true,
      "reasons": [],
      "metrics": {"unique_eids": 431202, "unique_scids": null, "eid_scid_ratio": null, "is_affinity": false}
    },
    "source_audience": null,
    "created_by": { "name": "Jane Doe", "email": "jane.doe@example.com" },
    "project": null,
    "operator": null,
    "dataset": [
      { "analysis_type": "WebDomain", "start_date": "2026-07-01", "end_date": "2026-07-14" }
    ],
    "created_at": "2026-07-21 13:58:41",
    "updated_at": "2026-07-21 14:05:11"
  }
}
```

### activation.completed / activation.failed {#activation-events}

`data` is the activation object of
[Get Activation](/api/v2/activations#get-apiv2analysesactivationsid).
`activation.completed` fires at `104` Completed - which comes **after** the
`105` DataStreaming delivery phase, so when this event arrives
`datastreams[].results.uri` is ready to read. `activation.failed` fires when
the export fails: at `107` Additional Info, where the export stopped because
it cannot be processed as requested, or at a `4xx` lifecycle error status.
Audience Manager shows the reason for a `107` on the activation.

```json
{
  "schema_version": 1,
  "id": "evt_01K9Z4G8M2T6WA4V8X0D3P5R7T",
  "type": "activation.completed",
  "created_at": "2026-07-21T15:40:02Z",
  "company_id": 55,
  "data": {
    "id": 123,
    "description": "Coffee lovers UK -> S3",
    "status": { "id": 104, "name": "Completed" },
    "audience": { "id": 88, "name": "Coffee lovers UK" },
    "created_by": { "name": "Jane Doe", "email": "jane.doe@example.com" },
    "project": null,
    "partner": { "name": "Amazon S3", "description": "Deliver to an S3 bucket" },
    "pricing_model": {},
    "datastreams": [
      {
        "name": "s3_export",
        "status": "success",
        "results": { "uri": "s3://your-bucket/exports/audience.csv.gz" }
      }
    ],
    "filters": { "freq_limit": false, "freq_min": null, "freq_max": null },
    "filter_hash": null,
    "created_at": "2026-07-21 14:20:10",
    "updated_at": "2026-07-21 15:40:02"
  }
}
```

### cohort.completed / cohort.failed {#cohort-events}

`data` is the cohort object of
[Get Cohort](/api/v2/cohorts#get-apiv2analysescohortsid). Cohorts run on their
own status scale (`1`-`5`), not the `1xx` lifecycle: `cohort.completed` fires
at status `4` Completed, and `cohort.failed` at status `5` Not Available, the
status a failed import ends at.

```json
{
  "schema_version": 1,
  "id": "evt_01K9Z4H2Q4V8YB6X0Z2F5R7T9V",
  "type": "cohort.completed",
  "created_at": "2026-07-21T12:12:44Z",
  "company_id": 55,
  "data": {
    "id": 42,
    "name": "Q3 customer file",
    "status": { "id": 4, "name": "Completed" },
    "total_eids": 184233,
    "project": null,
    "source": "file",
    "source_audience": null,
    "created_at": "2026-07-21 11:59:03",
    "updated_at": "2026-07-21 12:12:44"
  }
}
```

### webhook.test {#webhook-test}

Sent from the console's "send test" action, regardless of the endpoint's
subscriptions, so you can exercise your receiver and signature check. `data`
is a small fixed object (a message and the endpoint id), not a resource.

## Verifying the signature {#verifying-the-signature}

Every delivery carries `Intuizi-Signature: t=<unix>,v1=<hex>[,v1=<hex>]`, where
each `v1` is HMAC-SHA256 over `"{t}.{raw_body}"` keyed with your endpoint's
secret. Verify against the **raw** body bytes, compare in constant time, accept
if any `v1` matches, and reject timestamps more than 5 minutes from now.
During the 24 hours after a rotation two `v1` values are present (new secret
first).

{{< tabs >}}

  {{< tab name="Python" >}}
  ```python
  import hashlib, hmac, time

  def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
      parts = dict()
      signatures = []
      for item in header.split(","):
          key, _, value = item.partition("=")
          if key == "t":
              parts["t"] = value
          elif key == "v1":
              signatures.append(value)

      timestamp = int(parts.get("t", "0"))
      if abs(time.time() - timestamp) > tolerance:
          return False

      signed = f"{timestamp}.".encode() + raw_body
      expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

      return any(hmac.compare_digest(expected, sig) for sig in signatures)
  ```
  {{< /tab >}}

  {{< tab name="JavaScript" >}}
  ```javascript
  const crypto = require("crypto");

  function verify(secret, header, rawBody, tolerance = 300) {
    const signatures = [];
    let timestamp = 0;
    for (const item of header.split(",")) {
      const [key, value] = item.split("=");
      if (key === "t") timestamp = parseInt(value, 10);
      if (key === "v1") signatures.push(value);
    }

    if (Math.abs(Date.now() / 1000 - timestamp) > tolerance) return false;

    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${timestamp}.`)
      .update(rawBody)
      .digest("hex");

    return signatures.some((sig) => {
      const a = Buffer.from(expected);
      const b = Buffer.from(sig);
      return a.length === b.length && crypto.timingSafeEqual(a, b);
    });
  }
  ```
  {{< /tab >}}

  {{< tab name="PHP" >}}
  ```php
  function verify(string $secret, string $header, string $rawBody, int $tolerance = 300): bool
  {
      $timestamp = 0;
      $signatures = [];

      foreach (explode(',', $header) as $item) {
          [$key, $value] = array_pad(explode('=', $item, 2), 2, '');
          if ($key === 't') {
              $timestamp = (int) $value;
          } elseif ($key === 'v1') {
              $signatures[] = $value;
          }
      }

      if (abs(time() - $timestamp) > $tolerance) {
          return false;
      }

      $expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);

      foreach ($signatures as $signature) {
          if (hash_equals($expected, $signature)) {
              return true;
          }
      }

      return false;
  }
  ```
  {{< /tab >}}

  {{< tab name="Shell" >}}
  ```bash
  # Recompute the expected hex for a captured delivery, then compare it to
  # each v1 value from the Intuizi-Signature header. raw_body.json must hold
  # the EXACT body bytes as received.
  t=1774102711   # the t= value from the header

  printf '%s.' "$t" | cat - raw_body.json \
    | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -r | cut -d' ' -f1
  ```
  {{< /tab >}}

{{< /tabs >}}

Respond `2xx` quickly (within 10 seconds), then process asynchronously if your
handling is slow. Failed deliveries are retried up to 6 times over roughly 8.5
hours; see [delivery semantics](/concepts/webhooks#delivery-semantics).
