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

# Event Types

> Every webhook event, its exact data payload, and which ones actually fire.

Each delivery carries the same five-field envelope; only `data` changes. Envelope, headers, signing and retry behaviour are in [Webhooks Overview](/webhooks/overview). This page is the field-level reference.

```json theme={null}
{
  "event_type": "…",
  "event_id": "9f8e7d6c-5b4a-3928-1706-f5e4d3c2b1a0",
  "occurred_at": "2026-01-14T09:12:44.512000+00:00",
  "team_id": "11111111-2222-3333-4444-555555555555",
  "campaign_id": "550e8400-e29b-41d4-a716-446655440000",
  "data": { }
}
```

`campaign_id` is `null` for events that are not about a campaign — most often a reply to a [direct contact email](/contacts/direct-email), which has no campaign.

<Warning>
  **Six of the sixteen event types the API accepts do not currently deliver anything.** The subscription validator is more permissive than the delivery pipeline behind it. Read the status table below before building against a type. It is not a placeholder caution — `campaign_started` in particular has never been emitted at all.
</Warning>

## Status at a Glance

| Status | Event types |
| - | - |
| **Working** — emitted, mapped, deliverable | `reply_received`, `email_sent`, `email_delivered`, `email_bounced`, `email_opened`, `link_clicked`, `lead_status_changed`, `lead_unsubscribed`, `subsequence_entered`, `subsequence_completed` |
| **Reserved** — accepted and mapped, but nothing emits it | `campaign_completed` |
| **Emitted but not mapped** — the dispatcher drops it | `email_queued`, `email_send_failed`, `inbox_paused`, `domain_limit_reached` |
| **Neither emitted nor mapped** | `campaign_started` |

A subscription naming any of these validates with a `201`. The last three groups simply never produce a delivery.

***

## Replies

### `reply_received`

Category `replies`. Fires when an inbound email is matched to a conversation.

| Field | Type | Notes |
| - | - | - |
| `lead_email` | string | The address that replied |
| `subject` | string | Usually `Re: …` |
| `reply_text` | string | Plain-text body |
| `is_auto_reply` | boolean | See the caveat below |
| `inbox_email` | string \| null | Your mailbox that received it |

```json theme={null}
{
  "event_type": "reply_received",
  "campaign_id": "550e8400-e29b-41d4-a716-446655440000",
  "data": {
    "lead_email": "alice@prospect.com",
    "subject": "Re: Quick question",
    "reply_text": "Sure, send over the case study.",
    "is_auto_reply": false,
    "inbox_email": "john@yourdomain.com"
  }
}
```

<Warning>
  Two fields here are weaker than they look, and it depends which ingestion path handled the mail:

  * `inbox_email` is **always `null`** on the Stalwart sync path. It is populated on the workspace-email path. Treat it as optional and fall back to looking the thread up.
  * `is_auto_reply` is **hardcoded `false`** on two of the three emit sites. Auto-replies are not reliably flagged, so do not use this field to filter out-of-office responses — inspect `reply_text` instead.
</Warning>

This is the event that fires branch entry triggers. See [Subsequences](/campaigns/subsequences).

***

## Delivery

### `email_sent`

Category `delivery`. Fires once per outbound email accepted by the sending provider.

| Field | Type | Notes |
| - | - | - |
| `lead_email` | string | Recipient |
| `from_email` | string | Sending mailbox |
| `subject` | string | Rendered subject |
| `sequence_step` | integer | `0` for the initial email, `2`+ for follow-ups |
| `message_id` | string | The **provider's** message ID |

<Info>
  `message_id` here is the Azure Communication Services identifier, **not** the RFC `Message-ID` header. It will not match the `<direct_…@domain>` value returned by `POST /contacts/{id}/send-email`, and it is not what reply threading keys on. Do not use it to correlate a send with a reply.
</Info>

