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

# People

> Look up, search, merge, and split identity-resolved People records built from your Contacts.

The People API sits on top of your Contacts data and adds an identity layer. Each Contact record carries **exactly one** identifier — an email, a LinkedIn URL, or a phone number, never more than one — and every Contact belongs to a Person. A Person has no fields of its own beyond an id and timestamps; it exists purely to group together every Contact that represents the same real individual.

That means one Person can have several Contact rows attached to it — one for their work email, one for their LinkedIn profile, one for a phone number picked up later — and the People API lets you read and manage that group as a single record instead of reconciling separate Contacts yourself.

This is a read-heavy API with two write operations (merge and split) for correcting identity resolution. It is not a general contact CRUD API — to create, update, or delete the underlying Contact records themselves, use the [Contacts API](/api-reference/contacts/list-contacts).

## Base URL and access

```
https://api.revyops.com/api/public/people
```

All five endpoints require an `X-API-KEY` header, same as the rest of the public API. Your workspace is enabled for the People API when your account has the **Clay integration** turned on — this is the same integration that gates the API key panel (labeled "RevyOps API") under a client's **Integrations** page in the dashboard. If it isn't enabled, every People endpoint returns `403 Forbidden`, regardless of whether your API key is otherwise valid.

<Warning>
  Search, lookup, and get work with either your client's **Write/Read** or **Read-Only** API key. Merge and split require the **Write/Read** key specifically — calling either write endpoint with the Read-Only key returns `403 Forbidden`. This is not a separate key you need to request; it's the same "Write/Read API key" shown in the Integrations panel, just enforced more strictly on the two mutating endpoints.
</Warning>

There is no v1/v2 split for People — there's a single version, and unlike the v1 Contacts/Companies endpoints, an empty search **always** returns the standard paginated envelope with `results: []`, never the `{"status": "No contacts found"}` shape described in the [API introduction](/api-reference/introduction).

## The Person object

Every read endpoint (search, lookup, get) returns Person records in this shape:

| Field           | Type           | Description                                                                                           |
| --------------- | -------------- | ----------------------------------------------------------------------------------------------------- |
| `id`            | integer        | The Person's id. Use this for `GET /people/{person_id}` and as an entry in `person_ids` when merging. |
| `first_name`    | string \| null | Taken from one of the linked Contacts.                                                                |
| `last_name`     | string \| null | Taken from one of the linked Contacts.                                                                |
| `emails`        | array          | One entry per linked Contact whose identifier is an email.                                            |
| `linkedin_urls` | array          | One entry per linked Contact whose identifier is a LinkedIn URL.                                      |
| `phones`        | array          | One entry per linked Contact whose identifier is a phone number.                                      |
| `custom_fields` | array          | Deduplicated custom fields collected across every linked Contact.                                     |

<Note>
  `first_name` and `last_name` come from whichever linked Contact the database happens to return first — there's no explicit ordering, so if two Contacts on the same Person disagree on name, which one wins isn't guaranteed. In practice this rarely matters, since most People have one canonical name across all their Contacts.
</Note>

Each entry in `emails`, `linkedin_urls`, and `phones` shares the same shape — the `value` field holds the email address, LinkedIn URL, or phone number respectively:

| Field                       | Type                      | Description                                                                                                            |
| --------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `value`                     | string                    | The email, LinkedIn URL, or phone number itself.                                                                       |
| `company_id`                | integer \| null           | The company this Contact is linked to, if any.                                                                         |
| `job_title`                 | string \| null            | The Contact's `role` field.                                                                                            |
| `status`                    | string \| null            | The Contact's current campaign/lifecycle status.                                                                       |
| `previous_status`           | string \| null            | The status before the last change.                                                                                     |
| `first_campaign_email_sent` | string (ISO 8601) \| null |                                                                                                                        |
| `last_campaign_email_sent`  | string (ISO 8601) \| null |                                                                                                                        |
| `last_reply_received`       | string (ISO 8601) \| null |                                                                                                                        |
| `origin`                    | string \| null            | Where this Contact came from (e.g. an integration name, or `"RevyOps"` for one created directly via the Contacts API). |

`custom_fields` entries look like:

| Field          | Type              | Description                           |
| -------------- | ----------------- | ------------------------------------- |
| `id`           | integer           | The underlying custom field row's id. |
| `updated_time` | string (ISO 8601) |                                       |
| `field_name`   | string            |                                       |
| `field_value`  | string            |                                       |

Deduplication only collapses a `(field_name, field_value)` pair that repeats identically across multiple linked Contacts. If two Contacts on the same Person hold different values for the same field name, both values show up as separate `custom_fields` entries — the API doesn't try to pick a winner.

Here's a full example:

