> ## Documentation Index
> Fetch the complete documentation index at: https://docs.intentleads.si/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Removal requests and website visitors, pushed to your endpoints as they happen. One endpoint for everything, or one per event.

Add one or more **endpoints**. Each picks the events it receives, and we `POST` them as they happen:

| Event | What it is | Per request |
| - | - | - |
| `removals` | People you exported who asked to be removed. Delete them within 10 business days. | up to 500 |
| `visitors` | People identified on your [websites](https://docs.intentleads.si/guides/websites.md), with every lead field plus their LinkedIn photo and current profile, within about 15 minutes of the visit. | up to 100 |

Pick `*` (all events) and the endpoint also gets event types we add later. `GET /api/v1/webhooks/events` lists them.

> [!NOTE]
>
> Exports stay pull-only: webhooks never buy anyone. Website visitors are paid from your balance when they're identified. To get new people every day, run a daily export and pull ([Exports](https://docs.intentleads.si/guides/exports.md)).

## One endpoint or several

* **One for everything:** add one endpoint with all events and route on `type` in your code.
* **One per event:** for example, removals to your CRM's stop-list service and website visitors to a sales channel.
* **Per client:** each [workspace](https://docs.intentleads.si/guides/workspaces.md) has its own endpoints.

Each endpoint has its own place in each event, its own retries and its own delivery log, so one that's down never holds up another.

## Add an endpoint

In the dashboard, open **Settings → Webhooks** and click **Add endpoint** (owners). Enter the URL, keep **All events** or pick some, and save. Then **Send a test**.

![The Webhooks tab in Settings with no endpoints yet and an Add endpoint button](https://mintcdn.com/intentleads/16GfXNJlPs26aspr/images/app/settings-webhooks.png?fit=max\&auto=format\&n=16GfXNJlPs26aspr\&q=85\&s=445c0e3c87fe916638c0ef60c2d68240)

Settings → Webhooks. Each workspace has its own endpoints.

With the API, use an admin key:

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl -s -X POST "https://intentleads.si/api/v1/webhooks" \
  -H "Authorization: Bearer $INTENT_LEADS_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://app.example.com/hooks/intent-leads", "events": ["visitors"], "description": "Visitors to the CRM"}'
```

* The URL must be a public `https://` address you control: real people's details are sent to it.
* An endpoint gets events from the moment it's added (or from when you add an event to it). Older removals are in [`GET /api/v1/suppressions`](https://docs.intentleads.si/guides/suppressions) and older visitors in [`GET /api/v1/leads`](https://docs.intentleads.si/guides/leads).
* Change it with `PATCH /api/v1/webhooks/{id}` (`url`, `events`, `description`, `enabled`). A new URL keeps the endpoint's place, so nothing is skipped or sent twice.
* `DELETE /api/v1/webhooks/{id}` removes it.

With the [CLI](https://docs.intentleads.si/cli.md): `intentleads webhooks add https://app.example.com/hooks/intent-leads --events visitors`.

## Test it

**Send a test** in the dashboard, `POST /api/v1/webhooks/{id}/test` or `intentleads webhooks test ID --event visitors` sends a signed request now and shows what your endpoint answered:

* With an event (`removals` or `visitors`): a made-up sample in exactly the real shape, plus `"test": true`. Never real people.
* Without one: `{"type": "webhook.test", "test": true}`.

Tests don't move the endpoint's place. Every delivery and test is in the endpoint's **Recent attempts** (dashboard) and `GET /api/v1/webhooks/{id}/attempts` (the last 100).

## What we send

Removals, oldest first:

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "type": "removals",
  "removals": [
    {
      "lead_id": "01a13b71-2c4d-7e8f-9a0b-1c2d3e4f5a6b",
      "email_sha256": "5f2c8e0a4b9d7c1e3f6a8b0d2c4e6f8a1b3d5c7e9f0a2b4c6d8e0f1a3b5c7d9e",
      "suppressed_at": "2026-11-12T09:14:48.312+00:00"
    }
  ],
  "note": "Delete these people from your systems within 10 business days of suppressed_at."
}
```

Match each one by `lead_id` (recommended) or `email_sha256`, the same as in [Removal requests](https://docs.intentleads.si/guides/suppressions.md). You have **10 business days** from `suppressed_at` to delete or suppress them.

Website visitors, oldest first. Each is a full lead, the same fields as [`GET /api/v1/leads`](https://docs.intentleads.si/guides/leads), plus `visited_page`, `visited_at`, `photo_url`, `linkedin_headline`, `linkedin_title`, `linkedin_company` and `linkedin_location`:

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "type": "visitors",
  "visitors": [
    {
      "lead_id": "01a1c65f-…", "person_id": "7f3c1e2a-…", "lane": "V1",
      "first_name": "Jane", "last_name": "Doe", "job_title": "Practice Owner", "company": "Example Family Medicine",
      "business_email": "jane@example.com", "mobile": "+15550101001",
      "mobiles": [{"number": "+15550101001", "dnc": false}],
      "visited_page": "https://example.com/pricing", "visited_at": "2026-11-12T09:14:48.312Z",
      "photo_url": "https://media.licdn.com/…", "linkedin_title": "Practice Owner", "linkedin_company": "Example Family Medicine"
    }
  ],
  "note": "People identified on your websites. Never mention the visit in outreach."
}
```

Route on `type`: `removals`, `visitors` or `webhook.test`. Test sends also carry `"test": true`.

## Signatures (optional)

Every request is signed, so you can check it came from us. Checking is optional but recommended. Each endpoint has its own secret: **Reveal** it under the endpoint in **Settings → Webhooks**, or `GET /api/v1/webhooks/{id}/secret` (admin key). Rotate it with **New signing secret** or `PATCH {"rotate_secret": true}`.

Every request carries three headers, in the [Standard Webhooks](https://www.standardwebhooks.com) format:

| Header | |
| - | - |
| `webhook-id` | The batch id. A retry of the same batch keeps the same id, so you can drop repeats. |
| `webhook-timestamp` | Unix seconds when we sent it. |
| `webhook-signature` | `v1,` then the base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with your secret (the part after `whsec_`, base64-decoded). |

The Standard Webhooks libraries check it for you:

```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}}
from standardwebhooks.webhooks import Webhook

wh = Webhook(SECRET)  # "whsec_…"
payload = wh.verify(raw_body, headers)  # raises if the signature is wrong
```

```javascript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { Webhook } from "standardwebhooks";

const wh = new Webhook(SECRET); // "whsec_…"
const payload = wh.verify(rawBody, headers); // throws if the signature is wrong
```

Verify against the raw body, before parsing it, and reject timestamps more than a few minutes old.

## Answer and retries

Answer with any `2xx` within 15 seconds. Anything else, a redirect or no answer counts as a failure:

* We keep the batch and retry it on that endpoint after 1, 2, 4, 8 minutes, then every hour, until it succeeds. Nothing is skipped, and other endpoints carry on.
* After 8 failures in a row, we email your organization's members, and we're told too.
* While it's failing, removals are in `GET /api/v1/suppressions` and visitors in `GET /api/v1/leads`, and your 10 business days still run from `suppressed_at`.

Disabling an endpoint (`PATCH {"enabled": false}`) pauses it; enabling it again picks up where it stopped.

## Legacy: one webhook

The first version had one webhook per organization: `GET`, `PUT` and `DELETE /api/v1/webhooks/removals` and `POST /api/v1/webhooks/removals/test`. They still work and manage one endpoint that receives every event. New integrations should use `/api/v1/webhooks`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.