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

# Managing Contacts

> Look up, create, update, and delete contacts through the public API in batches of up to 100 records.

## Endpoints

```bash theme={null}
GET    /v1/contacts
GET    /v1/contacts/{contact_id}
PATCH  /v1/contacts/{contact_id}
DELETE /v1/contacts/{contact_id}
GET    /v1/contacts/{contact_id}/activities
POST   /v1/contacts
POST   /v1/contacts/upsert
POST   /v1/contacts/update
POST   /v1/contacts/delete
DELETE /v1/contacts
POST   /v1/contacts/search
```

Use these endpoints to sync CRM contacts from external systems. Batch write requests accept an `items` array with up to `100` records. For bulk file-based ingestion, see [Importing Contacts](/api-reference/importing-contacts); for field definitions, see [Managing Contact Fields](/api-reference/managing-contact-fields).

<Info>
  Contact writes support partial success. Tented processes each item independently and returns accepted or rejected results in request order.
</Info>

## Look Up Contacts

```bash theme={null}
GET /v1/contacts
```

`GET /v1/contacts` has two modes. With an `email` or `phone` query parameter it is an identifier lookup returning zero or one contact (shown here). Without an identifier it becomes a paginated listing — see [List Contacts](#list-contacts).

### Query Parameters

| Parameter | Type     | Required      | Notes                                                                   |
| --------- | -------- | ------------- | ----------------------------------------------------------------------- |
| `email`   | `string` | Conditionally | Either `email` or `phone` is required. Matched on the normalized value. |
| `phone`   | `string` | Conditionally | Either `email` or `phone` is required. Matched on the normalized value. |

## Lookup Request Example

```bash theme={null}
curl --request GET \
  --url 'https://api.tented.ai/v1/contacts?email=jane%40example.com' \
  --header 'Authorization: Bearer tented_your_api_key'
```

## Lookup Response Example

Returns `items` with the matching contact, or an empty array when no contact matches. `custom_fields` is keyed by each custom field's API name.

```json theme={null}
{
  "items": [
    {
      "contact_id": "11111111-1111-4111-8111-111111111111",
      "display_name": "Jane Doe",
      "first_name": "Jane",
      "last_name": "Doe",
      "email": "jane@example.com",
      "phone": null,
      "company": "Acme",
      "job_title": "CEO",
      "original_source": "Public API",
      "lifecycle_stage": "subscriber",
      "unsubscribed": false,
      "custom_fields": {
        "favorite_color": "Green"
      },
      "created_at": "2026-04-30T12:00:00.000Z",
      "updated_at": "2026-04-30T12:00:00.000Z"
    }
  ]
}
```

## Get a Contact

```bash theme={null}
GET /v1/contacts/{contact_id}
```

Fetch a single contact by its ID — the `contact_id` returned by create, update, and lookup responses.

### Path Parameters

| Parameter    | Type   | Required | Notes                                                       |
| ------------ | ------ | -------- | ----------------------------------------------------------- |
| `contact_id` | `uuid` | Yes      | Returns `404` when no contact matches, `400` when malformed |

## Get Request Example

```bash theme={null}
curl --request GET \
  --url 'https://api.tented.ai/v1/contacts/11111111-1111-4111-8111-111111111111' \
  --header 'Authorization: Bearer tented_your_api_key'
```

## Get Response Example

Returns the contact object directly, including `custom_fields` keyed by each custom field's API name — the same shape as a lookup item.

```json theme={null}
{
  "contact_id": "11111111-1111-4111-8111-111111111111",
  "display_name": "Jane Doe",
  "first_name": "Jane",
  "last_name": "Doe",
  "email": "jane@example.com",
  "phone": null,
  "company": "Acme",
  "job_title": "CEO",
  "original_source": "Public API",
  "lifecycle_stage": "subscriber",
  "unsubscribed": false,
  "custom_fields": {
    "favorite_color": "Green"
  },
  "created_at": "2026-04-30T12:00:00.000Z",
  "updated_at": "2026-04-30T12:00:00.000Z"
}
```

## List Contacts

```bash theme={null}
GET /v1/contacts?page=1&limit=100&updated_after=2026-07-01T00:00:00Z
```

Without an `email`/`phone` identifier, `GET /v1/contacts` pages through the workspace's contacts.

| Parameter       | Type      | Required | Notes                                                                                                                                                    |
| --------------- | --------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `page`          | `integer` | No       | 1-based page number (default `1`)                                                                                                                        |
| `limit`         | `integer` | No       | Page size, max `100` (default `25`)                                                                                                                      |
| `sort`          | `string`  | No       | `created_at` (default) \| `updated_at` \| `last_activity_at` \| `display_name` \| `first_name` \| `last_name` \| `normalized_email` \| `original_source` |
| `order`         | `string`  | No       | `asc` \| `desc` (default)                                                                                                                                |
| `search`        | `string`  | No       | Matches name, email, phone, and company                                                                                                                  |
| `updated_after` | `string`  | No       | ISO 8601 timestamp — only contacts updated after it. The building block for incremental syncs                                                            |

Returns `{items, pagination}`. List items do not include `custom_fields` — fetch a single contact for those.

## Update a Single Contact

```bash theme={null}
PATCH /v1/contacts/{contact_id}
```

Single-record alternative to the batch update: the request body takes the same fields as an update item (minus `contact_id`), returns the updated contact directly, `404` when missing, and `409` with `existing_contact` when an email/phone change collides with another contact.

## Delete a Single Contact

```bash theme={null}
DELETE /v1/contacts/{contact_id}
```

Returns `204` on success, `404` when the contact does not exist.

## Contact Activities

```bash theme={null}
GET /v1/contacts/{contact_id}/activities?page=1&limit=25&order=desc
```

Read-only, paginated activity timeline: `contact_created`, `contact_updated`, email engagement events (sent, delivered, opened, clicked, bounced, unsubscribed), and flow entry/exit — each with a `type`, `timestamp`, and event-specific `metadata`.

## Create Contacts

```bash theme={null}
POST /v1/contacts
```

### Request Body

| Field   | Type    | Required | Notes                          |
| ------- | ------- | -------- | ------------------------------ |
| `items` | `array` | Yes      | Between `1` and `100` contacts |

### Create Item Fields

| Field                        | Type              | Required      | Notes                                           |
| ---------------------------- | ----------------- | ------------- | ----------------------------------------------- |
| `email`                      | `string`          | Conditionally | Either `email` or `phone` is required           |
| `phone`                      | `string`          | Conditionally | Either `email` or `phone` is required           |
| `client_item_id`             | `string`          | No            | Your own per-item identifier for reconciliation |
| `display_name`               | `string`          | No            | Maximum `255` characters                        |
| `first_name`                 | `string`          | No            | Maximum `255` characters                        |
| `last_name`                  | `string`          | No            | Maximum `255` characters                        |
| `company`                    | `string`          | No            | Maximum `255` characters                        |
| `job_title`                  | `string`          | No            | Maximum `255` characters                        |
| `address`                    | `string`          | No            | Maximum `500` characters                        |
| `address2`                   | `string`          | No            | Maximum `255` characters                        |
| `city`                       | `string`          | No            | Maximum `255` characters                        |
| `state`                      | `string`          | No            | Maximum `255` characters                        |
| `country`                    | `string`          | No            | Maximum `255` characters                        |
| `zip_code`                   | `string`          | No            | Maximum `50` characters                         |
| `lead_status`                | `string`          | No            | Maximum `100` characters                        |
| `lifecycle_stage`            | `string`          | No            | Maximum `100` characters                        |
| `tented_score`               | `number \| null`  | No            | Optional contact score                          |
| `unsubscribed`               | `boolean`         | No            | Defaults to `false` when omitted                |
| `marketing_email_subscribed` | `boolean \| null` | No            | Email subscription state                        |
| `marketing_sms_subscribed`   | `boolean \| null` | No            | SMS subscription state                          |

## Create Request Example

```bash theme={null}
curl --request POST \
  --url https://api.tented.ai/v1/contacts \
  --header "Authorization: Bearer $TENTED_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "items": [
      {
        "client_item_id": "crm-row-001",
        "email": "jane@example.com",
        "first_name": "Jane",
        "last_name": "Doe",
        "company": "Acme",
        "job_title": "CEO"
      },
      {
        "client_item_id": "crm-row-002",
        "phone": "+15551234567",
        "display_name": "Sam Rivera"
      }
    ]
  }'
```

## Create Response Example

`201 Created`

```json theme={null}
{
  "status": "completed",
  "created_count": 2,
  "rejected_count": 0,
  "items": [
    {
      "index": 0,
      "client_item_id": "crm-row-001",
      "contact_id": "11111111-1111-4111-8111-111111111111",
      "status": "created",
      "contact": {
        "contact_id": "11111111-1111-4111-8111-111111111111",
        "display_name": "Jane Doe",
        "first_name": "Jane",
        "last_name": "Doe",
        "email": "jane@example.com",
        "phone": null,
        "company": "Acme",
        "job_title": "CEO",
        "original_source": "Public API",
        "created_at": "2026-04-30T12:00:00.000Z",
        "updated_at": "2026-04-30T12:00:00.000Z"
      }
    },
    {
      "index": 1,
      "client_item_id": "crm-row-002",
      "contact_id": "22222222-2222-4222-8222-222222222222",
      "status": "created",
      "contact": {
        "contact_id": "22222222-2222-4222-8222-222222222222",
        "display_name": "Sam Rivera",
        "email": null,
        "phone": "+15551234567",
        "original_source": "Public API",
        "created_at": "2026-04-30T12:00:00.000Z",
        "updated_at": "2026-04-30T12:00:00.000Z"
      }
    }
  ]
}
```

## Duplicate Contacts

Tented checks normalized email and phone values before creating a contact. If an item matches an existing contact, that item is rejected with `error_code: "conflict"`. If you want matches to be updated instead of rejected, use [Upsert Contacts](#upsert-contacts).

```json theme={null}
{
  "index": 0,
  "client_item_id": "crm-row-001",
  "status": "rejected",
  "error_code": "conflict",
  "message": "Contact already exists",
  "existing_contact": {
    "contact_id": "11111111-1111-4111-8111-111111111111",
    "display_name": "Jane Doe",
    "email": "jane@example.com",
    "phone": null
  }
}
```

## Upsert Contacts

```bash theme={null}
POST /v1/contacts/upsert
```

Create-or-update in one call — no conflict handling required. Each item (up to `100`) is matched by its **normalized email** (or phone, for phone-only items): a match updates that contact, no match creates one. Items accept the full update field set including `custom_fields`, and each result reports `status: "created"` or `"updated"`.

<Info>
  Matching is identifier-scoped: an item is only ever matched on its own identifier. If a *new* email's item carries a phone that belongs to a different contact, the item is rejected with `error_code: "conflict"` and the `existing_contact` — it never silently modifies the phone-matched contact.
</Info>

```bash theme={null}
curl --request POST \
  --url 'https://api.tented.ai/v1/contacts/upsert' \
  --header 'Authorization: Bearer tented_your_api_key' \
  --header 'Content-Type: application/json' \
  --data '{
    "items": [
      {
        "email": "jane@example.com",
        "first_name": "Jane",
        "company": "Acme",
        "custom_fields": {"account_tier": "enterprise"}
      }
    ]
  }'
```

Response mirrors the batch format with `created_count`, `updated_count`, and `rejected_count`.

## Search Contacts

```bash theme={null}
POST /v1/contacts/search
```

Filter contacts with a rule tree — the same engine that powers dynamic lists and blast audiences.

| Field            | Type      | Required | Notes                                                                                                                      |
| ---------------- | --------- | -------- | -------------------------------------------------------------------------------------------------------------------------- |
| `rules`          | `object`  | Yes      | AND/OR rule group over standard fields, custom fields, activity, and list membership (max depth `5`, max `100` conditions) |
| `page` / `limit` | `integer` | No       | Pagination, `limit` ≤ `100`                                                                                                |
| `search`         | `string`  | No       | Free-text filter applied alongside the rules                                                                               |
| `sort` / `order` | `string`  | No       | Same sort keys as [List Contacts](#list-contacts)                                                                          |

Discover the available fields and operators with [`GET /v1/contact-fields/rule-metadata`](/api-reference/managing-contact-fields).

## Update Contacts

```bash theme={null}
POST /v1/contacts/update
```

### Request Body

| Field   | Type    | Required | Notes                                |
| ------- | ------- | -------- | ------------------------------------ |
| `items` | `array` | Yes      | Between `1` and `100` update records |

### Update Item Fields

| Field                        | Type              | Required | Notes                                                    |
| ---------------------------- | ----------------- | -------- | -------------------------------------------------------- |
| `contact_id`                 | `uuid`            | Yes      | Contact to update                                        |
| `client_item_id`             | `string`          | No       | Your own per-item identifier for reconciliation          |
| `email`                      | `string \| null`  | No       | Set to `null` to clear, as long as phone remains present |
| `phone`                      | `string \| null`  | No       | Set to `null` to clear, as long as email remains present |
| `display_name`               | `string \| null`  | No       | Maximum `255` characters                                 |
| `first_name`                 | `string`          | No       | Maximum `255` characters                                 |
| `last_name`                  | `string`          | No       | Maximum `255` characters                                 |
| `email_domain`               | `string \| null`  | No       | Usually derived automatically from email                 |
| `company`                    | `string`          | No       | Maximum `255` characters                                 |
| `job_title`                  | `string`          | No       | Maximum `255` characters                                 |
| `address`                    | `string`          | No       | Maximum `500` characters                                 |
| `address2`                   | `string`          | No       | Maximum `255` characters                                 |
| `city`                       | `string`          | No       | Maximum `255` characters                                 |
| `state`                      | `string`          | No       | Maximum `255` characters                                 |
| `country`                    | `string`          | No       | Maximum `255` characters                                 |
| `zip_code`                   | `string`          | No       | Maximum `50` characters                                  |
| `original_source`            | `string \| null`  | No       | Maximum `255` characters                                 |
| `original_source_detail`     | `string \| null`  | No       | Maximum `255` characters                                 |
| `lead_status`                | `string`          | No       | Maximum `100` characters                                 |
| `lifecycle_stage`            | `string`          | No       | Maximum `100` characters                                 |
| `tented_score`               | `number \| null`  | No       | Optional contact score                                   |
| `unsubscribed`               | `boolean`         | No       | Unsubscribe flag                                         |
| `marketing_email_subscribed` | `boolean \| null` | No       | Email subscription state                                 |
| `marketing_sms_subscribed`   | `boolean \| null` | No       | SMS subscription state                                   |
| `marketing_sms_invalid`      | `boolean`         | No       | SMS validity flag                                        |
| `custom_fields`              | `object`          | No       | Editable custom fields by API name                       |

<Warning>
  A contact must always have at least one valid email or phone. An update that clears both identity fields is rejected for that item.
</Warning>

<Note>
  `marketing_email_invalid` is system-managed and read-only: Tented sets it to `true` when a marketing email to the contact hard-bounces, and resets it to `false` when the contact's email address changes. It appears in contact responses and audience rules, but requests that include it are rejected.
</Note>

## Update Request Example

```bash theme={null}
curl --request POST \
  --url https://api.tented.ai/v1/contacts/update \
  --header "Authorization: Bearer $TENTED_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "items": [
      {
        "client_item_id": "crm-row-001",
        "contact_id": "11111111-1111-4111-8111-111111111111",
        "company": "Acme Enterprise",
        "lead_status": "qualified",
        "custom_fields": {
          "favorite_color": "Green"
        }
      }
    ]
  }'
```

## Update Response Example

`200 OK`

```json theme={null}
{
  "status": "completed",
  "updated_count": 1,
  "rejected_count": 0,
  "items": [
    {
      "index": 0,
      "client_item_id": "crm-row-001",
      "contact_id": "11111111-1111-4111-8111-111111111111",
      "status": "updated",
      "contact": {
        "contact_id": "11111111-1111-4111-8111-111111111111",
        "email": "jane@example.com",
        "company": "Acme Enterprise",
        "lead_status": "qualified",
        "updated_at": "2026-04-30T12:05:00.000Z"
      }
    }
  ]
}
```

## Delete Contacts

```bash theme={null}
POST /v1/contacts/delete
DELETE /v1/contacts
```

Use `POST /v1/contacts/delete` if your HTTP client does not support request bodies on `DELETE`.

### Request Body

| Field   | Type    | Required | Notes                                |
| ------- | ------- | -------- | ------------------------------------ |
| `items` | `array` | Yes      | Between `1` and `100` delete records |

### Delete Item Fields

| Field            | Type     | Required | Notes                                           |
| ---------------- | -------- | -------- | ----------------------------------------------- |
| `contact_id`     | `uuid`   | Yes      | Contact to delete                               |
| `client_item_id` | `string` | No       | Your own per-item identifier for reconciliation |

## Delete Request Example

```bash theme={null}
curl --request POST \
  --url https://api.tented.ai/v1/contacts/delete \
  --header "Authorization: Bearer $TENTED_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "items": [
      {
        "client_item_id": "crm-row-001",
        "contact_id": "11111111-1111-4111-8111-111111111111"
      }
    ]
  }'
```

## Delete Response Example

`200 OK`

```json theme={null}
{
  "status": "completed",
  "deleted_count": 1,
  "rejected_count": 0,
  "items": [
    {
      "index": 0,
      "client_item_id": "crm-row-001",
      "contact_id": "11111111-1111-4111-8111-111111111111",
      "status": "deleted"
    }
  ]
}
```

## Batch Response Format

Every contacts write endpoint returns:

| Field            | Type     | Notes                                                   |
| ---------------- | -------- | ------------------------------------------------------- |
| `status`         | `string` | Always `completed` after the request has been processed |
| `created_count`  | `number` | Present on create responses                             |
| `updated_count`  | `number` | Present on update responses                             |
| `deleted_count`  | `number` | Present on delete responses                             |
| `rejected_count` | `number` | Number of rejected items                                |
| `items`          | `array`  | Per-item results in request order                       |

Rejected items include:

| Field              | Type     | Notes                                              |
| ------------------ | -------- | -------------------------------------------------- |
| `index`            | `number` | Zero-based item index from the request             |
| `client_item_id`   | `string` | Returned when supplied                             |
| `contact_id`       | `uuid`   | Returned when supplied                             |
| `status`           | `string` | `rejected`                                         |
| `error_code`       | `string` | `bad_request`, `not_found`, `conflict`, or `error` |
| `message`          | `string` | Human-readable failure reason                      |
| `existing_contact` | `object` | Returned for duplicate conflicts                   |

## Common Item-Level Rejections

| Error Code    | Cause                                                                    |
| ------------- | ------------------------------------------------------------------------ |
| `bad_request` | No fields to update                                                      |
| `bad_request` | Update would remove both email and phone                                 |
| `bad_request` | Custom field is missing, inactive, non-editable, or has an invalid value |
| `not_found`   | Contact does not exist in the workspace                                  |
| `conflict`    | Email or phone matches another existing contact                          |

## Common Request Errors

| Status             | Cause                                                          |
| ------------------ | -------------------------------------------------------------- |
| `400 Bad Request`  | Invalid JSON body                                              |
| `400 Bad Request`  | Request body is missing                                        |
| `400 Bad Request`  | `items` is missing, empty, or contains more than `100` records |
| `400 Bad Request`  | A create item has neither `email` nor `phone`                  |
| `400 Bad Request`  | Invalid email, phone, UUID, or field format                    |
| `401 Unauthorized` | Missing or invalid bearer token                                |

<Card title="Back to API Overview" icon="arrow-left" href="/api-reference/introduction">
  Review the full Tented API endpoint map.
</Card>
