Skip to content
Pathbound DOCS

Contacts REST API

Contacts represent people in your Pathbound workspace. Each contact has a unified profile, an event timeline, conversation history, and a list of notes. Contacts are associated with a company and have a chronological event timeline.

EndpointScopeWhat it does
POST /v1/contactscontacts:writeCreate one contact.
POST /v1/contacts/bulkcontacts:writeCreate up to 1,000 contacts in one request.
GET /v1/contactscontacts:readList, filter, search and count contacts, or check which emails you already have.
GET /v1/contacts/:contact_idcontacts:readOne contact with its unified profile and recent events.
PUT /v1/contacts/:contact_idcontacts:writeChange properties.
DELETE /v1/contacts/:contact_idcontacts:writeDelete the contact.
GET /v1/contacts/events/:contact_idevents:readThe contact’s event timeline.
GET /v1/contacts/:contact_id/conversationscontacts:readEmail and chat threads, and meetings.
GET /v1/contacts/:contact_id/meetings/:meeting_id/transcriptcontacts:readA recorded meeting’s transcript.
GET /v1/contacts/:contact_id/email-threadscontacts:readA compact list of the contact’s Gmail threads.
GET, POST /v1/contacts/:contact_id/notescontacts:read, contacts:writeList the contact’s notes, add one.
PATCH, DELETE /v1/contacts/:contact_id/notes/:note_idcontacts:writeChange or delete one note.

Related endpoints on other pages: tags on a contact, sending a contact an email, enriching contacts, and identifying a logged-in user from your server.

POST /v1/contacts

Required scope: contacts:write

Without external_id, this creates a contact and an email that already exists is a 409. With external_id, your own id for the person, it is an upsert on that id, so the same request sent twice leaves one contact, unchanged by the second:

  1. A contact that has this external_id is updated.
  2. Else a contact that has no external id yet and holds the email takes your id and is updated. If that contact is already keyed to another id, the answer is 409 with code: "EXTERNAL_ID_CONFLICT" and its contact_id, and nothing changes.
  3. Else the contact is created.

An update merges what you send: a property you send replaces the stored value, and one you leave out keeps it. An email that another contact already holds is not written, and skipped_traits says so ("email": "email_belongs_to_contact:<contact_id>"); the other fields are.

FieldTypeRequiredDescription
emailstringwithout external_idContact email address (must be unique). Optional with external_id.
external_idstringnoYour own id for the person, up to 100 characters, stored as external_contact_id. The same id identify takes. Placeholder values ("undefined", "null", …) are a 400.
propertiesobjectnoArbitrary key-value properties for the contact. lifecyclestage, if set, must be one of the lifecycle stages.
visitor_idstring | string[]noWebsite visitor ID(s) from the events snippet to associate. A visitor already tied to another contact is left with it.
company_idstringnoAssociate the contact with this company. An id that is not a company in your workspace is a 404.
companyobjectnoThe company by your own key instead of its Pathbound id: { "external_id": "…" } (the id you gave it on Create a Company) or { "domain": "…" }. It only looks a company up: one that finds nothing is a 422 with code: "COMPANY_NOT_FOUND", and nothing is written. Not with company_id.

A visitor is tied to a contact only through visitor_id: a sb_visitor_ids key in properties is ignored, here and on Update a Contact.

With external_id, a company you name replaces the contact’s company, and the company it had is removed from associations.companies: a user who moves to another account is no longer listed under the old one. Without a company, a contact on a corporate email domain is linked to the company that holds that domain, if there is one and the contact has no company yet.

201 when the contact was created. A contact updated through its external_id is answered 200 OK in the same shape, with "created": false.

{
"status": "success",
"message": "Contact created successfully",
"contact_id": "665f1c2ab3d4e5f601234567",
"created": true,
"contact": {
"contact_id": "665f1c2ab3d4e5f601234567",
"_id": "665f1c2ab3d4e5f601234567",
"properties": {
"email": "[email protected]",
"firstname": "Ada",
"lastname": "Lovelace"
},
"associations": { "companies": ["665f1c2ab3d4e5f6012345aa"] },
"first_seen_at": "2026-10-12T10:00:00.000Z",
"segment_ids": [],
"creation_source": "manual",
"created_at": "2026-10-12T10:00:00.000Z",
"updated_at": "2026-10-12T10:00:00.000Z",
"notes": [],
"tags": []
},
"timestamp": "2026-10-12T10:00:00.000Z"
}

