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

# Contacts Overview

> The team-wide contact directory — people of record, independent of any campaign.

A **contact** is a person your team knows about. A **lead** is one of that person's memberships of one campaign. The directory endpoints operate on contacts; the `/campaigns/{campaign_id}/leads*` endpoints operate on leads.

<Info>
  One contact can have zero, one or many leads. A contact you imported last month and never enrolled anywhere has no lead rows at all — and can still be emailed directly. See [Direct Email](/contacts/direct-email).
</Info>

| | Contact | Lead |
| - | - | - |
| Scope | Team-wide | One campaign |
| Identity | The person of record, keyed by email | A membership row pointing at a contact |
| Carries | Signal, tags, source provenance, engagement rollups | Per-campaign status, sequence step, opens/replies for that campaign |
| Scope family | `contacts:*` | `leads:*` |
| Endpoints | `/contacts*` | `/campaigns/{campaign_id}/leads*` |

`GET /contacts/{contact_id}` returns both: the contact's own fields plus a `campaigns` array with one entry per lead.

## Contact Signal

Every contact carries a derived engagement `signal`, computed by a periodic rollup job from all of that contact's campaign memberships. The first matching rule wins:

| Signal | Set when |
| - | - |
| `UNSUBSCRIBED` | Suppressed for your team — an unsubscribe row exists for that email address |
| `BOUNCED` | A membership bounced and the contact has never otherwise engaged |
| `HOT_LEAD` | A thread carries a positive intent tag (Demo Booked / Interested) |
| `ENGAGED` | Has replied or opened, or a thread carries the Question intent tag |
| `CONTACTED` | Has been sent to, but no engagement recorded |
| `NEVER_CONTACTED` | No membership has been sent yet |

### Pinning a signal

**PUT** `/api/public/v1/contacts/{contact_id}/signal`

A manual override wins over the derived value unconditionally, so a pinned `HOT_LEAD` survives every recompute. Send `null` to clear the pin and return to the derived signal.

```python theme={null}
response = requests.put(
    f"{base_url}/api/public/v1/contacts/{contact_id}/signal",
    headers={"X-API-Key": api_key},
    json={"signal_override": "HOT_LEAD"}
)
```

`signal_override` is required as a key — omitting it is a 400, not a no-op. An unrecognised value returns 400 with the allowed list in `detail.allowed`.

<Info>
  Requires `contacts:update`.
</Info>

## Searching the Directory

**GET** `/api/public/v1/contacts`

```python theme={null}
response = requests.get(
    f"{base_url}/api/public/v1/contacts",
    headers={"X-API-Key": api_key},
    params={"signal": "HOT_LEAD", "has_replied": True, "limit": 50}
)

for contact in response.json()["contacts"]:
    print(contact["email"], contact["signal"], contact["campaign_count"])
```

All filters AND together. List-valued filters (`signal`, `tag_ids`, `source_type`) OR within themselves.

| Parameter | Type | Description |
| - | - | - |
| `signal` | array | Match any of these signals |
| `tag_ids` | array | Contacts carrying any of these tags |
| `is_unsubscribed` | bool | Team-level suppression |
| `is_bounced` | bool | Any membership bounced |
| `source_type` | array | `CSV`, `MANUAL`, `COPY`, `EXTERNAL_EMAIL`, `API` |
| `source_name` | string | Exact origin name, e.g. the uploaded file name |
| `campaign_id` | string | Only contacts with a membership in this campaign |
| `not_in_campaign_id` | string | Exclude contacts already in this campaign |
| `last_touch_after` | datetime | Last outbound contact at or after this time |
| `last_touch_before` | datetime | Last outbound contact strictly before this time |
| `has_replied` | bool | Whether the contact ever replied |
| `has_opened` | bool | Whether the contact ever opened |
| `search` | string | Substring match across email, name and company |
| `list_id` | string | Narrow to the members of a saved list |
| `page` / `limit` | int | Pagination — `limit` is 1–100, default 20 |
| `sort_by` / `sort_desc` | — | Sort column and direction, default `created_at` descending |

