Skip to main content
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.

Base URL and access

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.
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.
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.

The Person object

Every read endpoint (search, lookup, get) returns Person records in this shape:
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.
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: custom_fields entries look like: 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:

Search people

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.
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, 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.
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:
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

Fetches exactly one Person by exactly one identifier. Provide email, linkedin_url, or phone — never more than one, never zero. If you provide zero identifiers, or more than one, this returns:
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:
On a match, the response is a single Person object — same shape as one entry in the search results array — with 200.

Get a person by ID

Fetches one Person by its numeric id. Returns a 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

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. Request:
Response, 200:
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.
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.
If any id in person_ids doesn’t correspond to a Person in your workspace, nothing is changed and you get:

Split a person

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.
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 — e.g. GET /api/public/contacts?email=priya.nair@acme.io — to get the Contact’s id, then pass that id here.
Request:
Response, 200:
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:
If person_id doesn’t exist for your workspace, or any id in contact_ids isn’t currently linked to person_id, you get:

Error summary

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