```json theme={null}
{
  "event_type": "email_sent",
  "data": {
    "lead_email": "alice@prospect.com",
    "from_email": "john@yourdomain.com",
    "subject": "Quick question",
    "sequence_step": 0,
    "message_id": "abcd1234-0000-1111-2222-333344445555"
  }
}
```

### `email_delivered`

Category `delivery`. Fires on the provider's delivery confirmation.

| Field | Type |
| - | - |
| `lead_email` | string |
| `delivered_at` | string (ISO 8601) |

The provider's own message ID is recorded internally but is not exposed in `data`.

### `email_bounced`

Category `delivery`. Fires on a hard or soft bounce.

| Field | Type | Notes |
| - | - | - |
| `lead_email` | string | |
| `bounce_type` | string | The provider's delivery status |
| `status_message` | string | Human-readable SMTP diagnostic |
| `sequence_step` | integer \| null | **Always `null`** — see below |

```json theme={null}
{
  "event_type": "email_bounced",
  "data": {
    "lead_email": "alice@prospect.com",
    "bounce_type": "Failed",
    "status_message": "550 5.1.10 RESOLVER.ADR.RecipientNotFound",
    "sequence_step": null
  }
}
```

<Warning>
  **This payload drops the useful half of the bounce classification.** The emitter records `smtp_status_code`, `bounce_category` and `bounce_reason_tag` — the structured fields you would actually branch on — and the public mapper does not forward any of them. You get `bounce_type` and a free-text `status_message` only.

  `sequence_step` is declared in the payload but never emitted, so it is **always `null`**.

  If you need reliable bounce classification today, parse `status_message` for the SMTP code, or read the contact's suppression state via `GET /contacts/{id}` — a bounce sets `is_suppressed: true` with `suppression_reason: "bounced"`.
</Warning>

***

## Engagement

### `email_opened`

Category `engagement`. Fires when the tracking pixel loads.

| Field | Type | Notes |
| - | - | - |
| `lead_email` | string | |
| `opened_at` | string (ISO 8601) | |
| `sequence_step` | integer \| null | **Always `null`** — see below |

```json theme={null}
{
  "event_type": "email_opened",
  "data": {
    "lead_email": "alice@prospect.com",
    "sequence_step": null,
    "opened_at": "2026-01-14T09:12:44.512000+00:00"
  }
}
```

<Warning>
  Neither of the two open-tracking paths emits `sequence_step`, so it is **always `null`**. You cannot tell from this event which email in the sequence was opened — only that some email to that address was. To attribute an open to a step, correlate on `lead_email` against your own record of the `email_sent` events you received.

  Mail clients that pre-fetch images produce opens that no human caused. Do not treat a single open as intent.
</Warning>

### `link_clicked`

Category `engagement`. Fires per tracked link.

| Field | Type | Notes |
| - | - | - |
| `lead_email` | string | |
| `link_index` | integer | Position of the link in the email body |
| `url` | string | The **original** destination, not the tracking redirect |
| `clicked_at` | string (ISO 8601) | |

```json theme={null}
{
  "event_type": "link_clicked",
  "data": {
    "lead_email": "alice@prospect.com",
    "link_index": 0,
    "url": "https://yoursite.com/pricing",
    "clicked_at": "2026-01-14T09:14:02.000000+00:00"
  }
}
```

Clicks are deduplicated per email and link index, so the same lead clicking the same link twice produces one event.

***

## Lifecycle

### `lead_status_changed`

Category `lifecycle`. Fires on every lead status transition.

| Field | Type |
| - | - |
| `lead_email` | string |
| `from_status` | string \| null |
| `to_status` | string |

Statuses: `PENDING`, `QUEUED`, `SENT`, `OPENED`, `REPLIED`, `FAILED`, `BOUNCED`, `UNSUBSCRIBED`, `EXTERNAL`, `BLOCKED`.

```json theme={null}
{
  "event_type": "lead_status_changed",
  "data": {
    "lead_email": "alice@prospect.com",
    "from_status": "SENT",
    "to_status": "REPLIED"
  }
}
```