`not_in_campaign_id` is the useful one for outreach: it gives you everyone matching a filter who is *not* already being worked, which is how you avoid double-emailing across campaigns.

<Info>
  Requires `contacts:read`.
</Info>

## Importing Contacts

**POST** `/api/public/v1/contacts/import`

```python theme={null}
mapping = {
    "email": "email_address",
    "first_name": "fname",
    "company": "Company",
    "custom_city": "City"
}

with open("contacts.csv", "rb") as f:
    response = requests.post(
        f"{base_url}/api/public/v1/contacts/import",
        headers={"X-API-Key": api_key},
        files={"file": f},
        data={"mapping": json.dumps(mapping)}
    )

print(response.json())
# {"batch_id": "...", "total_rows": 500, "imported_rows": 498,
#  "skipped_rows": 2, "contacts_created": 310, "contacts_matched": 188}
```

`mapping` is a JSON string in the same direction the campaign upload uses: **lead field name → CSV column header**. Keys prefixed `custom_` land in the contact's free-form `custom_fields`.

<Warning>
  The direction matters and is the opposite of what the key order suggests. `{"email": "email_address"}` means "the CSV column called `email_address` fills the `email` field" — not the reverse.
</Warning>

Import **upserts team-wide**: an email already in the directory updates the existing contact rather than creating a duplicate. `contacts_created` and `contacts_matched` tell you which happened.

Two optional form fields:

| Field | Effect |
| - | - |
| `list_id` | Add every imported contact to this STATIC list; reported as `added_to_list` |
| `filename` | Origin name recorded as `source_name`, overriding the uploaded file's name |

<Info>
  Importing creates **no** campaign membership. Pair it with `POST /contacts/bulk/campaigns` to enrol imported contacts into a campaign. Requires `contacts:create`.
</Info>

## Bulk Operations

Four endpoints take a list of contact IDs and apply one change to all of them.

| Endpoint | Body | Scope |
| - | - | - |
| `POST /contacts/bulk/tags` | `{"contact_ids": [...], "tag_id": "..."}` | `contacts:update` |
| `POST /contacts/bulk/tags/remove` | `{"contact_ids": [...], "tag_id": "..."}` | `contacts:update` |
| `POST /contacts/bulk/lists` | `{"contact_ids": [...], "list_id": "..."}` | `contacts:update` |
| `POST /contacts/bulk/campaigns` | `{"contact_ids": [...], "campaign_id": "..."}` | `contacts:update` |

```python theme={null}
response = requests.post(
    f"{base_url}/api/public/v1/contacts/bulk/tags",
    headers={"X-API-Key": api_key},
    json={"contact_ids": ["650e8400-...", "750e8400-..."], "tag_id": "850e8400-..."}
)

print(response.json())
# {"success": true, "message": "Tagged 1 contacts",
#  "requested": 2, "affected": 1, "skipped": 1, "not_found": 0}
```

Every bulk call is a per-contact no-op on repeat, so re-running the same request is safe:

| Field | Meaning |
| - | - |
| `requested` | IDs you sent |
| `affected` | Contacts the operation actually changed |
| `skipped` | Contacts that already satisfied it |
| `not_found` | IDs outside your team |

`contact_ids` must be a non-empty list of UUIDs and is capped at **1000** entries; anything else is a 400. A tag or list belonging to another team is a 404.

<Warning>
  `/bulk/lists` only accepts STATIC lists. A DYNAMIC list computes its members from its filter, so writing membership to it is meaningless and returns 400 — edit the filter instead.
</Warning>

`/bulk/campaigns` creates one membership per contact. Contacts already in the campaign are skipped, and addresses blocked in any of the campaign's workspaces are reported under `details.blocked` without being enrolled.

## Saved Lists

