---
title: Contacts
description: Search, manage, import, export, and enrich contacts along with their notes, activities, timeline, and deals.
---

Contacts are the people you send to. This group covers the full lifecycle: searching and filtering, creating and editing (singly and in bulk), CSV/XLSX/JSON import and export, the hydrated contact 360 view, the per-contact email and activity feeds, and CRM notes, activities, and deals attached to a contact. List endpoints return a `data` array plus a `pagination` envelope; errors follow the standard `{error, message, code, request_id}` shape (see [error codes](/api/error-codes/)).

## Search contacts

`POST /contacts/search`

Faceted, server-side contact search scoped to your organization. The request body holds the filters; pagination is via query params.

Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `cursor` | query | string | Opaque pagination cursor from the previous page's `pagination.next_cursor`. |
| `limit` | query | string | Page size (numeric string). |
| `category` | query | string | Convenience filter for a single category ID. |

### Request body

Every field is optional; an empty body matches all contacts in the organization.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `query` | string | No | Text search across core fields (name, email, company). |
| `custom_field_filters` | array | No | Per custom-field filters: `{ "name", "value", "type" }` where `type` is one of `equal`, `starts_with`, `ends_with`, `contains`. |
| `campaign_ids` | string[] | No | Contact must be in ALL of these campaigns. |
| `lead_status` | string | No | Filter to one derived lead status: `pending`, `active`, `completed`, `replied`, `bounced`, `failed`, `undeliverable`, or `unsubscribed`. Requires exactly one `campaign_ids` entry, otherwise the request is rejected with `lead_filter_requires_campaign`; an unknown value is rejected with `invalid_lead_status`. |
| `engagement` | string | No | Filter by engagement inside that campaign: `opened`, `not_opened`, `clicked`, `not_clicked`, `replied`, `not_replied`, or `bounced`. `opened` means a human open (machine opens never count); the `not_*` values match only leads sent at least one step. Combines with `lead_status` as AND. Requires exactly one `campaign_ids` entry (`lead_filter_requires_campaign`); an unknown value is rejected with `invalid_engagement`. |
| `category_ids` | string[] | No | Contact must have ALL of these categories. |
| `segment_ids` | string[] | No | Contact must be a member of ALL of these segments (conditions plus manual overrides). An id that is not a valid UUID is rejected with `400`; an unknown segment matches nothing. |
| `verification_status` | string | No | Filter by verification verdict: `valid`, `risky`, `invalid`, or `unknown`. |
| `min_campaigns` | integer | No | Minimum number of associated campaigns. |
| `max_campaigns` | integer | No | Maximum number of associated campaigns. |
| `subscribed` | boolean | No | Filter by subscription status. |
| `created_after` | string (RFC 3339) | No | Created on or after this time. |
| `created_before` | string (RFC 3339) | No | Created on or before this time. |
| `updated_after` | string (RFC 3339) | No | Updated on or after this time. |
| `updated_before` | string (RFC 3339) | No | Updated on or before this time. |
| `sort_by` | string | No | Sort column, e.g. `first_name`, `campaign_count`. |
| `reverse` | boolean | No | Descending when true. |

```json
{
  "query": "acme",
  "subscribed": true,
  "category_ids": ["6f1c0b1e-0c2a-4b3a-9c1e-2d3f4a5b6c7d"],
  "min_campaigns": 1,
  "sort_by": "first_name",
  "reverse": false
}
```

### Response

Returns a `data` array of contacts plus a `pagination` envelope.

```json
{
  "data": [
    {
      "id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d",
      "first_name": "Dana",
      "last_name": "Reyes",
      "email": "dana@acme.com",
      "company": "Acme",
      "phone": "+15551234567",
      "custom_fields": { "title": "VP Sales" },
      "subscribed": true,
      "campaigns": [{ "id": "c1...", "name": "Q3 Outbound" }],
      "categories": [{ "id": "6f1c...", "title": "VIP", "color": "#0ea5e9" }],
      "verification_status": "valid",
      "verification_reason": "recipient accepted",
      "verification_sub_status": "",
      "verification_source": "probe",
      "verification_provider": "builtin",
      "verification_checked_at": "2026-06-10T11:58:00Z",
      "is_catch_all": false,
      "esp_provider": "gmail",
      "updated_at": "2026-06-10T12:00:00Z",
      "created_at": "2026-05-01T09:30:00Z"
    }
  ],
  "pagination": {
    "total": 1280,
    "next_cursor": "c1_b3BhcXVlLWN1cnNvcg",
    "has_more": true
  }
}
```