```json theme={null}
{
  "id": 48213,
  "first_name": "Priya",
  "last_name": "Nair",
  "emails": [
    {
      "value": "priya.nair@acme.io",
      "company_id": 9021,
      "job_title": "VP Sales",
      "status": "active",
      "previous_status": "new",
      "first_campaign_email_sent": "2026-04-02T14:11:00Z",
      "last_campaign_email_sent": "2026-06-18T09:00:00Z",
      "last_reply_received": "2026-06-19T11:42:00Z",
      "origin": "RevyOps"
    }
  ],
  "linkedin_urls": [
    {
      "value": "linkedin.com/in/priyanair",
      "company_id": 9021,
      "job_title": "VP Sales",
      "status": null,
      "previous_status": null,
      "first_campaign_email_sent": null,
      "last_campaign_email_sent": null,
      "last_reply_received": null,
      "origin": "lemlist"
    }
  ],
  "phones": [],
  "custom_fields": [
    {
      "id": 771029,
      "updated_time": "2026-06-01T08:30:00Z",
      "field_name": "seniority",
      "field_value": "VP"
    }
  ]
}
```

## Search people

```http theme={null}
GET /api/public/people
X-API-KEY: YOUR_API_KEY
```

Searches People records for the authenticated workspace, paginated. Filters match at the **Person level**: if any Contact linked to a Person matches a filter, the whole Person (with all its Contacts) is included in the results.

| Query parameter | Type    | Description                                                                                                                                       |
| --------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `email`         | string  | Match a Contact's email. Lowercased and trimmed before matching, so it's effectively case-insensitive.                                            |
| `linkedin_url`  | string  | Match a Contact's LinkedIn URL. Normalized before matching (see [Lookup a person](#lookup-a-person)).                                             |
| `phone`         | string  | Match a Contact's phone number. Normalized to E.164 before matching — see note below.                                                             |
| `firstname`     | string  | Note: no underscore.                                                                                                                              |
| `lastname`      | string  | Note: no underscore.                                                                                                                              |
| `domain`        | string  | Match the domain of the company any linked Contact belongs to. Contacts without a company never match.                                            |
| `page`          | integer | 1-based. Defaults to 1. An out-of-range page returns `results: []` with `current_page` pinned to the last valid page, not the page you requested. |
| `page_size`     | integer | Defaults to 100, capped at 100 — values above 100 are silently capped, not rejected.                                                              |

<Warning>
  If `phone` is supplied but can't be parsed into a valid E.164 number, the search doesn't error — it silently returns zero results (`results: []`, `count: 0`). Same if `email` (or `linkedin_url`) is the *only* identifier filter supplied and it's an empty string. This is different from [lookup](#lookup-a-person), where an identifier that fails to parse returns `404`, not an error about the request itself — `400` there is reserved for supplying the wrong *number* of identifiers.
</Warning>