A list is either an explicit set of contacts or a saved filter.

| Type | Membership | Use for |
| - | - | - |
| `STATIC` | Rows you write, via `contact_ids` at creation or `/contacts/bulk/lists` later | A fixed batch — webinar attendees, a purchased list |
| `DYNAMIC` | Evaluated from `filter_definition` at query time | A living segment — every `HOT_LEAD` not yet in a campaign |

A DYNAMIC list picks up newly imported contacts automatically, because nothing is stored about membership at all.

**POST** `/api/public/v1/contacts/lists`

```python theme={null}
response = requests.post(
    f"{base_url}/api/public/v1/contacts/lists",
    headers={"X-API-Key": api_key},
    json={
        "name": "Hot leads, not yet working",
        "list_type": "DYNAMIC",
        "filter_definition": {
            "signal": ["HOT_LEAD"],
            "not_in_campaign_id": "550e8400-e29b-41d4-a716-446655440000"
        }
    }
)
```

`filter_definition` takes exactly the same fields as the `GET /contacts` query parameters above.

| Endpoint | Scope | Notes |
| - | - | - |
| `GET /contacts/lists` | `contacts:read` | Optional `list_type` filter |
| `GET /contacts/lists/preview` | `contacts:read` | Evaluate an unsaved filter; returns `total` plus a `sample` |
| `GET /contacts/lists/{list_id}` | `contacts:read` | `cached_count` is `null` until the rollup job has counted the list once |
| `PATCH /contacts/lists/{list_id}` | `contacts:update` | Rename, re-describe or re-filter |
| `DELETE /contacts/lists/{list_id}` | `contacts:delete` | Removes the segment only — contacts are untouched |

<Tip>
  Call `GET /contacts/lists/preview` with a candidate filter before saving it. It runs the same compiler as the directory search, so the `total` it returns is exactly what the saved list will match.
</Tip>

`list_type` is immutable — create a new list to change direction. Creating returns **201**, not 200.

## Updating and Deleting Contacts

**PATCH** `/api/public/v1/contacts/{contact_id}`

Only the fields present in the body change. `custom_fields` merges key-by-key rather than replacing the stored object; pass `null` for a field to clear it.

```python theme={null}
response = requests.patch(
    f"{base_url}/api/public/v1/contacts/{contact_id}",
    headers={"X-API-Key": api_key},
    json={"company": "New Corp", "custom_fields": {"industry": "SaaS", "old_key": None}}
)
```

Updatable fields: `first_name`, `last_name`, `company`, `custom_fields`. Requires `contacts:update`.

**DELETE** `/api/public/v1/contacts`

The body is a plain JSON array of contact IDs — not an object.

```python theme={null}
response = requests.delete(
    f"{base_url}/api/public/v1/contacts",
    headers={"X-API-Key": api_key},
    json=["650e8400-e29b-41d4-a716-446655440000"]
)
# {"requested": 1, "deleted": 1, "not_found": 0}
```

<Warning>
  Deleting a contact removes it **and every campaign membership derived from it**, cascading to send-tracking rows attached to those memberships. This cannot be undone.

  Team-level suppression is keyed by email address rather than by contact, so an existing unsubscribe survives the deletion — re-importing that address will not resurrect the ability to email it.
</Warning>

Requires `contacts:delete`.

## Next Steps

<CardGroup cols={2}>
  <Card title="Direct Email" icon="send" href="/contacts/direct-email">
    Send a 1:1 email to a contact with no campaign behind it.
  </Card>

  <Card title="Leads" icon="users" href="/leads/overview">
    Manage leads inside a specific campaign.
  </Card>

  <Card title="Subsequences" icon="git-branch" href="/campaigns/subsequences">
    Route engaged leads onto conditional branches.
  </Card>

  <Card title="API Scopes" icon="key" href="/authentication/scopes">
    What each `contacts:*` scope grants.
  </Card>
</CardGroup>


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