# Contacts

A **contact** is a person: a property manager, an HOA board member, a facilities director, an accounts-payable clerk. Each contact can have:

- a **primary company** (`client_account_id`, see [Customers & sites](customers-and-sites.md));
- several **phone numbers and emails** (channels), one primary of each kind;
- **roles at other companies and sites** (links), with start and end dates so history survives a handoff;
- preferences (preferred channel, do-not-contact, birthday, tags) and the workspace's **custom fields** (see [Custom fields](custom-fields.md)).

A job has a list of **people on the job**. The primary person fills the job's own `contact_name`, `contact_email` and `contact_phone`. Extra people can be marked as **additional customers** who get their own proposal copy.

!!! note
    Partner keys that act for several workspaces must also send `firm_id` (query string, or JSON body on writes).

## Response format

Lists return `{"data": [...], "pagination": {"total", "limit", "offset"}}`. `limit` defaults to 50 (maximum 200). Single records return `{"data": {...}}`. Timestamps are UTC ISO 8601 with a `Z` (`2026-10-12T14:03:22Z`); dates are `YYYY-MM-DD`. Errors are `{"error": "...", "code": "..."}`; unknown body fields are a `400` that lists them in `fields`.

## The contact object

| Field | Type | Description |
|---|---|---|
| `id` | integer | TruTec id. |
| `name` | string or null | Full name. |
| `display_name` | string | Name, or company when there is no name. |
| `title` | string or null | Job title. |
| `contact_type` | string or null | The workspace's own category (Property Manager, Billing, ...). |
| `company` | string or null | Company name shown for the person. |
| `client_account_id` | integer or null | Primary company. |
| `client_account` | object or null | `{id, name}` of that company. |
| `email`, `phone` | string or null | The primary email and phone. |
| `notes` | string or null | The pinned note on the person. |
| `assigned_user_id` | integer or null | Salesperson for this person. `null` means the company's salesperson. |
| `preferred_channel` | string or null | `call`, `text` or `email`. |
| `do_not_contact`, `do_not_contact_reason` | boolean, string | Don't call, text or email this person. |
| `birthday` | string or null | `YYYY-MM-DD`. |
| `tags` | array of strings | Free tags. |
| `custom_fields` | object | Custom field values by key. |
| `archived_at`, `created_at`, `updated_at` | string or null | UTC timestamps. |

`GET /v1/contacts/{id}` and every write also return:

| Field | Description |
|---|---|
| `channels` | Every phone and email: `id`, `ref`, `kind` (`phone` / `email`), `label` (`mobile`, `work`, `office`, `home`, `fax`, `other`), `value`, `is_primary`, `do_not_contact`. |
| `links` | Companies and sites: `kind` (`company` / `location`), `id`, `name`, `roles`, `is_primary`, `current`, `started_on`, `ended_on`. |
| `jobs` | Jobs the person is on: `job_id`, `name`, `status`, `role`, `is_primary`, `bills_separately`. |

## Contacts

### List contacts

`GET /v1/contacts`

**Permission:** Read jobs

People A to Z. With `updated_after`, oldest change first, so you can page forward with the last row's `updated_at`.

| Name | Type | Required | Description |
|---|---|---|---|
| `q` | string | No | Name, email, phone, company or title contains this text. |
| `email` | string | No | Exact email, any case. Matches any of the person's emails, so use it to look someone up before creating them. |
| `client_account_id` | integer | No | People whose primary company this is. For everyone with a role there, use `GET /v1/client-accounts/{id}/contacts`. |
| `job_id` | integer | No | People on this job. |
| `contact_type` | string | No | Exact category. |
| `include_archived` | boolean | No | Include archived people. |
| `updated_after` | string | No | ISO 8601. |
| `limit`, `offset` | integer | No | Paging. |

