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

# Event Types

> Full JSON payload samples for message.sent, message.delivered, message.failed, message.replied, link.clicked, the email events, and contact_list.analyzed.

Every webhook event has a consistent envelope:

```json theme={null}
{
  "data": {
    "event_type": "<event-type>",
    "id": "<event-or-message-id>",
    "occurred_at": "<ISO-8601 UTC timestamp, e.g. 2025-01-17T10:30:00.278Z>",
    "payload": {/* event-specific payload */}
  },
  "meta": { "attempt": 1, "max_attempts": 4 }
}
```

All timestamps (`occurred_at`, `sent_at`, `delivered_at`, `failed_at`, `received_at`, `clicked_at`) are ISO-8601 UTC with millisecond precision and a `Z` suffix. Phone numbers (`to`, `from`) are E.164 strings. The envelope owns the event identity (`id`, `occurred_at`); the event-specific `payload` carries only event fields and resource references (`message_id`, `tracking_link_id`).

## Identifiers

`data.id` uniquely identifies the **event**, and together with `event_type` it is the delivery deduplication key — if you receive the same `(event_type, id)` pair twice, treat the second as a redelivery. It also arrives in the `X-Event-ID` header.

* **Message events** (`message.sent`, `message.delivered`, `message.failed`): `id` equals `message_id` — the message *is* the event identity, since a message emits at most one event of each type. The same `id` therefore recurs across one message's lifecycle (`message.sent` then `message.delivered`), distinguished by `event_type`.
* **`message.replied`**: `id` equals `message_id` and identifies the **inbound** message (the reply itself), not the outbound message it responds to. Correlate via `conversation_id`.
* **`link.clicked`**: `id` is a unique per-click identifier — every unique click delivers its own event. `tracking_link_id` identifies the link and recurs across clicks of that link.

## Test events