Combining filters narrows the result set (they're ANDed) — e.g. `firstname=Priya&domain=acme.io` returns only People with at least one Contact named Priya *and* at least one Contact at acme.io (not necessarily the same Contact).

A search response looks like this:

```json theme={null}
{
  "count": 214,
  "total_pages": 3,
  "current_page": 1,
  "page_size": 100,
  "results": [
    { "id": 48213, "first_name": "Priya", "last_name": "Nair", "emails": [ /* ... */ ], "linkedin_urls": [], "phones": [], "custom_fields": [] }
  ]
}
```

`count` is the number of matching **Person groupings**, not the number of underlying Contacts. `results` can occasionally come back shorter than a page implies — the endpoint silently drops any Person id it can't fully resolve when assembling the page, rather than erroring.

## Lookup a person

```http theme={null}
GET /api/public/people/lookup
X-API-KEY: YOUR_API_KEY
```

Fetches exactly one Person by exactly one identifier. Provide `email`, `linkedin_url`, or `phone` — never more than one, never zero.

| Query parameter | Type   | Description                                                                                                                         |
| --------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `email`         | string | Lowercased and trimmed before matching.                                                                                             |
| `linkedin_url`  | string | Normalized to `linkedin.com/in/<slug>` form (protocol, `www.`, locale subdomain, and trailing path/query stripped) before matching. |
| `phone`         | string | Parsed and normalized to E.164 before matching.                                                                                     |

If you provide zero identifiers, or more than one, this returns:

```json theme={null}
{
  "identifier": "Provide exactly one of 'email', 'linkedin_url', or 'phone'."
}
```

If you provide exactly one but it doesn't resolve to a Contact — including a `phone` value that can't be parsed at all — this returns `404`:

```json theme={null}
{
  "detail": "No people record found."
}
```

On a match, the response is a single [Person object](#the-person-object) — same shape as one entry in the search `results` array — with `200`.

## Get a person by ID

```http theme={null}
GET /api/public/people/{person_id}
X-API-KEY: YOUR_API_KEY
```

Fetches one Person by its numeric id.

| Path parameter | Type    | Description |
| -------------- | ------- | ----------- |
| `person_id`    | integer |             |

Returns a [Person object](#the-person-object) on `200`. Returns `404` if no Person with that id exists for your workspace, and also `404` — with the same `{"detail": "No people record found."}` body — in the edge case where the Person row exists but currently has zero linked Contacts (for example, if every Contact under it was individually deleted through the Contacts API).

## Merge people

```http theme={null}
POST /api/public/people/merge
X-API-KEY: YOUR_API_KEY
```

Merges two or more Person records into one, reassigning every Contact from the merged-away records onto a single primary record. Requires the **Write/Read** API key.

| Field        | Type              | Required | Description                                                                                                                                                         |
| ------------ | ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `person_ids` | array of integers | yes      | 2 or more unique Person ids. **The first id in the array becomes the primary** — every other Person's Contacts are reassigned to it.                                |
| `first_name` | string            | no       | Accepted by the request schema but currently has **no effect** — the merge endpoint does not apply it to the primary's Contacts. Don't rely on it to rename anyone. |
| `last_name`  | string            | no       | Same as above — accepted, silently ignored.                                                                                                                         |

Request:

```json theme={null}
{
  "person_ids": [48213, 51890, 52011]
}
```

Response, `200`:

```json theme={null}
{
  "primary_person_id": 48213,
  "merged_person_ids": [51890, 52011],
  "relinked_contacts": 4
}
```

What actually happens, in order, inside one atomic transaction with row-level locks on the affected Persons and Contacts:

1. Every Contact belonging to `person_ids[1:]` (the "secondary" people) is reassigned to `person_ids[0]` (the primary).
2. The secondary Person rows are deleted outright.
3. The primary is queued for an async rescore, since it may have inherited campaign activity its cached score doesn't reflect yet.

<Warning>
  **Merge rewrites custom fields on every Contact involved, including the primary's own.** For every custom field name across all Contacts being merged, one value is chosen — first by the order Person ids appear in `person_ids`, then by ascending Contact id — and that single value is written onto **every** Contact in the merge, overwriting whatever value each Contact already had for that field name. `bison_id` and any field name ending in `_status` (case-insensitive) are excluded from this and left untouched. There's no per-contact opt-out, and this cannot be undone by a later split — split only moves Contacts to a new Person id, it does not restore overwritten field values or recreate the deleted secondary Person ids.
</Warning>

If any id in `person_ids` doesn't correspond to a Person in your workspace, nothing is changed and you get:

```json theme={null}
{
  "error": "One or more people records were not found for this client."
}
```

## Split a person

```http theme={null}
POST /api/public/people/split
X-API-KEY: YOUR_API_KEY
```

Moves a specific subset of a Person's Contacts onto a brand-new Person record, leaving the rest behind on the original. Requires the **Write/Read** API key.

| Field         | Type              | Required | Description                                                                                                       |
| ------------- | ----------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
| `person_id`   | integer           | yes      | The Person to split Contacts off of.                                                                              |
| `contact_ids` | array of integers | yes      | 1 or more unique Contact ids currently linked to `person_id`. Must leave at least one Contact behind — see below. |

<Note>
  The Person object doesn't expose Contact ids anywhere in its `emails`/`linkedin_urls`/`phones` entries, so you can't get a `contact_id` to split from a People API response alone. Look it up first via the [Contacts API](/api-reference/contacts/get-contact) — e.g. `GET /api/public/contacts?email=priya.nair@acme.io` — to get the Contact's `id`, then pass that id here.
</Note>

Request:

```json theme={null}
{
  "person_id": 48213,
  "contact_ids": [90142]
}
```

Response, `200`:

```json theme={null}
{
  "source_person_id": 48213,
  "new_person_id": 52190,
  "moved_contact_ids": [90142],
  "moved_contacts": 1
}
```

A brand-new Person record is created (`new_person_id`), and every Contact id listed in `contact_ids` is reassigned from `source_person_id` to it. Nothing else about those Contacts changes.

If `contact_ids` includes every Contact currently on `person_id` — i.e. the split would leave the source Person with zero Contacts — the request is rejected and nothing changes:

```json theme={null}
{
  "contact_ids": ["Split must leave at least one contact on the source person."]
}
```

If `person_id` doesn't exist for your workspace, or any id in `contact_ids` isn't currently linked to `person_id`, you get:

```json theme={null}
{
  "error": "The source people record or one or more contacts were not found for this client."
}
```

## Error summary

| Code  | When                                                                                                                                                                                                                                      |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `200` | Success on any endpoint.                                                                                                                                                                                                                  |
| `400` | Lookup called with zero or 2+ identifiers; split would leave the source Person with zero contacts; other request-body validation failures (e.g. `person_ids` with fewer than 2 entries, duplicate ids).                                   |
| `403` | Missing/invalid `X-API-KEY`; Clay integration not enabled for your workspace; Read-Only key used against merge or split.                                                                                                                  |
| `404` | `GET /people/{person_id}` for an id that doesn't exist (or exists with zero contacts); lookup with a valid-but-unmatched or unparseable identifier; merge with an unknown `person_id`; split with an unknown `person_id` or `contact_id`. |
| `500` | Unexpected server error.                                                                                                                                                                                                                  |

Note that search never 404s — an unmatched search just returns `count: 0` with an empty `results` array.
