Contacts
Who a workspace knows how to reach - the telephony identity an inbound call resolves to.
Who a workspace already knows how to reach.
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 · 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:
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 · 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:
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 · 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.
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 |