<Warning>
  Deduplication is keyed on **lead + target status**, so a lead produces **at most one event per `to_status`**, ever. A normal progression `PENDING → SENT → OPENED → REPLIED` yields three events. But if a lead ever returns to a status it has already held, that second transition is silently suppressed. Do not treat this stream as a complete audit log of every write — it is a log of every status a lead first reached.

  `from_status` is `null` on the initial transition into `PENDING`.
</Warning>

### `lead_unsubscribed`

Category `lifecycle`. Fires once per lead.

| Field | Type | Values |
| - | - | - |
| `lead_email` | string | |
| `method` | string | `ONE_CLICK` (clicked the unsubscribe link) or `EMAIL_REPLY` (replied with an unsubscribe phrase) |

```json theme={null}
{
  "event_type": "lead_unsubscribed",
  "data": {
    "lead_email": "alice@prospect.com",
    "method": "ONE_CLICK"
  }
}
```

Deduplicated on lead alone — one event per lead, permanently. Unsubscribing also sets the contact's suppression state, so subsequent [direct emails](/contacts/direct-email) to that address fail with **409**.

### `subsequence_entered`

Category `lifecycle`. **New.** Fires when a lead enters a flow branch, either by an automatic trigger or a manual move.

| Field | Type | Notes |
| - | - | - |
| `lead_id` | UUID | Not an email address — branches are keyed on lead |
| `campaign_id` | UUID | |
| `entry_node_id` | UUID | The branch's first node |
| `trigger_reason` | string | See the two formats below |
| `entered_at` | string (ISO 8601) | |

```json theme={null}
{
  "event_type": "subsequence_entered",
  "campaign_id": "550e8400-e29b-41d4-a716-446655440000",
  "data": {
    "lead_id": "660e8400-e29b-41d4-a716-446655440000",
    "campaign_id": "550e8400-e29b-41d4-a716-446655440000",
    "entry_node_id": "770e8400-e29b-41d4-a716-446655440000",
    "trigger_reason": "KEYWORD: {\"phrases\": [\"interested\", \"pricing\"]}",
    "entered_at": "2026-01-14T09:20:11.004000+00:00"
  }
}
```

`trigger_reason` has two formats depending on how the lead got there:

| Source | Format | Example |
| - | - | - |
| Automatic trigger | `"{CONDITION_TYPE}: {params JSON}"` | `INTENT_TAG: {"tag_names": ["Demo Booked"]}` |
| Manual move | Fixed string | `Manual move via API` |

<Info>
  `entry_node_id` is the value `GET /campaigns/{id}/leads/{lead_id}/journey` reports as the branch's node, and the value `move-to-subsequence` takes as `target_subsequence_node_id`. It is **not** the `subsequence_id` used by `PUT` and `DELETE /flow/subsequences/{id}` — that is the entry **edge** id. The internal `entry_edge_id` is recorded but deliberately not exposed, since the public flow API addresses branches by node.
</Info>

Deduplicated on `lead_id + entry_node_id`, mirroring the uniqueness the enrollment table already enforces. One enrollment produces exactly one event, and a re-delivered trigger message cannot double-emit.

### `subsequence_completed`

Category `lifecycle`. **New.** Fires when a lead reaches the end of a branch.

| Field | Type |
| - | - |
| `lead_id` | UUID |
| `campaign_id` | UUID |
| `entry_node_id` | UUID |
| `completed_at` | string (ISO 8601) |

```json theme={null}
{
  "event_type": "subsequence_completed",
  "data": {
    "lead_id": "660e8400-e29b-41d4-a716-446655440000",
    "campaign_id": "550e8400-e29b-41d4-a716-446655440000",
    "entry_node_id": "770e8400-e29b-41d4-a716-446655440000",
    "completed_at": "2026-01-16T14:02:33.118000+00:00"
  }
}
```

`entry_node_id` identifies *which* branch completed — a lead can be enrolled in more than one. Same dedupe shape as `subsequence_entered`, so the two events pair up one-to-one per lead per branch.