```bash
curl "https://trutec.ai/api/v1/contacts?email=jamie@mapleridge.example" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "data": [
    {
      "id": 20417,
      "name": "Jamie Rivera",
      "display_name": "Jamie Rivera",
      "title": "Community manager",
      "contact_type": "Property Manager",
      "company": "Maple Ridge HOA",
      "client_account_id": 4812,
      "client_account": {"id": 4812, "name": "Maple Ridge HOA"},
      "email": "jamie@mapleridge.example",
      "phone": "(512) 555-0142",
      "notes": null,
      "assigned_user_id": null,
      "preferred_channel": "text",
      "do_not_contact": false,
      "do_not_contact_reason": null,
      "birthday": null,
      "tags": ["board"],
      "custom_fields": {},
      "archived_at": null,
      "created_at": "2026-09-14T15:30:02Z",
      "updated_at": "2026-10-12T14:03:22Z"
    }
  ],
  "pagination": {"total": 1, "limit": 50, "offset": 0}
}
```

**Errors:** `403` `API key lacks job read permission`; `400` for a bad `updated_after`.

### Get a contact

`GET /v1/contacts/{id}`

**Permission:** Read jobs

One person (archived or not) with `channels`, `links` and `jobs`.

```json
{
  "data": {
    "id": 20417,
    "name": "Jamie Rivera",
    "company": "Maple Ridge HOA",
    "client_account_id": 4812,
    "email": "jamie@mapleridge.example",
    "phone": "(512) 555-0142",
    "channels": [
      {"id": 5521, "ref": "5521", "kind": "phone", "label": "mobile", "value": "(512) 555-0142", "is_primary": true, "do_not_contact": false},
      {"id": 5522, "ref": "5522", "kind": "email", "label": "work", "value": "jamie@mapleridge.example", "is_primary": true, "do_not_contact": false}
    ],
    "links": [
      {"kind": "company", "id": 4812, "name": "Maple Ridge HOA", "roles": ["property_manager"], "is_primary": true, "current": true, "started_on": null, "ended_on": null},
      {"kind": "location", "id": 9307, "name": "Clubhouse lot", "roles": ["site_contact"], "is_primary": false, "current": true, "started_on": "2025-03-01", "ended_on": null}
    ],
    "jobs": [
      {"job_id": 41872, "name": "Maple Ridge HOA reseal", "status": "lead", "role": "billing", "is_primary": true, "bills_separately": false}
    ]
  }
}
```

The example is shortened; every field of the contact object is present.

