---
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`, `replied`, `bounced`, or `unsubscribed`. Requires exactly one `campaign_ids` entry, otherwise the request is rejected. |
| `category_ids` | string[] | No | Contact must have ALL of these categories. |
| `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": "",
      "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
  }
}
```

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`, `clicked`, `replied`, `bounced`, `current_step`, `last_activity_at`, and `failure_reason` when failed). 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), 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` filter so every scope's total is available at once.

```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. |
| `custom_fields` | object | No | Arbitrary string key/value custom fields. |

```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. |
| `filename` | string | No | Filename without extension. Sanitized server-side; empty falls back to `contacts-<YYYY-MM-DD>`. |

```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" }`. `target` is `ignore`, `email`, `first_name`, `last_name`, `company`, `phone`, `subscribed`, `categories`, or `custom:<key>`. |
| `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. |
| `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:company_size", "custom_key": "company_size" }
  ],
  "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" }
  ]
}
```

Imports are capped at 50,000 rows.

## 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 and, when present, suppression state. 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",
  "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
}
```

When the contact is suppressed, `suppression` is an object: `{ "reason", "source", "expires_at", "created_at" }` where `source` is `bounce`, `complaint`, or `unsubscribe`.

## 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, replies, bounces, deliverability and suppression events, notes, and meeting bookings. 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). |
| `before` | query | string (RFC 3339 nano) | The `at` timestamp of the oldest event from the previous page. |

### Response

Returns a `data` array and a `has_more` flag (not a cursor envelope). Paginate by passing the oldest event's `at` as `before`.

```json
{
  "data": [
    {
      "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..."
    }
  ],
  "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`, or `meeting_canceled`. Fields not relevant to an event type are omitted.

## 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`, and `campaign_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.