_id is a transitional alias for contact_id and will be removed in a future release. Prefer contact_id in new integrations.

Without external_id, returns 409 Conflict if a contact with the same email already exists. The response carries that contact’s id as contact_id, so you can update it instead.

StatuscodeMeaning
400INVALID_EXTERNAL_IDexternal_id is empty, longer than 100 characters, or a placeholder value.
400INVALID_LINKcompany is not an object with exactly one of external_id and domain, or is sent beside company_id.
409EXTERNAL_ID_CONFLICTWith external_id: the email belongs to a contact keyed to another id. contact_id is that contact.
422COMPANY_NOT_FOUNDThe company key finds no company in your workspace.

POST /v1/contacts/bulk

Required scope: contacts:write

Creates up to 1,000 contacts in one request. It is safe to send the same list twice: a contact whose email already exists is reported as existing and is not changed. An item with an external_id is an upsert on it, as on Create a Contact, and comes back created or updated. One bad item never fails the others.

FieldTypeRequiredDescription
contactsobject[]yes1 to 1,000 items.
contacts[].emailstringwithout external_idThe contact’s email.
contacts[].external_idstringnoAs in Create a Contact.
contacts[].propertiesobjectnoAs in Create a Contact.
contacts[].company_idstringnoA company to associate the contact with.
contacts[].companyobjectnoThe company by your own key, as in Create a Contact.
contacts[].tagsstring[]noTags to put on the contact. Applied to contacts the request creates or updates, not to an existing one.
Terminal window
curl -X POST https://api.pathbound.ai/v1/contacts/bulk \
-H "Authorization: Bearer $PATHBOUND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"contacts": [
{ "email": "[email protected]", "properties": { "firstname": "Ada" }, "tags": ["webinar-oct"] },
{ "email": "[email protected]", "properties": { "firstname": "Grace", "lifecyclestage": "lead" } },
{ "email": "not-an-email" }
]
}'
{
"status": "success",
"requested": 3,
"created": 1,
"updated": 0,
"existing": 1,
"errors": 1,
"results": [
{ "index": 0, "email": "[email protected]", "status": "existing", "id": "665f1c2ab3d4e5f601234567" },
{ "index": 1, "email": "[email protected]", "status": "created", "id": "665f1c2ab3d4e5f601234568" },
{ "index": 2, "email": "not-an-email", "status": "error", "error": "Valid email is required" }
],
"timestamp": "2026-10-12T10:00:00.000Z"
}

results has one entry per item you sent, in the same order, and index is the item’s position in your array. An entry’s status is created, updated (an item with an external_id that found its contact), existing (an item without one whose email exists) or error, with the code of Create a Contact when it has one, and skipped_traits when a field was not written. The request is a 200 even when some items failed, so read errors and the entries with "status": "error". A 429 with code: "QUOTA_EXCEEDED" means the new contacts in the batch would take the workspace past its limit; an item that updates a contact through its external_id does not count toward it.

Visitor ids are not bound by this endpoint. To tie a visitor to a contact, use Create a Contact, Update a Contact or POST /v1/identify.


GET /v1/contacts

Required scope: contacts:read