Message events include `is_test` (boolean). It is `true` for test messages (sent from the app's test flow or via `POST /v1/projects/{id}/test`) and for the synthetic event produced by the dashboard's **Send test webhook** button (which additionally corresponds to no real message: its ids are random). Production traffic always carries `is_test: false`. `link.clicked` has no `is_test` field. The test event's sample payload carries `"carrier": "Verizon"`.

## Carrier values <a id="carrier-values" />

`carrier` (string or `null`) sits in `payload` next to `to` and `from` on `message.sent`, `message.delivered`, `message.failed`, and `message.replied`. It is `null` when the carrier is not known.

The three national networks always arrive as exactly `AT&T`, `Verizon`, or `T-Mobile`. Brands and subsidiaries are reported under the network they run on:

| Value | Also covers |
| - | - |
| `AT&T` | Cricket, FirstNet |
| `Verizon` | TracFone, Straight Talk |
| `T-Mobile` | Metro, Sprint, Mint Mobile, Boost |

Any other non-null value is a regional carrier's own registered name (for example `CELLULAR SOUTH, INC.`); treat it as an opaque string.

## `message.sent` <a id="message-sent" />

Triggered when a message is successfully accepted by the carrier. `carrier` is the recipient's mobile carrier; see [Carrier values](#carrier-values).

```json theme={null}
{
  "data": {
    "event_type": "message.sent",
    "id": "msg_7f8a9b0c1d2e3f4g",
    "occurred_at": "2025-01-17T10:30:00.278Z",
    "payload": {
      "message_id": "msg_7f8a9b0c1d2e3f4g",
      "conversation_id": "conv_1a2b3c4d5e6f7g8h",
      "project_id": "proj_9i8h7g6f5e4d3c2b",
      "to": "+15551234567",
      "from": "+15559876543",
      "carrier": "Verizon",
      "status": "sent",
      "sent_at": "2025-01-17T10:30:00.278Z",
      "message_text": "Hi Jane, thanks for supporting our campaign!",
      "is_test": false,
      "contact": {
        "phone_number": "+15551234567",
        "custom_fields": {
          "First Name": "Jane",
          "Last Name": "Smith",
          "Email": "jane@example.com",
          "City": "Austin",
          "State": "TX"
        }
      }
    }
  },
  "meta": { "attempt": 1, "max_attempts": 4 }
}
```

## `message.delivered` <a id="message-delivered" />

Triggered when the carrier confirms the message was delivered to the recipient's device. `carrier` is the recipient's mobile carrier; see [Carrier values](#carrier-values).

```json theme={null}
{
  "data": {
    "event_type": "message.delivered",
    "id": "msg_7f8a9b0c1d2e3f4g",
    "occurred_at": "2025-01-17T10:30:15.412Z",
    "payload": {
      "message_id": "msg_7f8a9b0c1d2e3f4g",
      "conversation_id": "conv_1a2b3c4d5e6f7g8h",
      "project_id": "proj_9i8h7g6f5e4d3c2b",
      "to": "+15551234567",
      "from": "+15559876543",
      "carrier": "Verizon",
      "status": "delivered",
      "sent_at": "2025-01-17T10:30:00.278Z",
      "delivered_at": "2025-01-17T10:30:15.412Z",
      "message_text": "Hi Jane, thanks for supporting our campaign!",
      "is_test": false,
      "contact": {
        "phone_number": "+15551234567",
        "custom_fields": { "First Name": "Jane", "VoterID": "TX12345678" }
      }
    }
  },
  "meta": { "attempt": 1, "max_attempts": 4 }
}
```

`sent_at` is always present on `message.delivered`. When the carrier's delivery receipt arrives before the send is recorded, it is the carrier-reported send time, or the delivery time if none was reported.

## `message.failed` <a id="message-failed" />

Triggered when the carrier rejects the message or delivery fails. `error_code` is a short delivery-failure code (e.g. `"300"` opted-out, `"012"` invalid destination) paired with a human-readable `error_message`. The full list of codes, messages, and how to react to each lives in [Delivery error codes](/api-reference/webhooks/error-codes). Fields describing a message that was accepted before failing (`sent_at`, `message_text`) may be absent when the message was rejected at send time. `carrier` is the recipient's mobile carrier, or `null` when the message was rejected before it reached a carrier; see [Carrier values](#carrier-values).

```json theme={null}
{
  "data": {
    "event_type": "message.failed",
    "id": "msg_7f8a9b0c1d2e3f4g",
    "occurred_at": "2025-01-17T10:30:20.007Z",
    "payload": {
      "message_id": "msg_7f8a9b0c1d2e3f4g",
      "conversation_id": "conv_1a2b3c4d5e6f7g8h",
      "project_id": "proj_9i8h7g6f5e4d3c2b",
      "to": "+15551234567",
      "from": "+15559876543",
      "carrier": "Verizon",
      "status": "failed",
      "failed_at": "2025-01-17T10:30:20.007Z",
      "error_code": "012",
      "error_message": "Invalid destination number",
      "is_test": false,
      "contact": { "phone_number": "+15551234567", "custom_fields": {} }
    }
  },
  "meta": { "attempt": 1, "max_attempts": 4 }
}
```

## `message.replied` <a id="message-replied" />

Triggered when an inbound SMS/MMS is received from a contact. `carrier` is the sender's mobile carrier; see [Carrier values](#carrier-values).

```json theme={null}
{
  "data": {
    "event_type": "message.replied",
    "id": "msg_inbound_abc123",
    "occurred_at": "2025-01-17T11:00:00.531Z",
    "payload": {
      "message_id": "msg_inbound_abc123",
      "conversation_id": "conv_1a2b3c4d5e6f7g8h",
      "project_id": "proj_9i8h7g6f5e4d3c2b",
      "from": "+15551234567",
      "to": "+15559876543",
      "carrier": "Verizon",
      "text": "Thanks for the update!",
      "media_urls": [],
      "is_opt_out": false,
      "is_opt_in": false,
      "received_at": "2025-01-17T11:00:00.531Z",
      "is_test": false,
      "contact": {
        "phone_number": "+15551234567",
        "custom_fields": { "First Name": "Jane", "Last Name": "Smith" }
      }
    }
  },
  "meta": { "attempt": 1, "max_attempts": 4 }
}
```

To answer a `message.replied` event with your own text, call `POST /v1/conversations/{conversation_id}/messages`; the reply goes out from the same number inside the same thread and its outcome arrives on `message.sent` / `message.delivered` / `message.failed`. See [Two-Way Conversations](/api-reference/two-way-conversations).

## `link.clicked` <a id="link-clicked" />

Triggered when a recipient clicks a tracking link in your message.

```json theme={null}
{
  "data": {
    "event_type": "link.clicked",
    "id": "click_xyz789",
    "occurred_at": "2025-01-17T10:35:00.844Z",
    "payload": {
      "tracking_link_id": "link_xyz789",
      "message_id": "msg_7f8a9b0c1d2e3f4g",
      "project_id": "proj_9i8h7g6f5e4d3c2b",
      "short_url": "https://link.yourdomain.com/abc123",
      "destination_url": "https://yoursite.com/volunteer-signup",
      "clicked_at": "2025-01-17T10:35:00.844Z",
      "device": "iPhone",
      "browser": "Safari 17.2",
      "os": "iOS 17.2",
      "ip_address": "203.0.113.42",
      "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1",
      "is_bot": false,
      "is_unique_click": true,
      "is_share_click": false,
      "contact": {
        "phone_number": "+15551234567",
        "custom_fields": { "First Name": "Jane", "City": "Austin" }
      }
    }
  },
  "meta": { "attempt": 1, "max_attempts": 4 }
}
```

### What "unique" means

`is_unique_click` is `true` the first time a given **device fingerprint** clicks a
particular tracking link, and `false` for that fingerprint's later clicks on the
same link.

The fingerprint is derived from the clicker's **IP address and User-Agent** — it
identifies a device on a network, not a person. Two consequences worth planning
around if you aggregate these events yourself:

* One person who taps a link on cell data and again on Wi-Fi produces **two**
  unique clicks, because the IP changed.
* Two people behind one shared connection (a household or office NAT) using the
  same device model and browser can collapse into **one** unique click.

Uniqueness is scoped **per link**, and each recipient receives their own tracking
link, so one person's repeat clicks never suppress another recipient's.

Clicks we identify as automated — carrier link scanners, security crawlers, and
link-preview fetchers — arrive with `is_bot: true` and are excluded from the
click totals shown in campaign reports.

## Email events

<Info>
  These five event types can be subscribed on any endpoint alongside the message
  events. They fire for organizations with email enabled.
</Info>

Email events use the same envelope as message events: `data.event_type`,
`data.id`, `data.occurred_at`, and `data.payload`, with `meta.attempt`. There
are only five, and each one is deliberate:

| Event | Fires when |
| - | - |
| `email.delivered` | The recipient's mailbox provider accepted the message. |
| `email.bounced` | A **permanent** bounce. Soft bounces are deferred and retried, and never fire this event. An address held back by send-time screening is not a bounce and does not fire it. |
| `email.complained` | The recipient marked the message as spam. |
| `email.unsubscribed` | A one-click or hosted-preferences unsubscribe. |
| `email.clicked` | A **unique, non-bot** click. A recipient's repeat clicks on the same link fire once. |

There is no `email.opened`: open tracking is a pixel, which makes it both
approximate and, at campaign volume, the highest-rate event we could send. There
is no inbound email event either, because the product has no inbox.

Test sends and seed addresses never emit email webhooks.

### Deduplication

Every email payload carries a stable `id` that is also the dedup key, so a
redelivery of the same event reuses the same value:

| Event | `id` format |
| - | - |
| `email.delivered`, `email.bounced`, `email.complained` | `<ses_message_id>:<kind>`, where kind is `delivery`, `bounce`, or `complaint` |
| `email.unsubscribed` (one-click / hosted page) | `unsub:<email_message_id>` |
| `email.clicked` | `<email_message_id>:<link_id>` |

The HTML body of a campaign is never included in a webhook payload.

### `email.delivered` <a id="email-delivered" />

```json theme={null}
{
  "data": {
    "event_type": "email.delivered",
    "id": "0100018f2c3d4e5f-a1b2c3d4:delivery",
    "occurred_at": "2026-09-02T15:04:05.123Z",
    "payload": {
      "id": "0100018f2c3d4e5f-a1b2c3d4:delivery",
      "email_message_id": "01J9X4M2Q7R5T8V0Y2A4C6E8G0",
      "campaign_id": "3f7a9c1e-8b24-4d6f-a2e5-9c7b3f1a8d42",
      "sender_identity_id": "7c1de5f0-9a42-4b6d-8e33-2f5a9c7b1e64",
      "recipient": "voter@example.com",
      "occurred_at": "2026-09-02T15:04:05.123Z"
    }
  },
  "meta": { "attempt": 1, "max_attempts": 4 }
}
```

### `email.bounced` <a id="email-bounced" />

Only permanent bounces fire. `bounce_type` and `bounce_subtype` come from the
mailbox provider; `reason` is the SMTP diagnostic code when one is supplied.

`bounce_class` is `address` when the mailbox does not exist or refuses all mail,
and `policy` when the receiving mail system refused this message for policy or
reputation reasons. Only `address` bounces are suppressed.

```json theme={null}
{
  "data": {
    "event_type": "email.bounced",
    "id": "0100018f2c3d4e5f-a1b2c3d4:bounce",
    "occurred_at": "2026-09-02T15:04:05.123Z",
    "payload": {
      "id": "0100018f2c3d4e5f-a1b2c3d4:bounce",
      "email_message_id": "01J9X4M2Q7R5T8V0Y2A4C6E8G0",
      "campaign_id": "3f7a9c1e-8b24-4d6f-a2e5-9c7b3f1a8d42",
      "sender_identity_id": "7c1de5f0-9a42-4b6d-8e33-2f5a9c7b1e64",
      "recipient": "voter@example.com",
      "occurred_at": "2026-09-02T15:04:05.123Z",
      "bounce_type": "Permanent",
      "bounce_subtype": "General",
      "bounce_class": "address",
      "reason": "smtp; 550 5.1.1 user unknown"
    }
  },
  "meta": { "attempt": 1, "max_attempts": 4 }
}
```

### `email.complained` <a id="email-complained" />

`reason` is the provider's feedback type (for example `abuse`), and is `null`
when the provider does not supply one.

```json theme={null}
{
  "data": {
    "event_type": "email.complained",
    "id": "0100018f2c3d4e5f-a1b2c3d4:complaint",
    "occurred_at": "2026-09-02T15:04:05.123Z",
    "payload": {
      "id": "0100018f2c3d4e5f-a1b2c3d4:complaint",
      "email_message_id": "01J9X4M2Q7R5T8V0Y2A4C6E8G0",
      "campaign_id": "3f7a9c1e-8b24-4d6f-a2e5-9c7b3f1a8d42",
      "sender_identity_id": "7c1de5f0-9a42-4b6d-8e33-2f5a9c7b1e64",
      "recipient": "voter@example.com",
      "occurred_at": "2026-09-02T15:04:05.123Z",
      "reason": "abuse"
    }
  },
  "meta": { "attempt": 1, "max_attempts": 4 }
}
```

Treat this event as final. We suppress the address automatically; re-adding it
to a list will not send to it again.

### `email.unsubscribed` <a id="email-unsubscribed" />

`scope` says what the recipient opted out of, and `reason` is `one_click` for an
RFC 8058 one-click unsubscribe or the reason chosen on the hosted preferences
page.

```json theme={null}
{
  "data": {
    "event_type": "email.unsubscribed",
    "id": "unsub:01J9X4M2Q7R5T8V0Y2A4C6E8G0",
    "occurred_at": "2026-09-02T15:04:05.123Z",
    "payload": {
      "id": "unsub:01J9X4M2Q7R5T8V0Y2A4C6E8G0",
      "email_message_id": "01J9X4M2Q7R5T8V0Y2A4C6E8G0",
      "campaign_id": "3f7a9c1e-8b24-4d6f-a2e5-9c7b3f1a8d42",
      "sender_identity_id": "7c1de5f0-9a42-4b6d-8e33-2f5a9c7b1e64",
      "recipient": "voter@example.com",
      "occurred_at": "2026-09-02T15:04:05.123Z",
      "scope": "identity",
      "reason": "one_click"
    }
  },
  "meta": { "attempt": 1, "max_attempts": 4 }
}
```

A mailbox provider retrying the one-click POST does not produce a second event:
the dedup key is the message, and a recipient unsubscribes from one send once.

### `email.clicked` <a id="email-clicked" />

Fires once per recipient per link. Repeat clicks on the same link by the same
recipient do not fire again, and clicks we identify as automated (link scanners,
security crawlers, preview fetchers) never fire at all.

```json theme={null}
{
  "data": {
    "event_type": "email.clicked",
    "id": "01J9X4M2Q7R5T8V0Y2A4C6E8G0:9c7b3f1a-8d42-4e6f-a2e5-3f7a9c1e8b24",
    "occurred_at": "2026-09-02T15:04:05.123Z",
    "payload": {
      "id": "01J9X4M2Q7R5T8V0Y2A4C6E8G0:9c7b3f1a-8d42-4e6f-a2e5-3f7a9c1e8b24",
      "email_message_id": "01J9X4M2Q7R5T8V0Y2A4C6E8G0",
      "campaign_id": "3f7a9c1e-8b24-4d6f-a2e5-9c7b3f1a8d42",
      "recipient": "voter@example.com",
      "link_id": "9c7b3f1a-8d42-4e6f-a2e5-3f7a9c1e8b24",
      "occurred_at": "2026-09-02T15:04:05.123Z",
      "device_type": "mobile",
      "browser": "Safari",
      "os": "iOS"
    }
  },
  "meta": { "attempt": 1, "max_attempts": 4 }
}
```

Click events are flushed in batches, so `occurred_at` is the moment of the click
rather than the moment of delivery. `device_type`, `browser`, and `os` are
best-effort and may be `null`.

## Contact list events

### `contact_list.analyzed` <a id="contact-list-analyzed" />

Fires once when a line-type analysis run on a contact list finishes. It is opt-in: subscribe to it on the endpoint alongside the other events. Use it instead of polling `GET /v1/contact-lists/{id}` after `POST /v1/contact-lists/{id}/analyze`.

```json theme={null}
{
  "data": {
    "event_type": "contact_list.analyzed",
    "id": "5b0e2c94-7d1a-4f38-9a6c-c2e81f4d7a03",
    "occurred_at": "2026-09-29T14:12:40.518Z",
    "payload": {
      "id": "5b0e2c94-7d1a-4f38-9a6c-c2e81f4d7a03",
      "list_id": "3f7a9c1e-8b24-4d6f-a2e5-9c7b3f1a8d42",
      "name": "Spring Outreach List",
      "analysis": {
        "status": "complete",
        "analyzed_numbers": 11800,
        "breakdown": {
          "mobile": 11200,
          "landline": 300,
          "voip": 250,
          "invalid": 50
        }
      },
      "analyzed_url": "https://api.politicalcomms.com/v1/contact-lists/3f7a9c1e-8b24-4d6f-a2e5-9c7b3f1a8d42/download?type=analyzed"
    }
  },
  "meta": { "attempt": 1, "max_attempts": 4 }
}
```

`analyzed_url` is an API URL, so download it with your `X-API-Key` (see `GET /v1/contact-lists/{id}/download`). It does not expire.


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