Contacts
List, look up, read, or upsert people in your workspace directory without exposing private agent memory.
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET |
/api/v1/contacts |
contacts:read |
List contacts, newest first, 1–100 at a time. |
GET |
/api/v1/contacts/lookup |
contacts:read |
Find one contact by an exact email or phone. |
GET |
/api/v1/contacts/{contact_id} |
contacts:read |
Read one contact. |
POST |
/api/v1/contacts |
contacts:write |
Match a contact by identity, or create one. |
Your CRM has a customer. Our inbox has a person who messaged on WhatsApp. These endpoints are how you read the directory and assert that the two are the same human, so that when a conversation opens your team sees a name rather than a phone number.
`contacts:write` does not include reading
The two scopes are separate. A key with only contacts:write gets 403 missing_scope on every GET here. If your sync both looks people up and upserts them, create the key with Read contacts and Manage contacts both ticked. Scopes can't be added to a key later; see Scopes.
What it does #
POST /contacts is an upsert, not a create. Every request is resolved against the existing directory before anything is written:
- Each identifier you sent,
emailandphone, is looked up on its own. - If one or both match the same existing contact, that contact is returned. Any of your values that the contact doesn't have yet are filled in.
- If they match two different contacts, nothing is written and you get
identity_conflict. - If nothing matches and you sent a
name, a new contact is created.
Two rules follow from that, and both surprise people.
- Existing values are never overwritten. A matched contact keeps its name, email and phone. Your values only fill fields that are empty. If you send a phone that differs from the one already on the contact you matched by email, that is a conflict, not an update.
- A new contact needs a name. If nothing matches and you didn't send
name, the request fails with400and nothing is created.
The practical consequence: sending the same body twice is safe and returns one contact. You don't need to check whether a contact exists before calling.
To change a name, email or phone that is already set, edit the contact in the console under Contacts. The API doesn't change existing values.
Parameters #
namestringoptionalThe person's name, up to 120 characters. Never used for matching. Required when no existing contact matches, because a new contact needs a name. On a matched contact it only fills an empty name.
emailstringoptionalUp to 200 characters. Trimmed and case-folded before matching, so Kavya.Iyer@Example.IN and kavya.iyer@example.in are the same contact.
phonestringoptionalUp to 32 characters. Normalised to E.164 before matching. A number with + keeps its own country code; a number without one is read as a number in your workspace's country.
At least one of email or phone is required. A request with only a name, or with no fields, is refused before any lookup:
{"error": {"code": "invalid_argument","message": "Provide an email or a phone number.","details": {},"request_id": "req_01JRB7K2M4Q8T1VZC6N3XW5HDA","correlation_id": "cor_01JRB7K2M4Q8T1VZC6N3XW5HDA"}}
This rejection carries no details.reason, so branch on error.code. A name is not an identity: "Kavya Iyer" matches nothing, so a name-only upsert could only ever create unreachable rows.
How phone numbers are matched #
The directory stores phone numbers in E.164, and so does every channel. Your phone is normalised the same way before it is matched, so +91 98450 31276, +919845031276 and, for an Indian workspace, 98450 31276 all resolve to the same contact.
A number without + is checked strictly against your workspace's country. A number that can't be a real number there, or that can't be parsed at all, fails the request. That is not retryable: fix the number in your system, or send it with its country code.
Send E.164 anyway
Normalising in your own code, before you call, means your CRM and Amomic-AI hold the same string. It also means you never depend on the workspace's country being the one you assumed.
Sending one #
curl -X POST https://services.amomic.in/api/v1/contacts \-H "Authorization: Bearer $AMOMIC_API_KEY" \-H "Content-Type: application/json" \-d '{"name": "Kavya Iyer","email": "kavya.iyer@example.in","phone": "+919845031276"}'
What comes back #
201 Created, whether the contact was matched or created. The body is the contact as the directory resolved it:
{"contact_id": "cnt_01JRB7M9W2E5K8P3T6Y1V4QZAH","org_id": "org_veloria_home","name": "Kavya Iyer","phone": "+919845031276","email": "kavya.iyer@example.in","wa_id": null,"tags": ["ai-captured"],"consent": {},"last_seen_channel": "api","created_at": null,"ai_excluded": false}
| Field | Type | Notes |
|---|---|---|
contact_id |
string | cnt_ followed by a time-ordered id. Use it with GET /contacts/{contact_id}. |
org_id |
string | Your workspace id. |
name, email, phone |
string | null | As stored after this call. On a matched contact these are the contact's existing values. |
wa_id |
string | null | The WhatsApp id, once the person has messaged you on WhatsApp. |
tags |
string[] | A contact this call creates is tagged ai-captured, like every contact created automatically. A matched contact keeps its tags. |
consent |
object | Per-channel flags such as {"whatsapp": false}. Empty means nothing recorded. |
last_seen_channel |
string | null | api after this call. |
created_at |
string | null | null in the response for a contact this call just created. Read it back to see the stored time. |
ai_excluded |
boolean | true when the agent is told to stay out of this person's conversations. |
Read only the fields you need and default anything missing. This response isn't narrowed to the read projection below, which is why it carries org_id and ai_excluded and the reads don't.
The response doesn't say whether the contact was created or matched. If you need to know, look it up first.
When your identifiers disagree #
You sent an email that belongs to contact A and a phone that belongs to contact B. Or you sent an email that matched contact A, and a phone that differs from the one A already has:
{"error": {"code": "conflict","message": "Those details belong to two different contacts.","details": { "reason": "identity_conflict" },"request_id": "req_01JRB7Q3H6N9D2F5J8L1S4U7XB","correlation_id": "cor_01JRB7Q3H6N9D2F5J8L1S4U7XB"}}
Nothing is written. The request is refused whole.
Why we refuse instead of merging #
- Merging A and B is destructive and irreversible. It would fold two customers' conversation histories, channel permissions and tags into one record on the strength of one automated call. If they really are two people, such as a shared family phone or a colleague's email typed into a form, you can't undo it.
- Picking the email match and ignoring the phone silently moves a phone number from one person to another. The next WhatsApp message from that number lands under the wrong name.
- Creating a third contact turns one confusing state into a worse one.
A conflict means your systems hold information ours doesn't. So the request stops and comes back to you.
Resolving one #
- 1
Decide which identifier is authoritative
Send only that one.
{"email": "kavya.iyer@example.in"}returns contact A and leaves B alone. This is the fix when the phone number in your system was stale. - 2
Or fix the record in the console
Open Contacts, search for either identifier, and edit or delete the record that is wrong. Then retry the upsert. See Contacts and customer memory.
- 3
Route the conflict to a human, not a retry queue
identity_conflictwill return409until someone changes the data. A job that retries it on a schedule produces nothing but log volume. Flag it for review with therequest_idattached, and move on to the next record.
The same guard exists inside the product
Agents that resolve a contact during a conversation follow the same rule: conflicting identities stop the action for manual review rather than guessing.
Idempotency #
POST /contacts ignores the Idempotency-Key header. Sending it is harmless and does nothing.
It doesn't need one. Executing twice resolves the same identifiers, finds the same contact and writes nothing new; the operation converges by construction. Storing a response to replay would only add a 24-hour window in which a later, genuinely different request got the old answer.
The practical rule: retry POST /contacts freely after a timeout, a 500 or a connection reset. There is no duplicate to create. The one exception is not a retry artefact: if someone changed the directory between your two calls, the retry can newly hit identity_conflict.
See Idempotency for the endpoints that honour the header.
Reading and looking up contacts #
All three reads return the same projection, without the private memory the agent keeps about a person. Memory is never returned by the API.
{"contact_id": "cnt_01JR9X4C7F2H5K8M1P4S7V0YBD","name": "Arjun Malhotra","phone": "+919811047352","email": "arjun.malhotra@example.in","wa_id": "919811047352","tags": ["amc-2026", "split-ac"],"consent": { "whatsapp": true },"last_seen_channel": "whatsapp","created_at": "2026-09-22T06:41:18.204311+00:00"}
List contacts #
/api/v1/contacts| Query | Default | Notes |
|---|---|---|
limit |
25 |
1–100. Outside that range is a 400. |
cursor |
none | The next_cursor from the previous page. |
Newest first, by creation time. The response is {"items": [...], "next_cursor": "..."}. next_cursor is the last contact's id when the page came back full, and null when it didn't. A full last page can return one more, empty, page. See Pagination.
There are no filters on the list. To find one person, use the lookup.
Look up by email or phone #
/api/v1/contacts/lookupPass email, phone, or both. Matching is exact after the same normalisation as the upsert: email is case-folded, phone becomes E.164. It never creates anything.
curl -G https://services.amomic.in/api/v1/contacts/lookup \-H "Authorization: Bearer $AMOMIC_API_KEY" \--data-urlencode "phone=+919811047352"
| Result | When |
|---|---|
200 with the contact |
One contact matches. |
400 invalid_argument |
Neither email nor phone was given. |
404 not_found |
Nothing matches. Contact not found. |
409 conflict, identity_conflict |
You sent both, and they belong to two different contacts. |
Read one contact #
/api/v1/contacts/{contact_id}Returns the contact, or 404 not_found with Contact not found. An id from another workspace is a 404 too.
Tags and channel permissions are managed in the console; the upsert never changes them. For what a customer actually said, use Conversations.
Failures #
invalid_argument`Provide an email or a phone number.` Neither identifier was present. `details` is empty.invalid_argument`The contact could not be resolved from those details.` Nothing matched and there was no `name` to create a contact with. Send a name.invalid_argument`Request validation failed.` A field is too long, or `limit` is outside 1–100. `details.errors` names it.unauthenticatedThe key is missing, malformed, unknown, revoked, past its roll grace period, or for the wrong environment. `details.reason` says which. See [Authentication](/api/authentication).permission_deniedmissing_scopeThe key lacks `contacts:read` (reads) or `contacts:write` (upsert). `details.required_scope` names it.permission_deniedworkspace_suspendedThe workspace is suspended. Restore billing to use its keys.not_found`Contact not found.` Unknown id, an id from another workspace, or no lookup match.conflictidentity_conflictThe identities belong to two different contacts, or the matched contact already holds a different value. Nothing was written.rate_limitedrate_limited120 requests per minute per key. A bulk import will meet this; pace it. See [Rate limits](/api/rate-limits).internalThe directory couldn't be reached, or a phone number couldn't be used. Retry the first with backoff; fix the number for the second. Log the `request_id`.A 500 on the upsert is safe to retry. On most endpoints a 500 leaves you wondering whether the side effect happened. On an upsert it doesn't matter. Back off, try a few times, then alert. If the same record fails every time, check its phone number.
Related #
Conversations
The read side: what the customer said, and the customer object that carries their identity.
Tickets
POST /tickets takes requester name, email and phone inline, so a ticket doesn't need a contact upsert first.
Campaigns
A campaign audience is a list of contact_ids or a set of tags. The ids come from here.
Contacts in the console
Edit, tag and exclude people, and set channel permissions.