ParameterTypeDefaultDescription
pagenumber1Page number.
limitnumber10Results per page (1–100).
fieldsstringfullids (the id only), summary (the id and key properties), full, or a comma-separated list of field names. See How much of each record comes back. Internal fields (tenant_id, user_id, __v, properties_provenance) cannot be requested.
count_onlybooleanfalseIf true, return only { count } and no records.
dry_runbooleanfalseIf true, return what reading this page would add to your plan’s count, and no records. See Usage and metering.
searchstring—Free-text search across name, email, company, job title.
sort_bystringlastmodifieddateemail, firstname, lastname, company, jobtitle, createdate, lastmodifieddate, or an indexed source field written source.field.
sort_dirstringdescSort direction (asc or desc).
statusstring—Filter: enriched or not_enriched.
lifecycle_stagestring—Filter by lifecycle stage (lead, prospect, mql, sql, opportunity, customer, evangelist, subscriber, other). Whole value, case-insensitive.
lead_statusstring—Filter by lead status (whole value, case-insensitive).
countrystring—Filter by country (whole value, case-insensitive).
industrystring—Filter by industry (whole value, case-insensitive).
citystring—Filter by city (whole value, case-insensitive).
statestring—Filter by state/region (whole value, case-insensitive).
job_titlestring—Filter by job title (case-insensitive match).
has_linkedinboolean—Filter contacts with/without a LinkedIn profile URL.
company_idstring—Filter by associated company ID (24 hex characters; any other value is a 400).
external_idstring—Your own id for the person, the one identify stores as external_contact_id. Exact match, so zero or one contact. One id of up to 255 characters; a repeated parameter is a 400.
created_afterstring—Only contacts created after this ISO 8601 date.
created_beforestring—Only contacts created before this ISO 8601 date.
tags_anystring—Comma-separated tags, up to 50. Contacts that carry at least one of them.
tags_allstring—Comma-separated tags, up to 50. Contacts that carry every one of them. When both are sent, tags_all is used.
data_sourcesstring—Comma-separated sources. Contacts that have data from at least one of them: hubspot, intercom, apollo, pipedrive, zoho, resend, zendesk, gmail, tldv, calendly, emailable, postgres (a connected database). An unknown name is a 400.
segmentstring—The id or handle of a saved segment. Only contacts in it.
segments_anystring—Comma-separated segment ids or handles. Contacts in at least one of them.
filtersstring—Typed filters as a JSON array [{"field","op","value"}], up to 10, on properties or source fields (for example emailable.score, hubspot.hs_lead_status). GET /v1/schema?entity=contacts lists the fields available to your workspace.
exclude_internalbooleanfalseLeave out your own team and test contacts, as defined in your workspace’s settings.
email_verification_statestring—Deliverability filter: a comma-separated union of deliverable, undeliverable, risky, unknown, unverified (no verdict on file). deliverable gives a safe-to-email list; undeliverable,risky a suppression list.
email_verified_afterstring—Only contacts whose email verdict was recorded at or after this ISO 8601 date.
email_verified_beforestring—Only contacts whose email verdict was recorded at or before this ISO 8601 date (stale verdicts). Neither date bound can be combined with unverified (no verdict on file, so no verdict date); the request returns 400.
has_event_typestring—Only contacts who have a tracked event of this type (e.g. button_click).
has_event_textstring—Only contacts who have an event with this exact element text/label (data.text), e.g. Go!.
has_event_text_containsstring—Only contacts who have an event whose element text/label contains this substring.
has_event_url_containsstring—Only contacts who have an event whose URL contains this substring.
has_event_class_containsstring—Only contacts who interacted with an element whose CSS class (data.class) contains this substring. This is the way to target elements without text or an id (icon buttons). Use a stable class token.
has_event_element_idstring—Only contacts who have an event on the DOM element with this id (data.id).
has_event_afterstring—Only contacts whose matching event occurred at or after this ISO 8601 date.
has_event_beforestring—Only contacts whose matching event occurred at or before this ISO 8601 date.
include_source_fieldsbooleanfalseWith fields=summary: add source_fields, a selection of each connected source’s own values for the contact (for example hubspot.lifecyclestage). Personal fields are left out.
includestring—Comma-separated list of optional fields to include. Currently supports provenance to add the per-field source/timestamp record (properties_provenance). Default omits provenance for response-size hygiene.
emailsstring—Switches the endpoint to checking which emails exist. No other parameter is applied, though each one sent is still validated.

The has_event_* filters return the contacts who performed a matching tracked event. For example ?has_event_type=button_click&has_event_text=Go! lists the known people who clicked the Go! button. They compose with every other filter (&lifecycle_stage=lead), with count_only, and with pagination/sorting. Notes:

  • Identified contacts only. Events are matched by their linked contact, so anonymous visitors never appear. To count distinct visitors (identified + anonymous), use POST /v1/aggregate with group_by=visitor_id.
  • Scope: using any has_event_* filter requires events:read in addition to contacts:read.
  • If an event filter matches an unusually large number of contacts, the request returns 400. Narrow it with has_event_after / has_event_before.

