# Contacts

> List, look up, read, or upsert people in your workspace directory without exposing private agent memory.

Source: https://docs.amomic.in/api/contacts

---

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

::: warning `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](/api/scopes).
:::

## What it does

`POST /contacts` is an **upsert**, not a create. Every request is resolved against the existing directory before anything is written:

1. Each identifier you sent, `email` and `phone`, is looked up on its own.
2. 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.
3. If they match **two different** contacts, nothing is written and you get [`identity_conflict`](#when-your-identifiers-disagree).
4. 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 with `400` and 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

```params
[
  {"name": "name", "type": "string", "required": false, "description": "The 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."},
  {"name": "email", "type": "string", "required": false, "description": "Up to 200 characters. Trimmed and case-folded before matching, so <code>Kavya.Iyer@Example.IN</code> and <code>kavya.iyer@example.in</code> are the same contact."},
  {"name": "phone", "type": "string", "required": false, "description": "Up to 32 characters. Normalised to E.164 before matching. A number with <code>+</code> 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:

```json title="400 Bad Request"
{
  "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.

::: tip 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

::: codegroup

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

```javascript Node.js
const BASE = "https://services.amomic.in/api/v1";

async function upsertContact({ name, email, phone }) {
  const response = await fetch(`${BASE}/contacts`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.AMOMIC_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ name, email, phone }),
  });

  if (response.ok) return response.json(); // 201, matched or created

  const { error } = await response.json();

  if (error.details?.reason === "identity_conflict") {
    // The email and the phone belong to two different people, or the matched
    // contact already holds a different value. Retrying cannot fix it.
    await flagForReview({ name, email, phone, requestId: error.request_id });
    return null;
  }

  throw new Error(`${error.code}: ${error.message} (${error.request_id})`);
}
```

```python Python
import os

import requests

BASE = "https://services.amomic.in/api/v1"


def upsert_contact(name=None, email=None, phone=None):
    payload = {k: v for k, v in {"name": name, "email": email, "phone": phone}.items() if v}
    response = requests.post(
        f"{BASE}/contacts",
        headers={"Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}"},
        json=payload,
        timeout=15,
    )
    if response.ok:
        return response.json()  # 201, matched or created

    error = response.json()["error"]
    if (error.get("details") or {}).get("reason") == "identity_conflict":
        # Two different contacts, or a different value already on the match.
        flag_for_review(name, email, phone, error.get("request_id"))
        return None

    raise RuntimeError(f"{error['code']}: {error['message']} ({error.get('request_id')})")
```

:::

## What comes back

`201 Created`, whether the contact was matched or created. The body is the contact as the directory resolved it:

```json title="201 Created"
{
  "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:

```json title="409 Conflict"
{
  "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

::: steps

::: step 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.
:::

::: step 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](/console/contact-memory#add-or-edit-a-contact).
:::

::: step Route the conflict to a human, not a retry queue

`identity_conflict` will return `409` until someone changes the data. A job that retries it on a schedule produces nothing but log volume. Flag it for review with the `request_id` attached, and move on to the next record.
:::

:::

::: note 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](/api/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.

```json title="One contact"
{
  "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

```endpoint
GET /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](/api/pagination).

There are no filters on the list. To find one person, use the lookup.

### Look up by email or phone

```endpoint
GET /api/v1/contacts/lookup
```

Pass `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.

::: codegroup

```bash cURL
curl -G https://services.amomic.in/api/v1/contacts/lookup \
  -H "Authorization: Bearer $AMOMIC_API_KEY" \
  --data-urlencode "phone=+919811047352"
```

```javascript Node.js
const url = new URL("https://services.amomic.in/api/v1/contacts/lookup");
url.searchParams.set("phone", "+919811047352");

const response = await fetch(url, {
  headers: { Authorization: `Bearer ${process.env.AMOMIC_API_KEY}` },
});
const contact = response.status === 404 ? null : await response.json();
```

```python Python
import os

import requests

response = requests.get(
    "https://services.amomic.in/api/v1/contacts/lookup",
    headers={"Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}"},
    params={"phone": "+919811047352"},
    timeout=15,
)
contact = None if response.status_code == 404 else response.json()
```

:::

| 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

```endpoint
GET /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](/api/conversations).

## Failures

```responses
[
  {"status": 201, "code": "", "reason": "", "description": "Upsert: the contact was matched or created. The body is the resolved record."},
  {"status": 400, "code": "invalid_argument", "reason": "", "description": "`Provide an email or a phone number.` Neither identifier was present. `details` is empty."},
  {"status": 400, "code": "invalid_argument", "reason": "", "description": "`The contact could not be resolved from those details.` Nothing matched and there was no `name` to create a contact with. Send a name."},
  {"status": 400, "code": "invalid_argument", "reason": "", "description": "`Request validation failed.` A field is too long, or `limit` is outside 1–100. `details.errors` names it."},
  {"status": 401, "code": "unauthenticated", "reason": "", "description": "The key is missing, malformed, unknown, revoked, past its roll grace period, or for the wrong environment. `details.reason` says which. See [Authentication](/api/authentication)."},
  {"status": 403, "code": "permission_denied", "reason": "missing_scope", "description": "The key lacks `contacts:read` (reads) or `contacts:write` (upsert). `details.required_scope` names it."},
  {"status": 403, "code": "permission_denied", "reason": "workspace_suspended", "description": "The workspace is suspended. Restore billing to use its keys."},
  {"status": 404, "code": "not_found", "reason": "", "description": "`Contact not found.` Unknown id, an id from another workspace, or no lookup match."},
  {"status": 409, "code": "conflict", "reason": "identity_conflict", "description": "The identities belong to two different contacts, or the matched contact already holds a different value. Nothing was written."},
  {"status": 429, "code": "rate_limited", "reason": "rate_limited", "description": "120 requests per minute per key. A bulk import will meet this; pace it. See [Rate limits](/api/rate-limits)."},
  {"status": 500, "code": "internal", "reason": "", "description": "The 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

::: cards

::: card Conversations
[The read side](/api/conversations): what the customer said, and the `customer` object that carries their identity.
:::

::: card Tickets
[POST /tickets](/api/tickets) takes requester name, email and phone inline, so a ticket doesn't need a contact upsert first.
:::

::: card Campaigns
[A campaign audience](/api/campaigns#audience) is a list of `contact_id`s or a set of tags. The ids come from here.
:::

::: card Contacts in the console
[Edit, tag and exclude people](/console/contact-memory), and set channel permissions.
:::

:::
