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 *telephony* identity, not a CRM record.** A contact here is keyed by `primaryPhone` - the one fact every inbound call carries - and it is written automatically: a voice agent creates one on the first call from a new number and fills fields in as it learns them. `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.

## Endpoints

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

Look a caller up by phone number

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

"Do we already know this number?" - one point read, cheap enough for a screen pop on every inbound call.

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

**3. `contact: null` is the ordinary answer** for a number nobody has called from before - a first-time caller, not a failure.

**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 - no `phone`), `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 | yes |  |

**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, or create one, and fold in what a call or conversation learned - the write a voice agent makes when a caller gives their name.

#### 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"}}}'
```

**3. Automated facts fill blanks; they never overwrite.** `source` is `call` (what the telephony layer knows) or `extraction` (what a model inferred). 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.

`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 - `phone` must be E.164), `forbidden` (403), `rate_limited` (429).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `phone` | `string` | yes |  |
| `facts` | `object` | yes |  |
| `source` | `call` \| `extraction` | yes |  |
| `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 |  |

### 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` | `string` | 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 |  |