<Warning>
  A lead that exits a branch early — because the campaign was paused, or the branch was deleted — does **not** produce this event. It fires only when the lead walks off the end of the branch's last step. Absence of `subsequence_completed` is not evidence of a bug.
</Warning>

### `campaign_completed` — reserved, does not fire

Category `lifecycle`. The API accepts this name in `event_types`, and the dispatcher knows how to map it, but **nothing writes it to the event stream**. Campaign completion is currently surfaced only as an in-app notification inside the ColdSend UI.

A subscription naming it is created successfully and then never delivers. It is listed here so the silence is expected rather than mysterious.

<Tip>
  To detect campaign completion today, poll `GET /campaigns/{id}` and watch for `status: "COMPLETED"`, or infer it from the absence of further `email_sent` events. Note that a campaign with `subsequences_enabled: true` can reach `COMPLETED` and still send branch emails afterwards, so completion is not the end of activity.
</Tip>

***

## Accepted but Not Delivered

These five validate at subscription time and never arrive. Listed so you do not debug a handler that was never going to be called.

| Event type | Category | Why it is silent |
| - | - | - |
| `campaign_completed` | `lifecycle` | Never emitted |
| `campaign_started` | `lifecycle` | Never emitted, and not mapped as deliverable |
| `email_queued` | `delivery` | Emitted internally, dropped by the dispatcher |
| `email_send_failed` | `delivery` | Emitted internally, dropped by the dispatcher |
| `inbox_paused` | `system` | Emitted internally, dropped by the dispatcher |
| `domain_limit_reached` | `system` | Emitted internally, dropped by the dispatcher |

<Warning>
  The `system` category consists **entirely** of `inbox_paused` and `domain_limit_reached` — neither is deliverable. A subscription created with `event_categories: ["system"]` will therefore receive **nothing at all**, ever. Do not use it.
</Warning>

***

## Categories

Subscribing by category is an alternative to naming types. The mapping is:

| Category | Members |
| - | - |
| `engagement` | `email_opened`, `link_clicked` |
| `delivery` | `email_sent`, `email_delivered`, `email_bounced` |
| `replies` | `reply_received` |
| `lifecycle` | `lead_status_changed`, `lead_unsubscribed`, `campaign_completed`, `subsequence_entered`, `subsequence_completed` |
| `system` | `domain_limit_reached`, `inbox_paused` |

<Warning>
  `event_types` and `event_categories` **AND** together rather than OR. If you set both, an event must appear in `event_types` *and* belong to one of the listed categories. Set one or the other.

  Categories also include the silent members above, so `lifecycle` looks broader than it behaves.
</Warning>

***

## Ordering and Guarantees

* **At-least-once.** Retries and manual redeliveries can produce the same `event_id` more than once. Deduplicate on it.
* **Not ordered.** `email_delivered` can arrive before `email_sent`, and `subsequence_entered` before the `reply_received` that caused it. Never assume sequence from arrival order — use `occurred_at`.
* **Best-effort emission.** Every emit site is wrapped so that a failure to record an event never fails the business operation it describes. Events can be missing. For anything you must not lose, reconcile against the REST API.
* **Same transaction as the write.** The subsequence events are written inside the enrollment's own transaction, so they never survive a rollback of the change they describe.

<Tip>
  Build handlers to be idempotent and order-independent from the start. Both properties are cheap to add on day one and expensive to retrofit after a duplicate has corrupted state.
</Tip>

## Next Steps

<CardGroup cols={2}>
  <Card title="Webhooks Overview" icon="webhook" href="/webhooks/overview">
    Signing, retries and the delivery log.
  </Card>

  <Card title="Subsequences" icon="git-branch" href="/campaigns/subsequences">
    What causes the two branch events.
  </Card>

  <Card title="Direct Email" icon="mail" href="/contacts/direct-email">
    Sends that produce `campaign_id: null`.
  </Card>

  <Card title="API Scopes" icon="key" href="/authentication/scopes">
    The `webhooks:*` family.
  </Card>
</CardGroup>


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