Every listed contact carries email_verification_state, the deliverability verdict on file (deliverable, undeliverable, risky, unknown, or null when nobody has verified the address). It is the verdict whoever reached it: Emailable, or a data provider such as Hunter that verified the address it found. emailable: { state, score } | null adds Emailable’s own detail and is null for an address Emailable did not check. The email_verification_state query parameter filters on the verdict server-side, so ?email_verification_state=deliverable&count_only=true answers “how many contacts are safe to email” without paging any records. The finer verdict fields (score, reason, role, free, disposable, accept_all, smtp_provider) are available through filters as emailable.<field> once Emailable is connected.

{
"status": "success",
"contacts": [
{
"contact_id": "665f1c2ab3d4e5f601234567",
"_id": "665f1c2ab3d4e5f601234567",
"properties": { "email": "[email protected]", "firstname": "Ada", "lastname": "Lovelace" },
"associations": { "companies": ["665f1c2ab3d4e5f6012345aa"] },
"first_seen_at": "2026-09-15T10:00:00.000Z",
"created_at": "2026-09-15T10:00:00.000Z",
"updated_at": "2026-10-12T10:00:00.000Z",
"tags": ["webinar-oct"],
"emailable": { "state": "deliverable", "score": 97 }
}
],
"pagination": {
"page": 1,
"limit": 10,
"total_pages": 10,
"total_items": 100
},
"timestamp": "2026-10-12T10:00:00.000Z"
}

_id is a transitional alias for contact_id and will be removed in a future release.

With fields=summary each row is flat:

{
"contact_id": "665f1c2ab3d4e5f601234567",
"_id": "665f1c2ab3d4e5f601234567",
"email": "[email protected]",
"firstname": "Ada",
"lastname": "Lovelace",
"company": "Analytical Engines",
"lifecyclestage": "customer",
"last_activity_at": "2026-10-12T10:00:00.000Z",
"emailable": { "state": "deliverable", "score": 97 },
"email_verification_state": "deliverable",
"tags": ["webinar-oct"]
}

With fields=ids each row is { "contact_id", "_id" } and the page does not count against your plan. A page in any other shape counts every contact on it as read. To walk a large list cheaply, filter with fields=ids and read what you need through the batch API.

For a “how many” question, prefer count_only=true.

Pass emails, a comma-separated list of up to 50 addresses, to learn which of them are already contacts. This is the cheap way to avoid creating duplicates before an import. It is free, and it matches the whole address exactly after normalizing it, unlike search.

Terminal window
curl -G https://api.pathbound.ai/v1/contacts \
-H "Authorization: Bearer $PATHBOUND_API_KEY" \
{
"status": "success",
"mode": "exists",
"requested": 2,
"found": 1,
"results": [
{ "email": "[email protected]", "exists": true, "id": "665f1c2ab3d4e5f601234567" },
{ "email": "[email protected]", "exists": false, "id": null }
],
"timestamp": "2026-10-12T10:00:00.000Z"
}

Addresses past the 50th are not checked, and the response then carries "truncated": true.


GET /v1/contacts/:contact_id

Required scope: contacts:read

Returns the contact along with its unified profile and (optionally) recent events.

ParameterTypeDefaultDescription
include_eventsbooleantrueIf false, omit the events array and total_events.
events_limitnumber20Max events to include (1–50). Ignored when include_events=false.
includestring—Comma-separated list of optional fields to include. Supports provenance (per-field provenance on the base contact record) and raw (adds unified_profile.raw_data, the un-deduplicated per-source field dumps; large, omitted by default).
dry_runbooleanfalseIf true, return whether reading this contact would add to your plan’s count, and no record.
{
"status": "success",
"contact": {
"contact_id": "665f1c2ab3d4e5f601234567",
"_id": "665f1c2ab3d4e5f601234567",
"properties": { "email": "[email protected]", "firstname": "Ada", "lastname": "Lovelace" },
"associations": { "companies": ["665f1c2ab3d4e5f6012345aa"] },
"_summary": "Ada Lovelace | CTO | at Analytical Engines | London | Stage: customer",
"created_at": "2026-09-15T10:00:00.000Z",
"updated_at": "2026-10-12T12:00:00.000Z"
},
"unified_profile": {
"_id": "665f1c2ab3d4e5f601234567",
"source_count": 3,
"sources": {
"internal": { "id": "665f1c2ab3d4e5f601234567", "updated_at": "...", "first_seen_at": "..." },
"hubspot": { "id": "12345", "synced_at": "...", "first_seen_at": "..." },
"apollo": { "id": "ap_678", "enriched_at": "..." }
},
"profile": {
"email": {
"unique_values": [
{ "value": "[email protected]", "sources": [{ "source": "hubspot", "updated_at": "..." }, { "source": "apollo", "updated_at": "..." }] }
],
"all_sources": ["hubspot", "apollo"],
"has_conflict": false
},
"jobtitle": {
"unique_values": [
{ "value": "CTO", "sources": [{ "source": "hubspot", "updated_at": "..." }] },
{ "value": "Chief Technology Officer", "sources": [{ "source": "apollo", "updated_at": "..." }] }
],
"all_sources": ["hubspot", "apollo"],
"has_conflict": true
}
},
"created_at": "...",
"updated_at": "...",
"enriched_at": "..."
},
"events": [
{ "event_id": "665f1c2ab3d4e5f6012345e1", "_id": "665f1c2ab3d4e5f6012345e1", "event": "page_view", "timestamp": "...", "url": "...", "title": "..." }
],
"total_events": 42,
"timestamp": "2026-10-12T12:00:00.000Z"
}