**Errors:** `404` `Contact not found` (also for another workspace's contact).

### Create a contact

`POST /v1/contacts`

**Permission:** Write jobs

Send at least one of `name`, `company`, `email` or `phone`.

| Name | Type | Required | Description |
|---|---|---|---|
| `name` | string | No | Max 200 characters. |
| `title` | string | No | Job title. |
| `contact_type` | string | No | Category. Max 80 characters. |
| `company` | string | No | Company name. Linked to the customer with that name, or a new customer is created, as in the app. |
| `client_account_id` | integer | No | Primary company by id. Wins over `company`. |
| `email`, `phone` | string | No | Primary email and phone. More can be added with **Add a phone or email**. |
| `notes` | string | No | The pinned note. |
| `assigned_user_id` | integer | No | Salesperson for this person (an office user). |
| `preferred_channel` | string | No | `call`, `text` or `email`. |
| `do_not_contact` | boolean | No | Don't reach out. Add `do_not_contact_reason`. |
| `birthday` | string | No | `YYYY-MM-DD`. |
| `tags` | array of strings | No | Free tags. |
| `custom_fields` | object | No | Values by field key. |

Contacts have no `external_ref`. To avoid duplicates, look the person up first with `GET /v1/contacts?email=`.

```bash
curl -X POST "https://trutec.ai/api/v1/contacts" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Jamie Rivera",
    "email": "jamie@mapleridge.example",
    "phone": "(512) 555-0142",
    "client_account_id": 4812,
    "title": "Community manager",
    "preferred_channel": "text"
  }'
```

Returns `201` with `{"data": {contact with channels, links and jobs}}`.

**Errors:**

- `400` `Add at least a name, company, email, or phone number.`, `email does not look like an email address`, `Pick call, text or email.`, an unknown or invalid custom field, or unknown body fields
- `404` `client_account_id not found in this workspace`, `assigned_user_id does not match a user in this workspace`

### Update a contact

`PATCH /v1/contacts/{id}`

**Permission:** Write jobs

Takes the same fields as create; only the ones you send change. `client_account_id: null` removes the primary company. When the name, email, phone or company changes, every job the person is on picks up the new details, the same as saving the contact in the app.

```bash
curl -X PATCH "https://trutec.ai/api/v1/contacts/20417" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"phone": "(512) 555-0100", "tags": ["board", "billing"]}'
```

Returns `{"data": {contact}}`. Same errors as create.

### Archive or delete a contact

`DELETE /v1/contacts/{id}`

**Permission:** Write jobs and Delete records

Archives the person (`204`). They drop out of lists but keep their history; `POST /v1/contacts/{id}/restore` brings them back.

Add `?hard=true` to delete permanently. Jobs keep their own contact fields. The person's own notes and files are deleted; notes that are also on their company's feed stay there.

**Errors:** `403` `API key lacks delete permission`; `403` with `"deletion_locked": true` when the workspace has turned off deleting records; `404`.

### Restore a contact

`POST /v1/contacts/{id}/restore`

**Permission:** Write jobs

Un-archives the person. Returns `{"data": {contact}}`.

## Phones and emails

The primary phone and email of each person are also their `phone` and `email` fields. Editing those fields moves the primary entry; these endpoints manage the full list.

Each entry has a `ref`. For a person whose phones and emails have never been edited, entries are read from the `phone` / `email` text and have `id: null` and a ref like `v-phone-0`; the first edit saves them all and gives them ids.

### Add a phone or email

`POST /v1/contacts/{id}/channels`

**Permission:** Write jobs

| Name | Type | Required | Description |
|---|---|---|---|
| `kind` | string | Yes | `phone` or `email`. |
| `value` | string | Yes | The number or address. Phone numbers are formatted for you. |
| `label` | string | No | `mobile`, `work`, `office`, `home`, `fax` or `other`. |
| `is_primary` | boolean | No | Make it the primary of its kind. The first of each kind is primary. |

```bash
curl -X POST "https://trutec.ai/api/v1/contacts/20417/channels" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"kind": "phone", "value": "512-555-0177", "label": "mobile"}'
```

```json
{"data": {"id": 5530, "ref": "5530", "kind": "phone", "label": "mobile", "value": "(512) 555-0177", "is_primary": false, "do_not_contact": false}}
```

**Errors:** `400` `kind must be phone or email`, `That email doesn’t look right.`, `Type the whole phone number.`; `409` `That one is already on file.`

### Update a phone or email

`PATCH /v1/contacts/{id}/channels/{ref}`

**Permission:** Write jobs

Send any of `value`, `label`, `is_primary` (true makes it primary) and `do_not_contact`. Returns `{"data": {channel}}`.

### Remove a phone or email

`DELETE /v1/contacts/{id}/channels/{ref}`

**Permission:** Write jobs

Removes one entry (`204`). If it was primary, the next one of that kind becomes primary. This is an edit of the contact, so it needs only Write jobs.

## Roles at companies and sites

### Set a role at a company or site

`POST /v1/contacts/{id}/links`

**Permission:** Write jobs

Records that the person works with a company or a site. Sending the same company or site again replaces the roles.

| Name | Type | Required | Description |
|---|---|---|---|
| `client_account_id` | integer | One of | The company. |
| `location_id` | integer | One of | The site. |
| `roles` | array | No | Any of `decision_maker`, `property_manager`, `site_contact`, `billing`, `accounts_payable`, `board_member`, `other`. Empty means "works with them". |
| `started_on` | string | No | `YYYY-MM-DD`. |
| `is_primary` | boolean | No | Make this their primary company (or main site). A person with no primary company gets the first company linked. |

```bash
curl -X POST "https://trutec.ai/api/v1/contacts/20417/links" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"location_id": 9307, "roles": ["site_contact"]}'
```

Returns `201` with `{"data": [links]}`: all of the person's links.

**Errors:** `400` `Send exactly one of client_account_id or location_id`, unknown roles; `404` for a company or site outside the workspace.

### End or remove a role

`DELETE /v1/contacts/{id}/links/{kind}/{target_id}`

**Permission:** Write jobs

`kind` is `company` or `location`. With `?end=true` the link is kept as history ("no longer works there", ended today or on `ended_on=YYYY-MM-DD`); without it the link is removed. Ending or removing their primary company picks another current company, or none. Returns `204`.

## People on a job

The people on a job, with their role on it, `is_primary` and `bills_separately`.

### List the people on a job

`GET /v1/jobs/{job_id}/contacts`

**Permission:** Read jobs

Primary first, then by role.

```json
{
  "data": [
    {
      "contact_id": 20417,
      "job_id": 41872,
      "role": "billing",
      "is_primary": true,
      "bills_separately": false,
      "created_at": "2026-10-12T14:03:22Z",
      "contact": {"id": 20417, "name": "Jamie Rivera", "display_name": "Jamie Rivera", "title": "Community manager", "contact_type": "Property Manager", "email": "jamie@mapleridge.example", "phone": "(512) 555-0142", "client_account_id": 4812, "archived_at": null, "updated_at": "2026-10-12T14:03:22Z"}
    }
  ]
}
```

### Add a person to a job

`POST /v1/jobs/{job_id}/contacts`

**Permission:** Write jobs

Link a saved person with `contact_id`, or create one by sending `name` / `email` / `phone` / `company` (and optionally `title`, `contact_type`). The first person on a job becomes primary and fills the job's contact fields; the job is linked to the person's company if it has none.

| Name | Type | Required | Description |
|---|---|---|---|
| `contact_id` | integer | One of | A saved, non-archived person. |
| `name`, `email`, `phone`, `company`, `title`, `contact_type` | string | One of | A new person (a name or an email at least). |
| `role` | string | No | `billing`, `onsite`, `decision_maker`, `property_manager`, `estimator`, `operations`, or your own text. |
| `is_primary` | boolean | No | Make them the job's primary contact. |
| `bills_separately` | boolean | No | Add them as an **additional customer**: the job is bid to them too and each gets a proposal copy. The job must already have a main customer. |

```bash
curl -X POST "https://trutec.ai/api/v1/jobs/41872/contacts" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"contact_id": 20417, "role": "billing"}'
```

Returns `201` with `{"data": {person on the job}}`, or `200` when the person was already on the job.

**Errors:** `400` `Archived contacts cannot be linked to jobs. Restore it first.`, `Add the job’s main customer first, then add more customers.`; `404` for a job or contact outside the workspace.

### Change a person's role on a job

`PATCH /v1/jobs/{job_id}/contacts/{contact_id}`

**Permission:** Write jobs

Send `role`, `is_primary: true` (makes them primary; the job's contact fields follow), or `bills_separately` (turn "additional customer" on or off; the job's activity log records it). Returns `{"data": {person on the job}}`.

### Remove a person from a job

`DELETE /v1/jobs/{job_id}/contacts/{contact_id}`

**Permission:** Write jobs

Unlinks the person; the contact itself stays. If they were primary, the next person becomes primary and fills the job's contact fields. Returns `204`.

## Notes and history

A person's notes feed and their full history are in [Notes & tasks](notes-and-tasks.md): `GET /v1/contacts/{id}/notes`, `POST /v1/contacts/{id}/notes` and `GET /v1/contacts/{id}/timeline`.
