# Contacts

> List, create, update, and delete contacts in your workspace

Source: https://docs.graph8.com/developers/contacts/

> **Tip: Using the SDK or CLI?**
> The [CLI](https://docs.graph8.com/developers/cli/#g8-search-contacts) supports `g8 search-contacts`, `g8 create-contact`, and `g8 add-to-list`. The [JavaScript SDK](https://docs.graph8.com/developers/sdk/) tracks visitors and identifies users via `g8.identify()`.

Manage contacts in your graph8 workspace — your first-party CRM data.

Contacts get into your workspace through several paths: saving results from the [Search](https://docs.graph8.com/developers/search/) endpoints, importing from a CSV or CRM, creating manually via the API, or automatically during enrichment. Once a contact is in your workspace, these endpoints give you full read/write access at no credit cost.

> **Tip: Looking for new contacts?**
> These endpoints only return contacts **you already have**. To discover new contacts from graph8's global B2B database, use the [Search](https://docs.graph8.com/developers/search/) endpoints instead.

---

## List Contacts

`GET /contacts`

Returns a paginated list of contacts.

### Query Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page` | integer | `1` | Page number (1-indexed) |
| `limit` | integer | `50` | Items per page (1-200) |
| `email` | string | — | Filter by work email (exact match) |
| `list_id` | integer | — | Filter by list membership |
| `name` | string | — | Filter by first or last name (partial match, case-insensitive) |
| `job_title` | string | — | Filter by job title (partial match, case-insensitive) |
| `seniority_level` | string | — | Filter by seniority level (exact match) |
| `company_name` | string | — | Filter by company name (partial match, case-insensitive) |
| `company_id` | integer | — | Filter by company ID |
| `country` | string | — | Filter by country (exact match) |
| `state` | string | — | Filter by state (exact match) |
| `city` | string | — | Filter by city (partial match, case-insensitive) |
| `job_department` | string | — | Filter by job department (partial match, case-insensitive) |
| `industry` | string | — | Filter by company industry (partial match, case-insensitive) |
| `include_custom_fields` | boolean | `false` | When `true`, each contact gains a `custom_fields` object of its custom column values keyed by column slug (`name` from `GET /fields`). Default `false` keeps the legacy shape and adds no extra query. |

All filters are optional and can be combined. When multiple filters are provided, results must match all of them (AND logic).

### Example

**cURL**

```bash
# Basic pagination
curl "https://be.graph8.com/api/v1/contacts?limit=10&page=1" \
  -H "Authorization: Bearer $API_KEY"

# Search by name and company
curl "https://be.graph8.com/api/v1/contacts?name=jane&company_name=acme" \
  -H "Authorization: Bearer $API_KEY"

# Filter by seniority and country
curl "https://be.graph8.com/api/v1/contacts?seniority_level=VP&country=US" \
  -H "Authorization: Bearer $API_KEY"

# Filter by department and industry
curl "https://be.graph8.com/api/v1/contacts?job_department=Engineering&industry=SaaS" \
  -H "Authorization: Bearer $API_KEY"
```

**Python**

```python
# Basic pagination
response = requests.get(
    f"{BASE_URL}/contacts",
    headers=HEADERS,
    params={"limit": 10, "page": 1}
)
contacts = response.json()

# Search by name and company
response = requests.get(
    f"{BASE_URL}/contacts",
    headers=HEADERS,
    params={"name": "jane", "company_name": "acme"}
)

# Filter by department and industry
response = requests.get(
    f"{BASE_URL}/contacts",
    headers=HEADERS,
    params={"job_department": "Engineering", "industry": "SaaS"}
)
```

**TypeScript**

```typescript
// Basic pagination
const response = await fetch(`${BASE_URL}/contacts?limit=10&page=1`, {
  headers: { Authorization: `Bearer ${API_KEY}` }
});
const contacts = await response.json();

// Search by name and company
const filtered = await fetch(
  `${BASE_URL}/contacts?name=jane&company_name=acme`,
  { headers: { Authorization: `Bearer ${API_KEY}` } }
);

// Filter by department and industry
const deptFiltered = await fetch(
  `${BASE_URL}/contacts?job_department=Engineering&industry=SaaS`,
  { headers: { Authorization: `Bearer ${API_KEY}` } }
);
```

### Response

```json
{
  "data": [
    {
      "id": 101,
      "first_name": "Jane",
      "last_name": "Doe",
      "work_email": "jane@acme.com",
      "direct_phone": "+1...",
      "mobile_phone": "+1...",
      "job_title": "VP Sales",
      "job_department": "Sales",
      "seniority_level": "VP",
      "linkedin_url": "https://...",
      "city": "Berlin",
      "state": "BE",
      "country": "DE",
      "company_id": 1,
      "owner_id": "user_x",
      "owner_name": "John",
      "custom_fields": null
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 50,
    "total": 1,
    "has_next": false
  }
}
```

When called with `?include_custom_fields=true`, `custom_fields` is an object like `{"udo_lead_score_1712000000": "92", "udo_persona_1712000111": "Champion"}` (only set values appear; keys are the column slug). Rows with no custom values return `{}`.

---

## Get Contact

`GET /contacts/{contact_id}`

Returns detailed information about a single contact, including associated company data.

### Path Parameters

| Parameter | Type | Description |
|-----------|------|-------------|
| `contact_id` | integer | Contact ID |

### Query Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `include_custom_fields` | boolean | `false` | When `true`, the response gains a `custom_fields` object (same shape as the list endpoint). Default omits it (`custom_fields: null`). |

### Example

**cURL**

```bash
curl "https://be.graph8.com/api/v1/contacts/1" \
  -H "Authorization: Bearer $API_KEY"
```

**Python**

```python
response = requests.get(f"{BASE_URL}/contacts/1", headers=HEADERS)
contact = response.json()["data"]
```

**TypeScript**

```typescript
const response = await fetch(`${BASE_URL}/contacts/1`, {
  headers: { Authorization: `Bearer ${API_KEY}` }
});
const { data: contact } = await response.json();
```

### Response

```json
{
  "data": {
    "id": 1,
    "first_name": "Jane",
    "last_name": "Smith",
    "full_name": "Jane Smith",
    "work_email": "jane@acme.com",
    "personal_emails": null,
    "direct_phone": "+1-555-0100",
    "mobile_phone": null,
    "job_title": "VP of Engineering",
    "job_department": "Engineering",
    "seniority_level": "VP",
    "linkedin_url": "https://linkedin.com/in/janesmith",
    "twitter_url": null,
    "facebook_url": null,
    "city": "San Francisco",
    "state": "CA",
    "country": "US",
    "about": null,
    "confidence_score": 0.95,
    "company": {
      "id": 42,
      "name": "Acme Inc",
      "domain": "acme.com",
      "website": "https://acme.com",
      "industry": "Technology",
      "employee_count": "500",
      "city": "San Francisco",
      "state": "CA",
      "country": "US",
      "linkedin_url": "https://linkedin.com/company/acme",
      "logo_url": "https://logo.clearbit.com/acme.com"
    },
    "meta_data": {},
    "custom_fields": null,
    "created_at": "2026-01-15T10:30:00Z",
    "updated_at": "2026-02-01T14:20:00Z"
  }
}
```

### Errors

| Status | Meaning |
|--------|---------|
| `404` | Contact not found |

---

## Upsert Contact

`PUT /contacts/assert`

**The recommended way to write a contact.** Single contact upsert: matches an existing contact by `work_email` first, then by `linkedin_url`, updates it if found, and creates it if not. It is idempotent, so a retry never produces a duplicate or a `409`. Use it whenever the contact might already exist. SDK: `g8.contacts.upsert()`.

### Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `list_id` | integer | **Yes** | Target list |
| `work_email` | string | Conditional | Work email — at least one of `work_email` or `linkedin_url` is required |
| `linkedin_url` | string | Conditional | LinkedIn profile URL — at least one of `work_email` or `linkedin_url` is required |
| `first_name` | string | No | First name |
| `last_name` | string | No | Last name |
| `personal_emails` | string | No | Personal email addresses |
| `direct_phone` | string | No | Direct phone number |
| `mobile_phone` | string | No | Mobile phone number |
| `job_title` | string | No | Job title |
| `job_department` | string | No | Department |
| `seniority_level` | string | No | Seniority level |
| `city` | string | No | City |
| `state` | string | No | State or province |
| `country` | string | No | Country |
| `company_domain` | string | No | Company domain for matching |

### Example

**cURL**

```bash
curl -X PUT "https://be.graph8.com/api/v1/contacts/assert" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "list_id": 12345,
    "work_email": "jane@acme.com",
    "first_name": "Jane",
    "last_name": "Doe",
    "job_title": "VP Sales",
    "company_domain": "acme.com"
  }'
```

**SDK**

```typescript
import { g8 } from "@graph8/sdk";

g8.init({ apiKey: process.env.G8_API_KEY });
const { action } = await g8.contacts.upsert({
  list_id: 12345,
  work_email: "jane@acme.com",
  first_name: "Jane",
  job_title: "VP Sales",
});
// action is "created" or "updated"
```

### Response

```json
{ "data": { "action": "created", "count": 1 } }
```

The `action` field is either `"created"` or `"updated"`.

---

## Create Contact

`POST /contacts`

Create a new contact and add it to a list.

> **Caution: Create-only**
> `POST /contacts` never updates. If a contact with the same `work_email` or `linkedin_url` already exists it returns `409 Conflict` and writes nothing. To create-or-update in one call, use [Upsert Contact](#upsert-contact) (`PUT /contacts/assert`).

### Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `list_id` | integer | **Yes** | Target list to add the contact to |
| `first_name` | string | No | First name |
| `last_name` | string | No | Last name |
| `work_email` | string | No | Work email address |
| `personal_emails` | string | No | Personal email addresses |
| `direct_phone` | string | No | Direct phone number |
| `mobile_phone` | string | No | Mobile phone number |
| `job_title` | string | No | Job title |
| `job_department` | string | No | Department |
| `seniority_level` | string | No | Seniority level |
| `linkedin_url` | string | No | LinkedIn profile URL |
| `city` | string | No | City |
| `state` | string | No | State or province |
| `country` | string | No | Country |
| `company_domain` | string | No | Company domain for matching |

### Example

**cURL**

```bash
curl -X POST "https://be.graph8.com/api/v1/contacts" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "list_id": 5,
    "first_name": "John",
    "last_name": "Doe",
    "work_email": "john@techcorp.io",
    "job_title": "CTO",
    "company_domain": "techcorp.io"
  }'
```

**Python**

```python
response = requests.post(
    f"{BASE_URL}/contacts",
    headers=HEADERS,
    json={
        "list_id": 5,
        "first_name": "John",
        "last_name": "Doe",
        "work_email": "john@techcorp.io",
        "job_title": "CTO",
        "company_domain": "techcorp.io"
    }
)
result = response.json()["data"]
```

**TypeScript**

```typescript
const response = await fetch(`${BASE_URL}/contacts`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    list_id: 5,
    first_name: "John",
    last_name: "Doe",
    work_email: "john@techcorp.io",
    job_title: "CTO",
    company_domain: "techcorp.io"
  })
});
const { data: result } = await response.json();
```

### Response `201 Created`

```json
{
  "data": {
    "status": "ok",
    "count": 1,
    "validation_errors": []
  }
}
```

### Errors

| Status | When | What to do |
|--------|------|------------|
| `409 Conflict` | A contact with the same `work_email` / `linkedin_url` already exists. Nothing is written. | `detail.contact_id` is the existing contact. `detail.hint` and `detail.upsert` name `PUT /contacts/assert`; `detail.update` names `PATCH /contacts/{id}`. |

```json
{
  "type": "conflict",
  "code": "409",
  "message": "conflict",
  "detail": {
    "status": "duplicate",
    "contact_id": 42,
    "merged": false,
    "matched_by": "CONTACT_WORK_EMAIL",
    "field_diff": [],
    "validation_errors": [],
    "message": "A contact with work email already exists.",
    "hint": "A contact with this identity already exists (contact_id 42). POST /contacts only creates. To create-or-update idempotently, call PUT /api/v1/contacts/assert with the same fields plus list_id (SDK: g8.contacts.upsert()), or PATCH /api/v1/contacts/42 to update the existing contact.",
    "upsert": {
      "method": "PUT",
      "path": "/api/v1/contacts/assert",
      "operation_id": "assert_contact_contacts_assert_put",
      "requires": ["list_id", "work_email or linkedin_url"]
    },
    "update": { "method": "PATCH", "path": "/api/v1/contacts/42", "contact_id": 42 }
  },
  "request_id": "req_..."
}
```

---

## Update Contact

`PATCH /contacts/{contact_id}`

Update one or more fields on a contact. Only include the fields you want to change.

### Path Parameters

| Parameter | Type | Description |
|-----------|------|-------------|
| `contact_id` | integer | Contact ID |

### Request Body

All fields are optional. Include only the fields to update.

| Field | Type | Description |
|-------|------|-------------|
| `first_name` | string | First name |
| `last_name` | string | Last name |
| `work_email` | string | Work email address |
| `direct_phone` | string | Direct phone number |
| `mobile_phone` | string | Mobile phone number |
| `job_title` | string | Job title |
| `job_department` | string | Department |
| `seniority_level` | string | Seniority level |
| `linkedin_url` | string | LinkedIn profile URL |
| `city` | string | City |
| `state` | string | State or province |
| `country` | string | Country |

### Example

**cURL**

```bash
curl -X PATCH "https://be.graph8.com/api/v1/contacts/1" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"job_title": "CTO", "city": "New York"}'
```

**Python**

```python
response = requests.patch(
    f"{BASE_URL}/contacts/1",
    headers=HEADERS,
    json={"job_title": "CTO", "city": "New York"}
)
```

**TypeScript**

```typescript
const response = await fetch(`${BASE_URL}/contacts/1`, {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ job_title: "CTO", city: "New York" })
});
```

### Response

```json
{
  "data": {
    "updated": 1
  }
}
```

### Errors

| Status | Meaning |
|--------|---------|
| `400` | No fields provided to update |
| `404` | Contact not found |

---

## Delete Contact

`DELETE /contacts/{contact_id}`

Soft-delete a contact. The contact is marked as deleted but not permanently removed.

### Path Parameters

| Parameter | Type | Description |
|-----------|------|-------------|
| `contact_id` | integer | Contact ID |

### Example

**cURL**

```bash
curl -X DELETE "https://be.graph8.com/api/v1/contacts/1" \
  -H "Authorization: Bearer $API_KEY"
```

**Python**

```python
response = requests.delete(f"{BASE_URL}/contacts/1", headers=HEADERS)
```

**TypeScript**

```typescript
const response = await fetch(`${BASE_URL}/contacts/1`, {
  method: "DELETE",
  headers: { Authorization: `Bearer ${API_KEY}` }
});
```

### Response

```json
{
  "data": {
    "deleted": true
  }
}
```

### Errors

| Status | Meaning |
|--------|---------|
| `404` | Contact not found |

> **Caution:**
> Deleted contacts cannot be restored via the API. Contact support if you need to recover deleted data.

---

## Bulk Upsert Contacts

`PUT /contacts/assert/batch`

Bulk upsert of up to 100 contacts. Uses the same match keys as the single upsert (`work_email` then `linkedin_url`).

### Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `list_id` | integer | **Yes** | Target list for all contacts in this batch |
| `contacts` | array | **Yes** | List of contact objects (1–100 items). Each item follows the same field set as `PUT /contacts/assert`, with at least one of `work_email` or `linkedin_url` per row. |

### Example

**cURL**

```bash
curl -X PUT "https://be.graph8.com/api/v1/contacts/assert/batch" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "list_id": 12345,
    "contacts": [
      {
        "work_email": "alice@acme.com",
        "first_name": "Alice",
        "last_name": "Smith",
        "job_title": "CEO",
        "company_domain": "acme.com"
      },
      {
        "linkedin_url": "https://linkedin.com/in/bobdoe",
        "first_name": "Bob",
        "last_name": "Doe"
      }
    ]
  }'
```

### Response

```json
{ "data": { "total": 100, "created": 87, "updated": 13, "errors": [] } }
```

---

## List Contact Columns

`GET /contacts/columns`

List custom contact columns defined in your workspace.

### Query Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `list_id` | integer | — | Filter columns by list. Omit to return all (including global) columns. |

### Example

**cURL**

```bash
curl "https://be.graph8.com/api/v1/contacts/columns" \
  -H "Authorization: Bearer $API_KEY"

# Filter by list
curl "https://be.graph8.com/api/v1/contacts/columns?list_id=12345" \
  -H "Authorization: Bearer $API_KEY"
```

### Response

```json
{
  "data": [
    {
      "id": 1,
      "title": "Job Level",
      "name": "job_level",
      "data_type": "text",
      "is_global": true
    }
  ]
}
```

---

## Create Contact Column

`POST /contacts/columns/create`

Create a custom contact column. Optionally provision an enrichment waterfall pipeline in the same request so the column is immediately enrichable.

### Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | **Yes** | Display name for the column |
| `data_type` | string | **Yes** | Column data type — currently only `"text"` is supported |
| `list_id` | integer | No | Scope the column to a specific list. Omit (or `null`) for a global column. **Required when `enrichment` is provided** (waterfall pipelines are list-scoped). |
| `created_by` | string | No | Email of the user creating the column |
| `enrichment` | object | No | Enrichment waterfall config — strongly recommended for enrichable columns. When supplied, a companion waterfall pipeline is provisioned in the same request so the UI Enrich button and `POST /enrichment/waterfall/enrich` work out-of-the-box. Without it the column exists but is not enrichable until you call `POST /enrichment/waterfall/configs`. |

#### `enrichment` object

| Field | Type | Description |
|-------|------|-------------|
| `type` | string | Enrichment type — use `"waterfall"` |
| `providers` | array | Ordered list of provider slugs. Mutually exclusive with `steps`. Allowed values: `graph8`, `hunter`, `prospeo`, `dropcontact`, `icypeas`, `leadmagic`, `emaillistverify`, `apollo`, `lusha`, `rocketreach`. |
| `email_verification` | object | Optional email verification config (see below) |

#### `enrichment.email_verification` object

| Field | Type | Description |
|-------|------|-------------|
| `provider` | string | Verification provider (e.g. `"zerobounce"`) |
| `enabled` | boolean | Whether verification is active |
| `use_system_credentials` | boolean | Use the platform's credentials instead of your own |
| `accept_catchall` | boolean | Whether to accept catch-all addresses |
| `valid_statuses` | array | List of statuses to accept (e.g. `["valid"]`) |

### Example

**cURL**

```bash
# Basic column (no enrichment)
curl -X POST "https://be.graph8.com/api/v1/contacts/columns/create" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Job Level",
    "data_type": "text",
    "created_by": "user@graph8.com"
  }'

# Column with enrichment waterfall
curl -X POST "https://be.graph8.com/api/v1/contacts/columns/create" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Job Level",
    "data_type": "text",
    "list_id": 12345,
    "created_by": "user@graph8.com",
    "enrichment": {
      "type": "waterfall",
      "providers": ["hunter", "prospeo"],
      "email_verification": {
        "provider": "zerobounce",
        "enabled": true,
        "use_system_credentials": true,
        "accept_catchall": false,
        "valid_statuses": ["valid"]
      }
    }
  }'
```

### Response `201 Created`

**Without enrichment:**

```json
{
  "data": {
    "id": 1,
    "title": "Job Level",
    "data_type": "text",
    "name": "job_level",
    "object_id": "12345",
    "is_global": false,
    "enrichment": null
  }
}
```

**With enrichment:**

```json
{
  "data": {
    "id": 1,
    "title": "Job Level",
    "data_type": "text",
    "name": "job_level",
    "object_id": "12345",
    "is_global": false,
    "enrichment": {
      "column_id": "job_level",
      "config_id": "uuid",
      "name": "Job Level Enrichment",
      "type": "waterfall",
      "providers": ["hunter", "prospeo"],
      "email_verification": { "enabled": true, "provider": "zerobounce" }
    }
  }
}
```

> **Note:**
> - `list_id` is **required** when `enrichment` is set — waterfall pipelines are list-scoped.
> - `providers` and `steps` are mutually exclusive. Pass one, neither, or `email_verification` alone.
> - Unknown providers return `400` (`unsupported_provider`).
> - Without `enrichment`, the column exists but has no enrichment plan — `POST /enrichment/waterfall/enrich` will return `422` (`waterfall_config_missing`) until you call `POST /enrichment/waterfall/configs`.
> - Legacy payloads (title / data_type / list_id / created_by only) still work unchanged; `enrichment` in the response is `null`.