_summary is a human-readable one-liner the backend stitches together from the contact’s name, title, company, industry, location, and lifecycle stage. Parts are joined with | and prefixed where applicable (at <company>, Stage: <stage>). Convenient for list views, but not a stable contract, so don’t parse it.

unified_profile is the full deduplicated cross-source profile. Each profile field is a multi-source object: unique_values holds every distinct value found across sources (deduplicated), each annotated with the sources that supplied it; all_sources lists every source that had any value for the field; and has_conflict is true when sources disagree (more than one distinct value). sources is a per-source map (presence, external IDs, and sync/enrichment timestamps), and source_count is how many sources contributed. Pass ?include=raw to additionally get raw_data, the verbatim, un-deduplicated per-source field dumps. events is a compact projection of the contact’s most recent events and is omitted (along with total_events) when the request is made with ?include_events=false.

When PII redaction is enabled for an AI-agent (MCP) caller, the personal values inside unified_profile.profile are masked (the field and its source attribution remain visible) and raw_data is never returned, even with ?include=raw. An API key is never redacted.


PUT /v1/contacts/:contact_id

Required scope: contacts:write

FieldTypeRequiredDescription
propertiesobjectyesProperties to update or add. They are merged into the contact: a property you do not send keeps its value. Send {} to change only the fields below.
visitor_idstring | string[]noVisitor ID(s) to associate.
company_idstringnoA company to associate the contact with. An id that is not a company in your workspace is a 404.

To change a contact’s lifecycle stage, set properties.lifecyclestage to one of the allowed values.

The response is the updated contact, in the shape of Create a Contact.

lead, prospect, mql, sql, opportunity, customer, evangelist, subscriber, other.


DELETE /v1/contacts/:contact_id

Required scope: contacts:write

Permanently removes the contact and its notes. The contact’s events are kept. This cannot be undone.

An AI agent connected through the MCP server cannot call this endpoint: it is answered 403 with the code AGENT_DELETE_FORBIDDEN. An API key can.


GET /v1/contacts/events/:contact_id

Required scope: events:read

Returns the events associated with a contact, newest first by default. For aggregate event queries (across many contacts), use the Events API directly.

ParameterTypeDefaultDescription
limitnumber50Events to return (1–200).
offsetnumber0Events to skip. Page with offset and limit.
event_typestring—Filter by event type.
date_afterstring—Only events at or after this ISO 8601 date.
date_beforestring—Only events at or before this ISO 8601 date.
sort_dirstringdescSort direction (asc or desc).
fieldsstring—summary for a compact projection (event_id, _id, event, timestamp, url, contact_id); omit for full.
count_onlybooleanfalseIf true, return only { count }: how many of the contact’s events match. Free.
{
"status": "success",
"events": [
{
"event_id": "665f1c2ab3d4e5f6012345e1",
"_id": "665f1c2ab3d4e5f6012345e1",
"event": "page_view",
"timestamp": "2026-10-12T11:23:45.000Z",
"url": "https://example.com/pricing",
"domain": "example.com",
"data": { "title": "Pricing — Example", "path": "/pricing" }
}
],
"contact_id": "665f1c2ab3d4e5f601234567",
"total_items": 212,
"returned": 50,
"timestamp": "2026-10-12T12:00:00.000Z"
}

