Who a workspace already knows how to reach.

```ts
const { contact } = await connect.contacts.lookupByPhone({ workspaceId, phone });
if (contact) {
  // A caller this workspace has met before.
}
```

**This is the workspace's identity for a person, not a CRM record.** A contact holds a phone, an email, or both - never neither - and is found by phone first, then by email (`lookupByEmail` is the email twin of the read above). Most are written automatically: a voice agent creates one on the first call from a new number and fills fields in as it learns them. A contact known only by email has `primaryPhone: null` and cannot be called; the first phone it gains becomes its `primaryPhone`, which never changes after that. `source` tells you which kind you are looking at, and it is worth rendering: `call` means an agent guessed the details and nobody has confirmed them, `manual` means a person typed them.

**Reference it, do not copy it.** If your application keeps its own customer records - a CRM contact, a patient, a member - store this contact's `id` on yours rather than duplicating the fields. Two writable copies of a person's name with no single owner drift, and Connect's copy is the one an agent will keep updating from calls. Storing the id also gives you an exact join to anything else keyed by it: a booking made during a call carries `contactId`, so "which of my records is this appointment for" stops being a phone-number match and becomes a lookup.

**Contacts are strictly per-workspace.** A number known to two of your customers is two independent records with two independent histories - there is no cross-tenant identity graph, deliberately, because the only thing one could offer is telling one customer what another knows about a caller.

`contact: null` is the ordinary answer for a number nobody has called from before. It is not an error.

**An upsert never merges two people.** `contacts.upsert({ workspaceId, email })` (or `phone`, or both) answers `match` - which key found the contact - and `conflict`, which is `true` when the phone and the email belong to two different contacts. The write then went to the phone match and the email stayed where it was.

## Endpoints

### GET `/api/v1/application/contacts`

Look a contact up by phone number or email

**Scope** `contacts:read` &nbsp;·&nbsp; **Rate limit** 60 requests / min per key

"Do we already know this number?" - or this address. One point read, cheap enough for a screen pop on every inbound call. Send `phone` or `email`; when both are sent the phone wins, the order an upsert matches in.

#### Implementation

**1. Prerequisites** - an API key scoped to `contacts:read`, and a live subscription to your application in that workspace.

**2. Send the request:**
```bash
curl "https://connect.cosmoops.com/api/v1/application/contacts?workspaceId=WORKSPACE_ID&phone=%2B919000000001" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT"
```

The number is normalized before the lookup, so `+919000000001`, `919000000001` and `+91 90000 00001` all resolve to the same contact. Comparing unnormalized is how a contact that exists reads as absent. An email is lowercased and trimmed, and nothing else - `a.b@gmail.com` and `ab@gmail.com` are two keys.

**3. `contact: null` is the ordinary answer** for a number nobody has called from before - a first-time caller, not a failure. A contact found by email may have `primaryPhone: null`: it is known only by its address and cannot be called.

**4. Reference it, do not copy it.** If you keep your own customer records, store this contact's `id` on yours. Connect's copy is the one a voice agent keeps updating from calls; a second writable copy of a person's name with no single owner drifts. Storing the id also makes `Booking.contactId` an exact join, so "which of my records is this appointment for" is a lookup rather than a phone match.

`source` is worth rendering: `call` means an agent filled these in and nobody has confirmed them.

#### Errors
`validation_error` (400 - neither `phone` nor `email`), `forbidden` (403 - key missing `contacts:read`, or your application is not installed in that workspace), `rate_limited` (429).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | query | yes |  |
| `phone` | query | no |  |
| `email` | query | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The contact, or null |
| `400` | validation_error |
| `403` | forbidden |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `contact` | `any` | yes |  |

### POST `/api/v1/application/contacts`

Save what an interaction learned about a contact

**Scope** `contacts:write` &nbsp;·&nbsp; **Rate limit** 120 requests / min per key

Find the workspace's contact by phone, then by email, or create one, and fold in what a call or conversation learned - the write a voice agent makes when a caller gives their name, and the one a helpdesk makes for a customer who only ever wrote in.

#### Implementation

**1. Prerequisites** - an API key scoped to `contacts:write`, and a live subscription to your application in that workspace. Server-to-server only: a session token is refused.

**2. Send the facts:**
```bash
curl -X POST "https://connect.cosmoops.com/api/v1/application/contacts" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"workspaceId":"WORKSPACE_ID","phone":"+919000000001","source":"extraction","callId":"CALL_ID","facts":{"name":{"display":"Priya Sharma"},"business":{"company":"Acme Traders"}}}'
```

Send `phone`, `email`, or both - at least one. An email-only body creates a contact with `primaryPhone: null`; the first phone it is later given becomes its primary phone, and is immutable from then on.

**3. Automated facts fill blanks; they never overwrite.** `source` is `call` (what the telephony layer knows), `extraction` (what a model inferred) or `api` (a record your application keeps - the default). Either way a value somebody typed by hand wins, a phone or email is never marked verified, and an array is added to rather than replaced. `changedFields` is what actually landed - a value the record already held is not rewritten - so answer the caller from it, not from what you sent.

**4. Count the call once.** `countCall` defaults to `false`. Pass `true` only on the one write that closes the interaction; every save made while it is still running leaves it off, or a contact who phoned once reads as having phoned three times.

**5. `match` and `conflict`.** `match` says which key found the contact - `phone`, `email`, or `created`. `conflict: true` means the phone matched one contact and the email matched **another**: the facts went to the phone match, the email stayed with its owner, and nothing was merged - merging two people silently is unrecoverable. Show it to a person rather than retrying it away.

`contact.created` or `contact.updated` (with `changed`) reaches the workspace's applications when something changed. A write that changed nothing announces nothing.

#### Errors
`validation_error` (400 - neither `phone` nor `email`, a `phone` that is not E.164, or an invalid `email`), `forbidden` (403), `rate_limited` (429).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `phone` | `any` | no |  |
| `email` | `any` | no |  |
| `facts` | `object` | no |  |
| `source` | `call` \| `extraction` \| `api` | no |  |
| `callId` | `any` | no |  |
| `countCall` | `boolean` | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | An existing contact, after the merge |
| `201` | A contact this write created |
| `400` | validation_error |
| `403` | forbidden |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `contact` | `object` | yes |  |
| `created` | `boolean` | yes |  |
| `changedFields` | `array` | yes |  |
| `match` | `phone` \| `email` \| `created` | yes |  |
| `conflict` | `boolean` | yes |  |

### GET `/api/v1/application/contacts/{contactId}`

Read one contact

**Scope** `contacts:read` &nbsp;·&nbsp; **Rate limit** 60 requests / min per key

The record behind an id you already hold - the one you stored on your own customer record, or the `contactId` on a booking.

```bash
curl "https://connect.cosmoops.com/api/v1/application/contacts/CONTACT_ID?workspaceId=WORKSPACE_ID" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT"
```

A contact belongs to exactly one workspace, so an id from another tenant does not resolve - it answers `404`, the same as an id that never existed.

#### Errors
`forbidden` (403), `not_found` (404 - no such contact in that workspace), `rate_limited` (429).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `contactId` | path | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The contact |
| `403` | forbidden |
| `404` | not_found |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `primaryPhone` | `any` | yes |  |
| `phones` | `array` | yes |  |
| `emails` | `array` | yes |  |
| `name` | `object` | yes |  |
| `business` | `object` | yes |  |
| `tags` | `array` | yes |  |
| `source` | `call` \| `manual` \| `import` \| `api` \| `extraction` | yes |  |
| `createdAt` | `string` | yes |  |
| `updatedAt` | `string` | yes |  |