Every contact carries its address verification: `verification_status` (`valid`, `risky`, `invalid`, or `unknown`), `verification_sub_status` (`catch_all`, `disposable`, `role`, `spamtrap`, `mailbox_full`, `no_mx`, `syntax`, `undisclosed`, or empty), `verification_source` (`probe` for the built-in check, `provider` for a connected verification service, `imported` for a verdict that came with the contact, `manual` for one a member set, empty when never checked), `verification_provider` (who produced it), `verification_reason`, `verification_checked_at`, and `verification_confidence` (0 to 100, scored from the check plus what real mail to the address showed; see [what real mail teaches the check](/guides/deliverability/#what-real-mail-teaches-the-check)). Campaigns never send to `invalid`, and send to `risky` only when their `risky_emails` setting is on.

When the search filters by exactly one campaign, each contact additionally carries a `campaign_lead` object with its processing state inside that campaign (`status`, `sent`, `opened`, `machine_opened`, `clicked`, `replied`, `bounced`, `current_step`, `last_activity_at`, and `failure_reason` when failed). `opened` counts steps opened by a person; steps fetched automatically by a mail client (Apple Mail Privacy Protection and similar) are in `machine_opened` instead, matching the machine opens the analytics summary reports. The `status` derivation, highest priority first, is `unsubscribed` (not subscribed), then `bounced`, `replied`, `failed` (a step could not be sent after every retry; `failure_reason` carries the sending worker's reason), `completed` (every email step sent, no reply), `active` (some steps sent, more to send), `undeliverable` (pre-send verification refused the address, so the campaign skips the lead and never sends to it), and `pending` (queued, nothing sent). A step counts as sent only once the sending worker has delivered it to the mailbox provider; a send the worker could not complete is retried on the campaign's next pass and never shows as sent. The `lead_status` filter narrows to one of these buckets.

When the search filters by exactly one campaign, the first page (no `cursor`) also includes a `lead_counts` object: per-status lead totals for that campaign, independent of the `lead_status` and `engagement` filters so every scope's total is available at once. Alongside the status buckets it carries engagement totals that match the `engagement` filter: `contacted` (leads sent at least one step), `opened` (a human open on any step), `clicked`, and `replied_any` (a reply on any step, whatever the derived status).

```json
{
  "lead_counts": {
    "total": 3140,
    "queued": 1980,
    "processing": 910,
    "completed": 27,
    "replied": 180,
    "bounced": 22,
    "failed": 3,
    "unsubscribed": 18
  }
}
```

On the first page (no `cursor`), the response includes a `counts` object with org-wide facet totals (independent of the request filters), useful for a browse sidebar. Later pages omit it:

```json
{
  "counts": {
    "total": 12480,
    "subscribed": 11902,
    "unsubscribed": 578,
    "in_campaign": 3140,
    "not_contacted": 9340,
    "categories": [
      { "category_id": "6f1c0b1e-0c2a-4b3a-9c1e-2d3f4a5b6c7d", "count": 420 }
    ]
  }
}
```

## Create contacts

`POST /contacts`

Creates one or more contacts. The body is a JSON array, so a single create is an array of length one.

Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`

### Request body

A JSON array of contact objects (at least one, up to the per-request maximum; an empty array is a `400`).

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | Yes | Contact email address. |
| `first_name` | string | No | First name. |
| `last_name` | string | No | Last name. |
| `company` | string | No | Company name. |
| `phone` | string | No | Phone number. |
| `campaigns` | string[] | No | Campaign IDs to add the contact to. |
| `categories` | string[] | No | Category IDs to assign. |
| `segments` | string[] | No | Segment IDs to pin the contact into, as a manual include override, so it belongs whether or not the conditions match it. An unknown id is rejected with `400` before any contact is written, and the override is written in the same transaction as the contact, so a success response always means the membership exists. |
| `custom_fields` | object | No | String key/value custom fields. Keys may use letters, numbers, underscores, spaces, and dashes. |
| `subscribed` | boolean | No | Marketing-consent flag. Omit it to let a new contact default to subscribed and an existing one keep whatever it already had. |
| `verification_status` | string | No | A verdict you already hold for the address, in Warmbly's vocabulary (`valid`, `risky`, `invalid`, `unknown`) or any known service's (`ok`, `catch-all`, `do_not_mail`, `deliverable`, `ok_for_all`, ...). Stored as an imported verdict that the background check leaves alone. A value no known service writes is rejected with `unknown_verification_status`. |
| `verification_provider` | string | No | The vocabulary `verification_status` is written in: `zerobounce`, `millionverifier`, `neverbounce`, `bouncer`, `kickbox`, `emailable`, `debounce`, `clearout`, `emaillistverify`, or `warmbly`. Omit it to have the value recognised by itself. An unknown name is rejected with `unknown_verification_provider`. |

An address you already have is matched (lowercased) and enriched rather than duplicated: fields you send replace what is stored, fields you omit or send empty are left alone, and `custom_fields` is merged key by key. Use `PATCH /contacts/:id` to clear a value.

```json
[
  {
    "email": "lee@globex.com",
    "first_name": "Lee",
    "last_name": "Ng",
    "company": "Globex",
    "categories": ["6f1c0b1e-0c2a-4b3a-9c1e-2d3f4a5b6c7d"],
    "custom_fields": { "title": "Head of Ops" }
  }
]
```

### Response

Returns the created contacts as a bare JSON array (same contact shape as search).

```json
[
  {
    "id": "2c3d4e5f-6071-4b2c-9d3e-4f5a6b7c8d9e",
    "first_name": "Lee",
    "last_name": "Ng",
    "email": "lee@globex.com",
    "company": "Globex",
    "phone": "",
    "custom_fields": { "title": "Head of Ops" },
    "subscribed": true,
    "campaigns": [],
    "categories": [{ "id": "6f1c...", "title": "VIP", "color": "#0ea5e9" }],
    "verification_status": "unknown",
    "esp_provider": "",
    "updated_at": "2026-06-11T10:00:00Z",
    "created_at": "2026-06-11T10:00:00Z"
  }
]
```

## Bulk update contacts

`PATCH /contacts`

Applies one set of edits across a list of contacts: add/remove campaigns and categories, set custom-field operations, and toggle subscription. Up to 1000 contacts per batch.

Auth: **Scope** `BULK_CONTACTS` · **Org permission** `manage_contacts`

### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `contacts` | string[] | Yes | Contact IDs to edit (1 to 1000). |
| `add_campaigns` | string[] | No | Campaign IDs to add. |
| `remove_campaigns` | string[] | No | Campaign IDs to remove. |
| `add_categories` | string[] | No | Category IDs to add. |
| `remove_categories` | string[] | No | Category IDs to remove. |
| `fields` | array | No | Custom-field operations: `{ "type", "key", "value" }` where `type` is `ADD`, `EDIT`, `DELETE`, or `RENAME`. |
| `subscribe` | boolean | No | Set subscription status for all listed contacts. |

```json
{
  "contacts": ["1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d", "2c3d4e5f-6071-4b2c-9d3e-4f5a6b7c8d9e"],
  "add_categories": ["6f1c0b1e-0c2a-4b3a-9c1e-2d3f4a5b6c7d"],
  "subscribe": false,
  "fields": [{ "type": "EDIT", "key": "title", "value": "Decision Maker" }]
}
```

### Response

Returns the updated contacts as a bare JSON array (contact shape as above).

```json
[
  {
    "id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d",
    "email": "dana@acme.com",
    "subscribed": false,
    "categories": [{ "id": "6f1c...", "title": "VIP", "color": "#0ea5e9" }],
    "updated_at": "2026-06-11T10:05:00Z",
    "created_at": "2026-05-01T09:30:00Z"
  }
]
```

## Bulk delete contacts

`DELETE /contacts`

Deletes a list of contacts. Up to 1000 IDs per batch.

Auth: **Scope** `BULK_CONTACTS` · **Org permission** `manage_contacts`

### Request body

A JSON array of contact ID strings (1 to 1000; an empty array is a `400`).

```json
["1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d", "2c3d4e5f-6071-4b2c-9d3e-4f5a6b7c8d9e"]
```

### Response

`204 No Content`.

## Export contacts

`POST /contacts/export`

Exports contacts to CSV, XLSX, or JSON. The response is the file itself, not JSON.

Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`

### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `format` | string | Yes | `csv`, `xlsx`, or `json`. |
| `scope` | string | Yes | `all`, `filtered`, or `selected`. |
| `contact_ids` | string[] | No | Contact IDs when `scope` is `selected`. |
| `filters` | object | No | A search-contacts filter body when `scope` is `filtered`. |
| `fields` | string[] | No | Column identifiers in display order (built-ins like `email`, `first_name`, or `custom:<key>`). Empty uses the default columns. `lead_status`, `lead_opened`, `lead_clicked` and `lead_replied` are the contact's engagement inside the one campaign named in `filters.campaign_ids`; they are blank when the filters do not name exactly one campaign. |
| `filename` | string | No | Filename without extension. Sanitized server-side; empty falls back to `contacts-<YYYY-MM-DD>`. |

With `scope` set to `selected`, `filters` is optional and is applied on top of `contact_ids`; pass the campaign there to populate the `lead_*` columns for the selected rows.

```json
{
  "format": "csv",
  "scope": "filtered",
  "filters": { "subscribed": true },
  "fields": ["email", "first_name", "last_name", "company", "custom:title"],
  "filename": "subscribed-contacts"
}
```

### Response

`200 OK` with the file as an attachment. The relevant headers are:

| Header | Description |
| --- | --- |
| `Content-Type` | The export's MIME type (CSV, XLSX, or JSON). |
| `Content-Disposition` | `attachment; filename="..."`. |
| `X-Total-Rows` | Number of rows written. |

Exports are capped at 50,000 rows. Larger sets should be split via filters or selection.

## Preview an import

`POST /contacts/import/preview`

Uploads a CSV or XLSX file and returns detected columns plus a small sample so the client can build a column mapping before committing.

Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`

Send the file as `multipart/form-data` with a `file` form field. Uploads are capped at 50 MB.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `file` | form-data | file | The CSV/XLSX upload. |

### Response

```json
{
  "filename": "leads.csv",
  "format": "csv",
  "total_rows": 1243,
  "columns": ["Email", "First", "Last", "Company"],
  "has_header": true,
  "sample_rows": [
    ["dana@acme.com", "Dana", "Reyes", "Acme"]
  ],
  "suggested_mapping": [
    { "index": 0, "target": "email" },
    { "index": 1, "target": "first_name" },
    { "index": 2, "target": "last_name" },
    { "index": 3, "target": "company" }
  ]
}
```

`sample_rows` is capped at 20 rows. `suggested_mapping` is a default the client may override.

## Commit an import

`POST /contacts/import/commit`

Re-uploads the file with a mapping and dedup options, applies it, and returns per-row results.

Auth: **Scope** `BULK_CONTACTS` · **Org permission** `manage_contacts`

Send `multipart/form-data` with a `file` field and an `options` field containing the JSON below as a string.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `file` | form-data | file | The CSV/XLSX upload (max 50 MB). |
| `options` | form-data | string | JSON-encoded commit options (below). |

### `options` fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `mapping` | array | Yes | Column mappings: `{ "index", "target", "custom_key", "verification_provider" }`. `target` is `ignore`, `email`, `first_name`, `last_name`, `company`, `phone`, `subscribed`, `categories`, `verification_status`, or `custom` with the name in `custom_key`. `custom:<key>` is still accepted as the older spelling. Exactly one column must map to `email`. A `verification_status` column is read in the vocabulary named by `verification_provider` (see [Create contacts](#create-contacts)), or recognised value by value when it is omitted; a cell nobody recognises leaves that contact unverified rather than failing the row. The preview suggests this target itself when a column's header or values look like another service's results. |
| `dedup` | string | Yes | `skip`, `update`, or `create_duplicate` for rows whose email matches an existing contact. |
| `has_header` | boolean | Yes | Whether the first row is a header. |
| `category_ids` | string[] | No | Categories to assign to imported contacts. |
| `campaign_ids` | string[] | No | Campaigns to add imported contacts to. |
| `segment_ids` | string[] | No | Segments to pin imported contacts into, as a manual include override. Applies to imported, updated, and skipped-but-linked contacts alike. |
| `subscribed_default` | boolean | No | Subscription state for new contacts when no subscribed column is mapped. Defaults to true. |

```json
{
  "mapping": [
    { "index": 0, "target": "email" },
    { "index": 1, "target": "first_name" },
    { "index": 3, "target": "custom", "custom_key": "Company Mobile" }
  ],
  "dedup": "update",
  "has_header": true,
  "category_ids": ["6f1c0b1e-0c2a-4b3a-9c1e-2d3f4a5b6c7d"],
  "subscribed_default": true
}
```

### Response

```json
{
  "total": 1243,
  "imported": 1180,
  "updated": 41,
  "skipped": 18,
  "failed": 4,
  "started_at": "2026-06-11T10:10:00Z",
  "ended_at": "2026-06-11T10:10:07Z",
  "errors": [
    { "line": 57, "email": "not-an-email", "reason": "invalid email" }
  ],
  "quality": {
    "malformed": 4,
    "disposable": 0,
    "role": 62,
    "bad_share_pct": 0.3,
    "flagged": false
  }
}
```

`quality` describes the addresses in the file: `malformed` are not addresses at all, `disposable` are on known throwaway domains, and `role` counts shared inboxes such as `info@`. `bad_share_pct` is malformed plus disposable, as a percentage of the file; role addresses are deliberately excluded from it, since mailing a shared inbox is a choice rather than a defect. `flagged` is set above 25% on files of at least 20 rows, and carries a `summary` sentence. It is advisory: a flagged import still stores every row it could parse. A list bad enough to matter is refused at campaign launch instead.

Imports are capped at 50,000 rows. `errors` carries at most the first 1,000 entries; past that `errors_truncated` is `true` and the counters, not the list, are the real totals. Every row lands in exactly one of `imported`, `updated`, `skipped`, or `failed`, so those four always sum to `total`.

A custom-field name may use letters, numbers, underscores, spaces, and dashes (`Company Mobile`, `first-name`, `plan_tier`). Anything else is a `400` on the whole request, raised before any row is written, along with a mapping that names no `email` column or a `custom` column with no `custom_key`. Per-row `errors` are reserved for problems with the data itself.

## Verification overview

`GET /contacts/verification`

Reports which verifier checks this workspace's addresses and the contacts by verdict. **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`.

### Response

```json
{
  "provider": "millionverifier",
  "connection_id": "9a1b...",
  "credits": 48210,
  "builtin_ready": true,
  "counts": { "valid": 11240, "risky": 380, "invalid": 512, "unknown": 1890, "pending": 120 }
}
```

`provider` is `builtin` or `millionverifier`. `credits` is the connected service's remaining balance; `provider_error` is set instead when the service is connected but unusable (a rejected key, no credits), in which case the built-in check is in use. `builtin_ready` says whether the built-in mailbox probe can run on this instance. `pending` is the share of `unknown` nobody has checked yet.

## Verify or override contacts

`POST /contacts/verification`

Queues a fresh check of the listed contacts, or records a manual verdict on them. **Scope** `BULK_CONTACTS` · **Org permission** `manage_contacts`.

### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `action` | string | Yes | `verify` queues a re-check (each contact updates as its verdict lands); `mark_deliverable` records `valid`; `mark_undeliverable` records `invalid`. Manual verdicts are never re-checked automatically. Anything else is rejected with `invalid_action`. |
| `contacts` | string[] | No | Contact ids, up to the bulk maximum per request. |
| `campaign_id` | string | No | Instead of, or as well as, `contacts`: every lead of this campaign that verification refused. |

At least one contact must be selected (`no_contacts`). Marking leads deliverable resumes any campaign of the workspace that was paused for verification.

### Response

```json
{ "affected": 512, "action": "verify", "queued": true }
```

## Look up a contact by email

`GET /contacts/lookup`

Resolves a sender address to a contact in your organization. Returns `200` with `{"contact": null}` when nothing matches, so unknown senders render a clean empty state rather than a `404`. A display-name wrapped address (`Name <addr@example.com>`) is accepted and unwrapped.

Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `email` | query | string | The email address to resolve (required). |

### Response

```json
{
  "contact": {
    "id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d",
    "first_name": "Dana",
    "last_name": "Reyes",
    "email": "dana@acme.com",
    "company": "Acme",
    "subscribed": true,
    "campaigns": [],
    "categories": [],
    "verification_status": "valid",
    "esp_provider": "gmail",
    "updated_at": "2026-06-10T12:00:00Z",
    "created_at": "2026-05-01T09:30:00Z"
  }
}
```

## Get a contact

`GET /contacts/:id`

Returns the hydrated contact 360 payload: the contact plus an engagement summary, when present suppression state, and a `verification` object explaining the verdict: `status`, `confidence`, `reasons` (sentences, strongest first), `decisive` (true when real mail rather than a check decided the status), and `evidence`, the observations it was scored from, newest first, each `{ "kind", "detail", "observed_at" }` with `kind` one of `delivered`, `opened`, `clicked`, `replied`, `auto_replied`, `bounced_recipient`, `bounced_other`. Engagement and suppression counts are org-scoped; they are returned only when an organization is selected.

Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | UUID | Contact ID. |

### Response

```json
{
  "id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d",
  "first_name": "Dana",
  "last_name": "Reyes",
  "email": "dana@acme.com",
  "company": "Acme",
  "phone": "+15551234567",
  "custom_fields": { "title": "VP Sales" },
  "subscribed": true,
  "campaigns": [{ "id": "c1...", "name": "Q3 Outbound" }],
  "categories": [{ "id": "6f1c...", "title": "VIP", "color": "#0ea5e9" }],
  "verification_status": "valid",
  "esp_provider": "gmail",
  "updated_at": "2026-06-10T12:00:00Z",
  "created_at": "2026-05-01T09:30:00Z",
  "source": "import",
  "source_detail": "q3-leads.csv",
  "first_seen_at": "2026-05-01T09:30:00Z",
  "engagement": {
    "total_sent": 4,
    "total_opened": 3,
    "total_clicked": 1,
    "total_replied": 1,
    "total_bounced": 0,
    "total_complained": 0,
    "last_sent_at": "2026-06-09T08:00:00Z",
    "last_opened_at": "2026-06-09T08:14:00Z",
    "last_replied_at": "2026-06-09T11:02:00Z"
  },
  "suppression": null
}
```

`engagement.total_opened` and `engagement.last_opened_at` count opens by a person; opens a mail client or security gateway fetched automatically are left out, as they are in campaign analytics. A person's click counts as an open too; an automated click does not add to either field.

When the contact is suppressed, `suppression` is an object: `{ "id", "kind", "value", "reason", "source", "expires_at", "created_at" }`. `kind` is `email` when the contact's own address is on the list or `domain` when its whole domain is, `value` is the matching entry, and `source` is `bounce`, `complaint`, `unsubscribe`, `manual`, or `import`. `id` is the suppression entry, which `DELETE /suppressions/:id` lifts; see [deliverability and ops](/api/reference/deliverability-ops/#list-the-suppression-list).

## Update a contact

`PATCH /contacts/:id`

Partially updates a single contact. Only the fields present are changed. Campaign and category lists can be set wholesale or adjusted with diff-style add/remove.

Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | UUID | Contact ID. |

### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `first_name` | string | No | First name. |
| `last_name` | string | No | Last name. |
| `company` | string | No | Company. |
| `phone` | string | No | Phone. |
| `custom_fields` | object | No | Replaces the custom-fields map. |
| `subscribed` | boolean | No | Subscription status. |
| `campaigns` | string[] | No | Set the full campaign membership (nil leaves as-is). |
| `categories` | string[] | No | Set the full category list (nil leaves as-is). |
| `add_categories` | string[] | No | Diff-style add (ignored when `categories` is set). |
| `remove_categories` | string[] | No | Diff-style remove (ignored when `categories` is set). |

```json
{
  "company": "Acme Corp",
  "subscribed": true,
  "add_categories": ["6f1c0b1e-0c2a-4b3a-9c1e-2d3f4a5b6c7d"]
}
```

### Response

Returns the updated contact as a bare object (contact shape as in search).

```json
{
  "id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d",
  "first_name": "Dana",
  "last_name": "Reyes",
  "email": "dana@acme.com",
  "company": "Acme Corp",
  "subscribed": true,
  "campaigns": [],
  "categories": [{ "id": "6f1c...", "title": "VIP", "color": "#0ea5e9" }],
  "updated_at": "2026-06-11T10:20:00Z",
  "created_at": "2026-05-01T09:30:00Z"
}
```

## Delete a contact

`DELETE /contacts/:id`

Deletes a single contact.

Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | UUID | Contact ID. |

### Response

`204 No Content`.

## List emails sent to a contact

`GET /contacts/:id/emails`

Returns one row per email sent (or attempted) to the contact, newest first, with sender, campaign, sequence, and engagement timestamps. Pagination is keyed on the `(created_at, task_id)` of the last row.

Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | UUID | Contact ID. |
| `limit` | query | integer | Page size, 1 to 200 (default 50). |
| `before_at` | query | string (RFC 3339 nano) | `created_at` of the last row from the previous page. |
| `before_id` | query | UUID | `task_id` of the last row from the previous page. |

Both `before_at` and `before_id` must be supplied together; otherwise the cursor is ignored and the first page is returned.

### Response

Returns a `data` array plus a `pagination` envelope.

```json
{
  "data": [
    {
      "task_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
      "status": "sent",
      "message_id": "<abc@mail.acme.com>",
      "subject": "Quick question",
      "sent_at": "2026-06-09T08:00:00Z",
      "email_account_id": "e1...",
      "email_account_email": "rep@yourco.com",
      "email_account_name": "Rep One",
      "campaign_id": "c1...",
      "campaign_name": "Q3 Outbound",
      "step_id": "s1...",
      "step_name": "Email 1",
      "opened_at": "2026-06-09T08:14:00Z",
      "replied_at": "2026-06-09T11:02:00Z"
    }
  ],
  "pagination": {
    "total": 4,
    "next_cursor": null,
    "has_more": false
  }
}
```

## List a contact's timeline

`GET /contacts/:id/timeline`

Returns the merged activity feed for a contact: sends, opens, clicks (one per link, naming the link), replies, bounces, deliverability and suppression events, notes, meeting bookings, and lifecycle events (the contact's creation with its first-touch source, and every time it joined or left a campaign or a category). Requires a selected organization (org-scoped events would otherwise be hidden), so a request with no organization returns `400`.

Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | UUID | Contact ID. |
| `limit` | query | integer | Page size, 1 to 200 (default 50). Anything else is a `400`. |
| `cursor` | query | string | Opaque pagination cursor from `pagination.next_cursor`. A malformed cursor is a `400`. |
| `before` | query | string (RFC 3339 nano) | Deprecated. Returns the events strictly older than this timestamp, which can skip events that share an instant with the page boundary; use `cursor`. A value that is not an RFC 3339 timestamp is a `400`. Ignored when `cursor` is set. |

### Response

Returns a `data` array and the standard `pagination` envelope. Paginate by passing `pagination.next_cursor` back as `cursor` until `has_more` is `false`. The cursor is the exact position of the last event on the page (its time, source and row), so events that share a timestamp, common when a send, its open and a note land in the same second, are never skipped or repeated across pages. The top-level `has_more` mirrors `pagination.has_more` and is kept for clients written before the envelope. `pagination.total` is always `null`: the feed is merged from several tables and is never counted.

```json
{
  "data": [
    {
      "type": "email_clicked",
      "at": "2026-06-09T11:42:00Z",
      "email_account_id": "e1...",
      "email_account_email": "rep@yourco.com",
      "campaign_id": "c1...",
      "campaign_name": "Q3 Outbound",
      "step_id": "s1...",
      "step_name": "Intro",
      "subject": "Quick question",
      "machine": false,
      "link": {
        "id": "7c0f...",
        "url": "https://yourco.com/pricing?utm_source=warmbly&utm_medium=email&utm_campaign=q3_outbound&utm_content=pricing",
        "label": "Pricing",
        "utm_source": "warmbly",
        "utm_medium": "email",
        "utm_campaign": "q3_outbound",
        "utm_content": "pricing",
        "user_agent": "Mozilla/5.0 ..."
      }
    },
    {
      "type": "email_replied",
      "at": "2026-06-09T11:02:00Z",
      "email_account_id": "e1...",
      "email_account_email": "rep@yourco.com",
      "campaign_id": "c1...",
      "campaign_name": "Q3 Outbound",
      "task_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
      "subject": "Quick question"
    },
    {
      "type": "note",
      "at": "2026-06-08T16:30:00Z",
      "content": "Met at the conference, wants a follow-up in July.",
      "user_id": "u1..."
    },
    {
      "type": "campaign_added",
      "at": "2026-05-01T09:31:00Z",
      "campaign_id": "c1...",
      "campaign_name": "Q3 Outbound",
      "user_id": "u1..."
    },
    {
      "type": "contact_created",
      "at": "2026-05-01T09:30:00Z",
      "source": "import",
      "source_detail": "q3-leads.csv",
      "user_id": "u1..."
    }
  ],
  "has_more": false,
  "pagination": { "total": null, "next_cursor": null, "has_more": false }
}
```

`type` is one of `email_sent`, `email_opened`, `email_clicked`, `email_replied`, `email_bounced`, `reply_received`, `deliverability`, `suppressed`, `note`, `meeting_booked`, `meeting_rescheduled`, `meeting_canceled`, `contact_created`, `campaign_added`, `campaign_removed`, `category_added`, `category_removed`, `form_submitted`, or `page_hit`. Fields not relevant to an event type are omitted.

`email_opened` and `email_clicked` carry `machine`: `true` when an automated fetcher did it rather than the person (a mail privacy proxy, a fetch inside ten seconds of the send, several links followed within seconds). Per-link `email_clicked` events carry `machine_reason` (`prefetch`, `instant` or `burst`), and per-event `email_opened` rows carry it too (`prefetch` or `instant`); an open summarised from the lead alone carries only the flag. Both kinds carry an `origin` object when the event was logged: `client` when the user agent names a mail client or image proxy, `device_type`, `os`, `browser`, `browser_version`, and `country_code`, `region`, `city` when the consumer could resolve them. Opens appear once per event, so a contact who opened from two devices has two rows. Automated clicks are on the feed for the record but never count the step as clicked. An `email_clicked` event carries `link` with the link's `id`, `url`, `label` (its anchor text), the `utm_*` parameters the URL carried, and the `user_agent`; every link in an email is tracked on its own, so each link clicked is its own event. Clicks recorded before per-link attribution have no `link`.

A `form_submitted` event carries `form_id` and `form_name`. A `page_hit` event is a page view on your own site from a browser tied to the contact through an email-link ticket (see [Website tracking](/guides/website-tracking/)); `subject` is the page title, or its path when the page has none, and `page_hit` carries the full view: `url`, `path`, `title`, `referrer`, `referrer_domain`, `landing` (the first view of a session), the `utm_*` parameters, `device_type`, `os`, `browser`, `browser_version`, `device_brand`, `language`, `timezone`, `screen_width`, `screen_height`, and `country_code`, `region`, `city` when known.

Lifecycle events carry the name of what changed as it was at the time (`campaign_name`, or `category_id` plus `category_title`), so a later rename or deletion does not rewrite history. A `contact_created` event carries `source` (`manual`, `campaign`, `import`, `sheet_sync`, `api`, `ai_assistant`, or `unknown` for contacts that predate attribution) and `source_detail` (the file, campaign, sheet or API key name). The same values are on the contact itself as `source`, `source_detail` and `first_seen_at`, and never change after creation.

## Get a contact's campaign state

`GET /contacts/:id/campaigns`

Returns, for every campaign the contact is a lead of, the flow with this contact's progress on each step, the derived lead status, the last thing that happened, and what happens next. Requires a selected organization.

The next action is derived on read by the scheduler through the same constraints a real send goes through (the step's wait, the campaign's start date and sending windows, mailbox caps and spacing, the new-lead limit); a campaign is one self-perpetuating task, so nothing per contact is stored. `next.state` says how firm the timing is: `due` carries `scheduled_at`, the slot the scheduler would give the step on its next pass (leads ahead in the queue can still push it later); `waiting` carries `not_before` and a `constraint`; `paused` and `blocked` carry only the reason. `next` is absent once the flow has ended for the contact, and `ended_reason` says why.

Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | UUID | Contact ID. |

### Response

```json
{
  "data": [
    {
      "campaign_id": "c1...",
      "campaign_name": "Q3 Outbound",
      "campaign_status": "active",
      "lead_status": "active",
      "steps": [
        {
          "id": "s1...",
          "label": "Email 1",
          "kind": "email",
          "position": 0,
          "subject": "Quick question",
          "sent_at": "2026-06-09T08:00:00Z",
          "opened_at": "2026-06-09T08:14:00Z"
        },
        { "id": "s2...", "label": "Email 2", "kind": "email", "position": 1, "subject": "Following up" }
      ],
      "completed_steps": 1,
      "total_steps": 2,
      "current_step": { "id": "s1...", "label": "Email 1", "kind": "email", "position": 0, "subject": "Quick question", "sent_at": "2026-06-09T08:00:00Z", "opened_at": "2026-06-09T08:14:00Z" },
      "last_action": "Opened",
      "last_action_at": "2026-06-09T08:14:00Z",
      "next": {
        "step_id": "s2...",
        "step_label": "Email 2",
        "kind": "email",
        "subject": "Following up",
        "state": "waiting",
        "not_before": "2026-06-12T08:00:00Z",
        "constraint": "Waiting 3 days after Email 1"
      }
    }
  ]
}
```

`lead_status` uses the same values as the campaign Leads view: `pending`, `active`, `completed`, `replied`, `bounced`, `failed`, `unsubscribed`, or `undeliverable`. Each step carries whichever of `sent_at`, `opened_at`, `clicked_at`, `replied_at`, `bounced_at` and `failed_at` apply, plus `attempts` and `in_flight` (reserved for a worker whose result has not come back). While a branch condition is undecided, `next.step_id` is absent and `next.step_label` says the step depends on the contact's response.

## List a contact's activities

`GET /contacts/:id/activities`

Returns the structured CRM activity log for a contact (note, deal, task, campaign, and engagement events recorded in the CRM activity table). Requires a selected organization.

Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | UUID | Contact ID. |
| `limit` | query | integer | Page size, 1 to 100 (default 50). |
| `cursor` | query | UUID | Opaque cursor from the previous page's `pagination.next_cursor`. |

### Response

```json
{
  "data": [
    {
      "id": "ac1...",
      "contact_id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d",
      "organization_id": "org1...",
      "user_id": "u1...",
      "activity_type": "note_added",
      "metadata": { "note_id": "n1..." },
      "created_at": "2026-06-08T16:30:00Z"
    }
  ],
  "pagination": {
    "total": 12,
    "next_cursor": "ac0...",
    "has_more": true
  }
}
```

`activity_type` is a closed enum including `email_sent`, `email_opened`, `email_clicked`, `email_replied`, `email_bounced`, `note_added`, `note_updated`, `deal_created`, `deal_stage_changed`, `deal_won`, `deal_lost`, `task_created`, `task_completed`, `contact_created`, `contact_updated`, `campaign_added`, `campaign_removed`, `category_added`, and `category_removed`.

## List a contact's notes

`GET /contacts/:id/notes`

Returns the CRM notes attached to a contact, newest first. Requires a selected organization.

Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | UUID | Contact ID. |
| `limit` | query | integer | Page size, 1 to 100 (default 50). |
| `cursor` | query | UUID | Opaque cursor from the previous page's `pagination.next_cursor`. |

### Response

```json
{
  "data": [
    {
      "id": "n1b2c3d4-5f60-4a1b-8c2d-3e4f5a6b7c8d",
      "contact_id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d",
      "organization_id": "org1...",
      "user_id": "u1...",
      "content": "Met at the conference, wants a follow-up in July.",
      "created_at": "2026-06-08T16:30:00Z",
      "updated_at": "2026-06-08T16:30:00Z",
      "user": { "id": "u1...", "name": "Sam Rep" }
    }
  ],
  "pagination": {
    "total": 3,
    "next_cursor": null,
    "has_more": false
  }
}
```

## Create a contact note

`POST /contacts/:id/notes`

Adds a note to a contact. Requires a selected organization.

Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | UUID | Contact ID. |

### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `content` | string | Yes | Note body (1 to 10,000 characters). |

```json
{ "content": "Sent the proposal, following up Monday." }
```

### Response

`201 Created` with the created note (same shape as a note in the list).

```json
{
  "id": "n2c3d4e5-6071-4b2c-9d3e-4f5a6b7c8d9e",
  "contact_id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d",
  "organization_id": "org1...",
  "user_id": "u1...",
  "content": "Sent the proposal, following up Monday.",
  "created_at": "2026-06-11T10:30:00Z",
  "updated_at": "2026-06-11T10:30:00Z"
}
```

## Update a contact note

`PATCH /contacts/:id/notes/:noteId`

Edits a note's content. Requires a selected organization.

Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | UUID | Contact ID. |
| `noteId` | path | UUID | Note ID. |

### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `content` | string | No | New note body. |

```json
{ "content": "Sent the proposal, following up Tuesday." }
```

### Response

Returns the updated note.

```json
{
  "id": "n2c3d4e5-6071-4b2c-9d3e-4f5a6b7c8d9e",
  "contact_id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d",
  "organization_id": "org1...",
  "user_id": "u1...",
  "content": "Sent the proposal, following up Tuesday.",
  "created_at": "2026-06-11T10:30:00Z",
  "updated_at": "2026-06-11T10:35:00Z"
}
```

## Delete a contact note

`DELETE /contacts/:id/notes/:noteId`

Deletes a note. Requires a selected organization.

Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | UUID | Contact ID. |
| `noteId` | path | UUID | Note ID. |

### Response

`204 No Content`.

## List a contact's deals

`GET /contacts/:id/deals`

Returns the CRM deals associated with a contact as a bare JSON array.

Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts`

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | UUID | Contact ID. |

### Response

```json
[
  {
    "id": "d1b2c3d4-5f60-4a1b-8c2d-3e4f5a6b7c8d",
    "organization_id": "org1...",
    "pipeline_id": "p1...",
    "stage_id": "st1...",
    "contact_id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d",
    "name": "Acme expansion",
    "value": 12000,
    "currency": "USD",
    "status": "open",
    "expected_close_date": "2026-07-15T00:00:00Z",
    "campaign_id": "c1...",
    "created_at": "2026-06-05T09:00:00Z",
    "updated_at": "2026-06-10T12:00:00Z"
  }
]
```

`status` is `open`, `won`, or `lost`. `value`, `expected_close_date`, `won_at`, `lost_at`, `lost_reason`, `assigned_to`, `campaign_id`, and `source_mailbox_id` are nullable and omitted when unset.

## Segments

Segments are saved contact audiences: a list of conditions plus per-contact manual overrides. Membership is evaluated live on every read, so a segment never needs rebuilding. Every segment endpoint takes the contact scopes, except enrolling into a campaign, which writes leads and takes `WRITE_CAMPAIGNS`. Endpoints that operate on an existing segment address it by its `id`; besides `GET /segments`, the dashboard shows that ID on the segment page header (click to copy) and in the row menu of the Segments tab.

A segment object:

```json
{
  "id": "0b6f9c3e-2f7a-4c0e-9d8e-1a2b3c4d5e6f",
  "organization_id": "…",
  "name": "Warm fintech leads",
  "description": "Opened in the last 30 days, not yet replied",
  "color": "#0284c7",
  "match": "all",
  "conditions": [
    { "field": "custom.industry", "operator": "equals", "value": "fintech" },
    { "field": "last_opened_at", "operator": "within_days", "value": "30" },
    { "field": "emails_replied", "operator": "equals", "value": "0" }
  ],
  "contact_count": 412,
  "included_count": 3,
  "excluded_count": 1,
  "created_at": "2026-08-01T09:12:00Z",
  "updated_at": "2026-08-20T14:03:00Z"
}
```

`match` is `all` or `any`. A contact is a member when it matches the conditions or is manually included, and is not manually excluded. A segment with no conditions holds only its manual includes.

### Conditions

Each condition names a `field`, an `operator`, and either a `value` (scalar operators) or `values` (list operators). Fields and their kinds are returned by `GET /segments/fields`, including the workspace's custom fields as `custom.<key>` (written with the key in place of the angle-bracket placeholder, for example `custom.industry`).

| Kind | Fields | Operators | Value |
| --- | --- | --- | --- |
| text | `first_name`, `last_name`, `email`, `email_domain`, `phone`, `company`, `custom.*` | `equals`, `not_equals`, `contains`, `not_contains`, `starts_with`, `ends_with`, `is_empty`, `is_not_empty` | `value` string; comparisons ignore case |
| enum | `source`, `verification_status`, `esp_provider` | `in`, `not_in` | `values`, drawn from the field's `options` |
| bool | `subscribed`, `suppressed`, `is_catch_all` | `is_true`, `is_false` | none |
| date | `created_at`, `updated_at`, `last_sent_at`, `last_opened_at`, `last_clicked_at`, `last_replied_at` | `within_days`, `not_within_days` (`value` is a day count, 1 to 3650); `before`, `after` (`value` is `YYYY-MM-DD` or RFC 3339); `is_empty`, `is_not_empty` | see operators |
| number | `campaign_count`, `emails_sent`, `emails_opened`, `emails_clicked`, `emails_replied`, `emails_bounced` | `equals`, `not_equals`, `gt`, `gte`, `lt`, `lte` | `value`, a whole number |
| category | `category` | `in`, `not_in`, `is_empty`, `is_not_empty` | `values`, category ids |
| campaign | `campaign` | `in`, `not_in`, `is_empty`, `is_not_empty` | `values`, campaign ids |
| segment | `segment` | `in`, `not_in` | `values`, segment ids; at most five levels deep, no loops |

Engagement counters add up every campaign the contact has been in, and opens count human opens only. Limits: 50 conditions per segment, 200 values per list condition, 200 segments per workspace. A condition that fails validation is rejected with `400` and a message naming the condition.

### List, create, read, update, delete

`GET /segments` returns every segment with live counts under `data`. `POST /segments` creates one from `name` (required), `description`, `color` (`#rrggbb`), `match` and `conditions`; a duplicate name is a `409`. `GET /segments/:id` returns one segment. `PATCH /segments/:id` accepts the same fields, all optional. `DELETE /segments/:id` returns `204`, or `409` when another segment's conditions reference it or a campaign has it [linked as a live audience](/api/reference/campaigns/#replace-linked-segments); detach it first.

Auth: **Scope** `READ_CONTACTS` for reads, `WRITE_CONTACTS` for writes · **Org permission** `view_contacts` / `manage_contacts`

### Preview a definition

`POST /segments/preview`

Counts the contacts an unsaved definition would match. Send `match` and `conditions`; include `id` to keep that segment's manual overrides in the count while editing it.

```json
{ "contact_count": 412 }
```

### Manual overrides

`POST /segments/:id/members`

```json
{ "contacts": ["…", "…"], "mode": "include" }
```

`mode` is `include` (pin in), `exclude` (pin out) or `auto` (clear the override). Up to 1,000 contact ids per call; ids outside the organization are ignored. Returns `{ "updated": n }`.

`POST /segments/:id/members/lookup` takes `{ "contacts": [...] }` and returns `{ "data": { "<contact id>": "include" | "exclude" } }` for the contacts that carry an override.

`GET /segments/:id/overrides` lists every pinned contact (`contact_id`, `first_name`, `last_name`, `email`, `company`, `mode`, `created_at`) under `data`, includes first, newest first, capped at 500.

`GET /contacts/:id/segments` is the contact-side view: every segment in the organization with `member` (whether the contact is in it right now) and `mode` (its override, when any) under `data`.

Sequence action steps `add_to_segment` and `remove_from_segment` take a `segment_id` and apply the include or exclude override to the contact when the step runs; see [campaigns](/api/reference/campaigns/).

### Add to a campaign

`POST /segments/:id/add-to-campaign`

```json
{ "campaign_id": "…" }
```

Enrols every current member as a lead. Contacts already in the campaign are skipped, each new lead gets a `campaign_added` activity, and a running campaign is woken so the leads are scheduled. Returns `{ "campaign_id", "added", "members" }`. This is a snapshot: later members are not added until the call is repeated. Safe to retry. For a live link that keeps enrolling members as they join the segment, see [linked segments](/api/reference/campaigns/#list-linked-segments).

Auth: **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`