total_items is how many events match in all, and returned how many are in this response. Ask for the next page with offset raised by limit until you have total_items.


GET /v1/contacts/:contact_id/conversations

Required scope: contacts:read

Returns the contact’s conversation history, one block per channel: email (Gmail/SMTP), intercom, hubspot (logged 1:1 emails), instantly (cold-email messages), and meetings (tl;dv recordings and Calendly bookings). Each block has its own conversations[] and pagination. Blocks for integrations that aren’t connected come back empty.

The same history across everyone at a company is GET /v1/companies/:company_id/conversations.

ParameterTypeDefaultDescription
sourcestringallOne channel: email, intercom, hubspot, instantly, meetings, or all.
include_messagesbooleanfalseInclude message bodies (email/HubSpot/Instantly truncated to 5,000 characters per message, Intercom parts to 2,000; the last 20 messages per email thread). For meetings, adds tl;dv’s AI notes or Calendly’s booking-form answers. Never the transcript.
pagenumber1Page number. Each channel pages independently.
limitnumber20Threads per channel per page (1–50).
sort_dirstringdescasc or desc, by last-message date (meetings: by meeting date).
date_afterstring—ISO 8601. Only threads with activity (or meetings held) after this date.
date_beforestring—ISO 8601. Only threads with activity (or meetings held) before this date.

With include_messages=true, an Intercom conversation’s parts start with its opening message (part_type: "source", dated with the conversation’s created_at), followed by the conversation parts in order.

A tl;dv meeting in the meetings block reports has_transcript, transcript_segment_count, and has_notes. Read the transcript itself with Get Meeting Transcript. To read notes from many meetings at once, use POST /v1/meetings/query.


GET /v1/contacts/:contact_id/meetings/:meeting_id/transcript

Required scope: contacts:read

Returns one page of a tl;dv recording’s transcript. meeting_id is the _id of a meeting in the meetings block of the conversations endpoint, and contact_id must be a contact who was on the call. Any other combination returns 404. Calendly bookings have no transcript.

Pages are cut by size, not by segment count. To read the whole transcript, pass each response’s next_offset back as offset until next_offset is null.

ParameterTypeDefaultDescription
offsetnumber0Segment index to start from.
max_charsnumber8000Page size in characters (1,000–50,000).
{
"status": "success",
"contact_id": "665f1c2ab3d4e5f601234567",
"meeting": {
"_id": "6650f1c2e4b0a1b2c3d4e5f6",
"name": "QBR — Acme",
"happened_at": "2026-09-22T15:00:00.000Z",
"duration_seconds": 2280
},
"transcript": {
"segments": [
{ "speaker": "Dana Lee", "start_time": 0, "text": "Thanks for making time today." },
{ "speaker": "Sam Ortiz", "start_time": 4, "text": "Happy to. Let's start with the security review." }
],
"offset": 0,
"returned": 2,
"total_segments": 412,
"next_offset": 2
}
}

start_time is seconds from the start of the recording. A meeting with no transcript returns an empty page with unavailable_reason: not_available (tl;dv hasn’t produced one yet) or access_denied (tl;dv refused this connection access to the recording).


GET /v1/contacts/:contact_id/email-threads

Required scope: contacts:read

Returns a compact list of the contact’s Gmail threads, in threads. count is the number of threads in this response, at most limit: it is not the contact’s total, and no total is returned. Each entry names the thread and its last message, which is what you need to reply inside a thread.

ParameterTypeDefaultDescription
limitnumber10Threads to return (1–50).

Each contact has a list of free-form notes. Notes are written directly (no approval flow) and are visible to anyone with contacts:read.

GET /v1/contacts/:contact_id/notes

Required scope: contacts:read

POST /v1/contacts/:contact_id/notes

Required scope: contacts:write

FieldTypeRequiredDescription
contentstringyesNote body (markdown), up to 10,000 characters.
PATCH /v1/contacts/:contact_id/notes/:note_id

Required scope: contacts:write

Takes the same body as Create a Note and replaces the note’s text.

DELETE /v1/contacts/:contact_id/notes/:note_id

Required scope: contacts:write