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

# Webhook Payloads

> The competitor alert and team refresh webhooks, their signature and their retries

When the webhook is on in your competitor alert settings, Clustr POSTs every competitor alert
to your URL, one request per alert, and a recap after each team refresh. Turn it on and set the
URL and secret with [Update Competitor Alerts](https://docs.clustr-ai.com/api-reference/update-competitor-alerts.md), and send
a sample with [Test Competitor Alert Webhook](https://docs.clustr-ai.com/api-reference/test-competitor-alert-webhook.md).

Two events reach this URL:

| Event | Sent when |
| - | - |
| `competitor_monitoring.alert` | One alert: a Defense interaction, an Attack critical comment, or an Attack account over the threshold |
| `competitor_monitoring.team_refresh` | A competitor's team was refreshed: who joined, who left, who changed role |

The competitor report has its own webhook, configured separately:
[`competitor_monitoring.report_ready`](https://docs.clustr-ai.com/competitor-monitoring/report-schema#report-ready-webhook).

## Request

Every request is a `POST` with a JSON body and these headers:

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `User-Agent` | `ClustR-Webhook/1.0` |
| `X-ClustR-Event` | The event name, as in the body's `event` |
| `X-ClustR-Job-ID` | The delivery's id. For an alert it equals the body's `alert_id` |
| `X-ClustR-Signature` | `t=<unix seconds>,v1=<hex>`. Only when a webhook secret is saved; see [Signature](#signature) |

Header names are case-insensitive. Any `2xx` answer counts as delivered.

## `competitor_monitoring.account_alert`

One per account per monitoring run, for the Defense and Attack alerts: the account and every
person a competitor engaged there in the alert's period.

Only their people's own actions count: a like or comment by someone at the account on the
competitor's post never alerts. A pleasantry ("Congrats!") counts as a like. Defense alerts on
its rule. Attack alerts on its rule only when their sales team is prospecting the account
(`account.reading` is `prospecting`), and on any pitch. On both, a comment on a post that shows
buying intent alerts on its own.

| Field | Type | Description |
| - | - | - |
| `event` | string | Always `competitor_monitoring.account_alert` |
| `alert_id` | string (uuid) | The alert's id. Stable for the same account and the same interactions |
| `tenant_id` | string (uuid) | Your workspace |
| `fingerprint` | string | Clustr's dedupe key for the alert |
| `use_case` | string | `defense` (a customer, tags the CS owner) or `attack` (a prospect, tags the sales owner) |
| `account_scope` | string or null | On Attack: `target` or `other`. `null` on Defense |
| `rule` | object | `interaction_kinds` (`comment`, `like`), `min_people`, `period_days`, `comment_alone` (a single comment in the period is enough), and `text`, the rule in words |
| `account` | object | As on `competitor_monitoring.alert`, plus `reading` on Attack: what their activity at the account reads as over the last 90 days (`prospecting`, `keeping_in_touch`, `popular_post`, `one_off` or `light`) |
| `owner` | object or null | `role` (`cs_owner` on Defense, `account_owner` on Attack), `email`, `name` |
| `competitors` | array | The competitors that engaged, most active first: `id`, `name`, `linkedin_url` |
| `people` | array | Every person engaged: `name`, `title`, `linkedin_url`, `new` (engaged since the last alert on this account) and `interactions`, each with `type`, `direction`, `occurred_at`, `competitor`, `competitor_salesperson`, `url` (the post), `text`, `new`, `kind` (on a comment: `pitch`, `pleasantry` or `conversation`) and `intent_label` (the buying intent the post shows, when it shows one) |
| `interactions` | integer | How many interactions the alert covers |
| `last_at` | string (RFC 3339) | The most recent one |
| `app_url` | string | The account in Clustr |

## `competitor_monitoring.alert`

One schema for all three alert types. Every field is always present: a field that does not
apply to the alert type is `null`, never missing.

| Field | Type | Description |
| - | - | - |
| `event` | string | Always `competitor_monitoring.alert` |
| `alert_id` | string (uuid) | The alert's id. Stable: the same alert always has the same id, so use it to dedupe |
| `tenant_id` | string (uuid) | Your workspace |
| `fingerprint` | string | Clustr's dedupe key for the alert |
| `alert_type` | string | `defense_interaction`, `attack_critical_comment` or `attack_engagement_threshold` |
| `use_case` | string | `defense` (a customer) or `attack` (not a customer) |
| `account_scope` | string or null | On Attack: `target` (the account is on your target account list) or `other` (any other non-customer account a competitor engages with). `null` on Defense |
| `occurred_at` | string (RFC 3339) | When the interaction happened, or when the threshold was reached |
| `account` | object | The account: `id`, `clustr_account_id` (Clustr's own id for the account, `acc_` and 26 characters; it does not change when LinkedIn changes the numeric id), `name`, `domain`, `linkedin_url`, `crm_id` (its id in your CRM, `""` when not in it), `crm_provider` (`hubspot`, `salesforce` or `attio`, `""` when not in your CRM), `is_customer`, `is_target` (on your target account list) |
| `owner` | object or null | Who the alert names, and emails when email is on: `role` (`account_owner` on Attack, `cs_owner` on Defense), `email`, `name`. `null` when the mapped CRM field is empty |
| `competitor` | object | `id`, `name`, `linkedin_url` |
| `competitor_salesperson` | object or null | The competitor's person: `name`, `title`, `linkedin_url`, `linkedin_id`, `clustr_person_id` (Clustr's own id for the person, `per_` and 26 characters; it does not change when they rename their LinkedIn profile, and is left out when Clustr has no profile for them) |
| `engaged_person` | object or null | The person at the account: `name`, `title`, `linkedin_url` |
| `interaction` | object or null | The one interaction: `type` (`comment` or `like`), `direction`, `text` (the comment, `""` for a like), `critical_labels` (why a comment is critical, `[]` otherwise). `null` on `attack_engagement_threshold` |
| `engagement` | object or null | The count behind a threshold alert: `interactions`, `period_days`, `salespeople`, `scope` (`per_competitor`: with `competitor` alone; `all_competitors`: with every competitor, and `competitor` is the most active one) and `competitors` (how many took part). `null` on the other two types |
| `post` | object or null | The post: `url`, and `text` (the first 500 characters) |
| `app_url` | string | The alert in Clustr |

`direction` is `outbound` when the competitor's salesperson engaged the person's post, and
`inbound` when the person engaged the salesperson's post.

Attack covers every non-customer account, and `account_scope` says which kind it is, so you
can route them apart: `target` for the accounts on your target account list, `other` for every
other account a competitor engages with (someone matching your buyer titles). Slack posts and
emails say the same as "Target account" or "Other account". To stop the alerts on other
accounts, set `attack_other_accounts` to `false` with
[Update Competitor Alerts](https://docs.clustr-ai.com/api-reference/update-competitor-alerts.md). `account_scope` and
`account.is_target` are new fields (as is `account.clustr_account_id`): a receiver that ignores unknown keys keeps working.

The owner comes from your CRM and nothing else: the company owner field for Attack, and the CS
owner field (`company_cs` in [Set Owner Field Mappings](https://docs.clustr-ai.com/api-reference/set-owner-field-mappings.md))
for Defense. Clustr reads it from your CRM when it sends the alert. If your CRM cannot be
reached at that moment, it uses the owner from the last CRM sync instead.

### `defense_interaction`

Someone at one of your customers interacted with a competitor's salesperson. Every retained
interaction alerts, likes included, in both directions. Each interaction alerts once.

```json theme={null}
{
  "event": "competitor_monitoring.alert",
  "alert_id": "0b7d3c52-6a1e-5f39-9c1d-2e8f4a6b7c90",
  "tenant_id": "4e1b2c3d-5f6a-4b7c-8d9e-0a1b2c3d4e5f",
  "fingerprint": "a41f09c3e8b2d6f7",
  "alert_type": "defense_interaction",
  "use_case": "defense",
  "account_scope": null,
  "occurred_at": "2026-10-06T14:22:05Z",
  "account": {
    "id": "1035",
    "clustr_account_id": "acc_4bwcc6fsx7kz8aqg8hg6z0bf0p",
    "name": "Acme",
    "domain": "acme.com",
    "linkedin_url": "https://www.linkedin.com/company/acme",
    "crm_id": "9876543210",
    "crm_provider": "hubspot",
    "is_customer": true,
    "is_target": false
  },
  "owner": { "role": "cs_owner", "email": "lea.cs@example.com", "name": "Lea Martin" },
  "competitor": { "id": "c1a2b3c4-d5e6-7890-abcd-ef1234567890", "name": "Wiz", "linkedin_url": "https://www.linkedin.com/company/wiz" },
  "competitor_salesperson": {
    "name": "Jane Rep",
    "title": "Customer Success Manager",
    "linkedin_url": "https://www.linkedin.com/in/jane-rep",
    "linkedin_id": "ACoAAB1234",
    "clustr_person_id": "per_4bwcc6fsx7kz8aqg8hg6z0bf0p"
  },
  "engaged_person": { "name": "Paul Martin", "title": "VP Engineering", "linkedin_url": "https://www.linkedin.com/in/paul-martin" },
  "interaction": { "type": "like", "direction": "inbound", "text": "", "critical_labels": [] },
  "engagement": null,
  "post": { "url": "https://www.linkedin.com/feed/update/urn:li:activity:7300000000000000001", "text": "Three things we learned moving our security team to a single platform." },
  "app_url": "https://app.clustr-ai.com/accounts/1035/competitors"
}
```

### `attack_critical_comment`

A competitor's salesperson left a comment classified as critical (a meeting request, a
comparison, an objection handled, and so on) on the post of someone at a non-customer account.

```json theme={null}
{
  "event": "competitor_monitoring.alert",
  "alert_id": "5f2a8e14-3b7c-5d09-a6e1-7c4b9d2f0a13",
  "tenant_id": "4e1b2c3d-5f6a-4b7c-8d9e-0a1b2c3d4e5f",
  "fingerprint": "c09d27aa41f3b8e5",
  "alert_type": "attack_critical_comment",
  "use_case": "attack",
  "account_scope": "target",
  "occurred_at": "2026-10-06T09:41:17Z",
  "account": {
    "id": "2210",
    "name": "Initech",
    "domain": "initech.com",
    "linkedin_url": "https://www.linkedin.com/company/initech",
    "crm_id": "",
    "crm_provider": "",
    "is_customer": false,
    "is_target": true
  },
  "owner": { "role": "account_owner", "email": "marine.b@example.com", "name": "Marine B" },
  "competitor": { "id": "c1a2b3c4-d5e6-7890-abcd-ef1234567890", "name": "Wiz", "linkedin_url": "https://www.linkedin.com/company/wiz" },
  "competitor_salesperson": {
    "name": "Jane Rep",
    "title": "Account Executive",
    "linkedin_url": "https://www.linkedin.com/in/jane-rep",
    "linkedin_id": "ACoAAB1234",
    "clustr_person_id": "per_4bwcc6fsx7kz8aqg8hg6z0bf0p"
  },
  "engaged_person": { "name": "Sam Lee", "title": "Head of Security", "linkedin_url": "https://www.linkedin.com/in/sam-lee" },
  "interaction": {
    "type": "comment",
    "direction": "outbound",
    "text": "Happy to show you how we compare, we replaced them at three teams like yours.",
    "critical_labels": ["comparison", "meeting_request"]
  },
  "engagement": null,
  "post": { "url": "https://www.linkedin.com/feed/update/urn:li:activity:7300000000000000002", "text": "We are rethinking our cloud security stack this quarter. Who should we talk to?" },
  "app_url": "https://app.clustr-ai.com/accounts/2210/competitors"
}
```

### `attack_engagement_threshold`

A non-customer account reached your threshold: at least `attack_threshold_interactions`
interactions over `attack_threshold_days` days (10 in 30 by default), with one competitor's
team (`attack_threshold_scope` `per_competitor`, the default) or with all your competitors
together (`all_competitors`). There is no cooldown: the alert is sent again after every
collection run while the account stays at or above the threshold, each time with a new
`alert_id`.

```json theme={null}
{
  "event": "competitor_monitoring.alert",
  "alert_id": "9c4e1a77-0d2b-5e63-b8f4-1a3c5e7d9b20",
  "tenant_id": "4e1b2c3d-5f6a-4b7c-8d9e-0a1b2c3d4e5f",
  "fingerprint": "7e3b5d1f0a9c2e48",
  "alert_type": "attack_engagement_threshold",
  "use_case": "attack",
  "account_scope": "other",
  "occurred_at": "2026-10-07T03:00:00Z",
  "account": {
    "id": "4480",
    "name": "Globex",
    "domain": "globex.com",
    "linkedin_url": "https://www.linkedin.com/company/globex",
    "crm_id": "",
    "crm_provider": "",
    "is_customer": false,
    "is_target": false
  },
  "owner": null,
  "competitor": { "id": "c1a2b3c4-d5e6-7890-abcd-ef1234567890", "name": "Wiz", "linkedin_url": "https://www.linkedin.com/company/wiz" },
  "competitor_salesperson": null,
  "engaged_person": null,
  "interaction": null,
  "engagement": { "interactions": 12, "period_days": 30, "salespeople": 3, "scope": "per_competitor", "competitors": 1 },
  "post": null,
  "app_url": "https://app.clustr-ai.com/accounts/4480/competitors"
}
```

A sample sent with [Test Competitor Alert Webhook](https://docs.clustr-ai.com/api-reference/test-competitor-alert-webhook.md)
has the same shape, an `attack_critical_comment` with made-up values, plus `"test": true`.

## `competitor_monitoring.team_refresh`

Sent once after each finished team refresh of a competitor. The same recap is posted once to
each distinct Slack channel you configured. A follow-up employment check (a profile checked again
a few days after a refresh could not place it) that finds a leaver or a role change sends one
too, with `joiners` at 0.

| Field | Type | Description |
| - | - | - |
| `event` | string | Always `competitor_monitoring.team_refresh` |
| `tenant_id` | string (uuid) | Your workspace |
| `id` | string (uuid) | The refresh. Use it to dedupe |
| `competitor` | object | `id`, `name`, `linkedin_url` |
| `finished_at` | string (RFC 3339) | When the refresh finished |
| `joiners` | integer | New Sales or CS people found. They are followed automatically while you have free slots, and added Paused past your limit |
| `leavers` | integer | People who left the competitor |
| `function_changes` | integer | People still there who moved out of Sales or CS |
| `joiner_names` | string\[] | The first 5 joiners |
| `leaver_names` | string\[] | The first 5 leavers |
| `app_url` | string | The competitor's team in Clustr |

```json theme={null}
{
  "event": "competitor_monitoring.team_refresh",
  "tenant_id": "4e1b2c3d-5f6a-4b7c-8d9e-0a1b2c3d4e5f",
  "id": "e3f1a2b4-5c6d-4e7f-8a9b-0c1d2e3f4a5b",
  "competitor": { "id": "c1a2b3c4-d5e6-7890-abcd-ef1234567890", "name": "Wiz", "linkedin_url": "https://www.linkedin.com/company/wiz" },
  "finished_at": "2026-10-07T02:14:09Z",
  "joiners": 3,
  "leavers": 1,
  "function_changes": 0,
  "joiner_names": ["Ana Diaz", "Ben Okafor", "Chloe Roux"],
  "leaver_names": ["Dan Weiss"],
  "app_url": "https://app.clustr-ai.com/competitor-monitoring/competitors/c1a2b3c4-d5e6-7890-abcd-ef1234567890"
}
```

## Signature

When a webhook secret is saved, every request carries `X-ClustR-Signature`:

```
X-ClustR-Signature: t=1759831325,v1=5b1f0c...e9a2
```

`v1` is the hex HMAC-SHA256 of `<t>.<raw body>`, keyed with your secret. It is computed on each
attempt, over the exact bytes sent, so verify against the raw request body before parsing it.
Compare in constant time, and reject a `t` older than a few minutes to stop replays.

```python Python theme={null}
import hashlib
import hmac
import 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.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)
```

```javascript Node.js theme={null}
const crypto = require("crypto");

function verify(secret, header, rawBody, tolerance = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.`)
    .update(rawBody) // a Buffer of the raw request body
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Without a saved secret the requests are sent unsigned.

## Delivery and retries

Webhooks go through a queue, so they leave Clustr within a few seconds, not in the same instant
as the Slack post.

* **Up to 3 attempts.** A failed attempt is retried after 5 seconds, then after 20 seconds.
  After the third failure the delivery is dropped.
* **Within one attempt**, a `408`, `429`, `500`, `502`, `503` or `504` answer is retried a few
  times with a short backoff before the attempt counts as failed.
* **30-second timeout** per request.
* **Once per alert.** An alert is queued at most once per URL. A retry can still deliver the
  same alert twice if your endpoint timed out after processing it, so dedupe on `alert_id`
  (or `id` for a team refresh).

A broken webhook never holds up the Slack posts, and a Slack failure never holds up the
webhook.


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