# Keep contacts

> Create, find and change the people you text and call with /v1/contacts, give them several numbers and emails, tag them, opt them out, and put them in lists.

A contact is one person: a phone number, an email, or both, with a name,
a company, custom `attributes` and tags. Campaigns, lists and opt-outs all
start from contacts.

**You need:** an [API key](/getting-started/api-keys/). Every field:
[Contacts reference](/platform-api/reference/contacts/).

## Create a contact

```bash
curl -X POST https://api.omniocto.com/v1/contacts \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "phone": "+15555550100", "name": "Ana", "tags": ["vip"], "attributes": { "tier": "gold" } }'
```

```json
{ "id": "ct-00000000a1", "object": "contact", "uuid": "00000000-0000-4000-8000-000000000031",
  "name": "Ana", "phone": "+15555550100", "email": null, "company": null,
  "attributes": { "tier": "gold" }, "tags": ["vip"],
  "dnc": { "sms": false, "voice": false, "whatsapp": false, "email": null },
  "created_at": "2026-10-18T09:00:00.000Z", "updated_at": "2026-10-18T09:00:00.000Z" }
```

The answer is `201`. A phone is kept for texts, calls and WhatsApp at once.
Keeping a number is not consent: opt-outs belong to the number, and `dnc`
shows them.

Each number or email belongs to one contact. Creating a second contact
with it is `409 contact_exists`, and the error's `contact` names the one
that has it. Read that one, or change it, instead.

Send an `Idempotency-Key` with every create. If the answer is lost, send
the same request with the same key: you get the first contact back, never
a second one.

## Find a contact

| You know | Ask |
|---|---|
| the number | `GET /v1/contacts?phone=%2B15555550100` |
| the email | `GET /v1/contacts?email=ana@example.com` |
| part of a name, company, tag, number, email or note | `GET /v1/contacts?query=acme` |
| a tag, exactly | `GET /v1/contacts?tag=vip` |
| an attribute's value | `GET /v1/contacts?attribute_key=plan&attribute_value=gold` |
| the id | `GET /v1/contacts/ct-00000000a1` |

Write the `+` as `%2B` ([phone numbers](/platform-api/overview/#phone-numbers)). A number can be
shared by two people, so `phone` can answer more than one contact: the
person the number belongs to comes first.

`query` takes up to 100 characters, in any case, and at least 3 once
lower-cased; a Chinese name counts by its pinyin, so `王五` works. It also
matches the contact's notes. It cannot be combined with `phone` or `email`. Results come newest first, a page at a time:
pass `next_cursor` as `after` for the next page, with the same `query`.

### Find contacts by tag or attribute

`tag` matches one tag exactly, as stored: `vip` is not `VIP` or
`vip-lapsed`. `attribute_key` alone answers the contacts that have that
attribute; with `attribute_value`, those whose value is that text, as the
contact prints it (`gold`, `5`, `true`). They combine with each other and
with `query`, newest first, but not with `phone` or `email`. Filtering on
a rare attribute value in a very large address book is slower: tags are
indexed, attribute values are not.

## Sync changes

`updated_after` answers the contacts whose `updated_at` is at or after
that time, **oldest change first**. A contact that changes while you page
moves to the end and comes again, so keep the latest copy by `id`.

1. First run: `GET /v1/contacts?updated_after=1970-01-01T00:00:00Z&limit=200`,
   then each `next_cursor` as `after` until `has_more` is `false`. Upsert
   every contact by `id`.
2. Remember the largest `updated_at` you processed.
3. Next run: `updated_after` is that time minus 2 minutes, and the same
   walk.

The 2-minute overlap covers a change saved a moment after its time was
stamped. `updated_at` is answered to the millisecond and stored finer,
which the overlap also covers. It moves when the contact's fields, tags,
attributes or opt-outs change, and when a number or email is added,
changed, made primary or removed. Deleted contacts are not listed: run a
full pass now and then to find them.

## Change a contact

```bash
curl -X PATCH https://api.omniocto.com/v1/contacts/ct-00000000a1 \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" -H "Content-Type: application/json" \
  -d '{ "company": "Acme Bakery", "attributes": { "tier": null, "region": "north" } }'
```

Fields you leave out stay as they are. In `attributes`, `null` removes a
key and the other keys stay. A new `phone` replaces all of the contact's
numbers; to add one beside them, use the next section.

## Several numbers and emails

`GET /v1/contacts/{id}/channels` lists every number and email of the
contact: numbers first, then emails, the primary one of each first. Each
item says which channels it is on, whether it is `primary` (the one the
contact's `phone`, or `email`, shows), whether another contact `shared`
it, and its opt-outs. A contact has one primary number and one primary
email. The list pages like every other: `limit` (default 100) and
`after`. Pages follow the primary-first order, so a primary that changes
between two pages can repeat or skip an address: read again from the
first page after changing one.

```bash
curl -X POST https://api.omniocto.com/v1/contacts/ct-00000000a1/channels \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" -H "Content-Type: application/json" \
  -d '{ "phone": "+15555550101" }'
```

Send one `phone` or one `email` per request. A new one is `201`; one the
contact already has is `200`. Add `"primary": true` to make it the main
one: a primary number leaves the primary email as it is, and the reverse.
A number another contact has is `409 contact_exists`: two people who
really share a number are set up in the dashboard, under Contacts. A
contact takes up to 50 numbers and emails here.

Adding, changing or removing a number or email, making one primary, and
an opt-out or opt-in each move the contact's `updated_at`.

`DELETE /v1/contacts/{id}/channels/%2B15555550101` removes a number (or
pass an email). The number's opt-outs stay on the number. Someone who
shares it keeps it. The last number or email cannot be removed: delete the
contact instead.

## Tags

```bash
curl -X POST https://api.omniocto.com/v1/contacts/ct-00000000a1/tags \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" -H "Content-Type: application/json" \
  -d '{ "tags": ["vip", "spring-sale"] }'
```

Up to 50 tags a request, 64 characters each. A tag the contact has already
stays once. A tag cannot be only dots (`.` or `..`): such a tag could not
be removed by its path, so it is refused. `DELETE /v1/contacts/{id}/tags/vip` removes one; a tag it does
not have answers the same. Both answer the contact.

A workflow set to start when a tag is applied starts for tags added here
too, and for `tags` sent with a create.

## Opt-outs

Every contact has `dnc`: `true` for each channel its number or email is
opted out of. Change it with `POST /v1/contacts/{id}/dnc`:

```bash
curl -X POST https://api.omniocto.com/v1/contacts/ct-00000000a1/dnc \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" -H "Content-Type: application/json" \
  -d '{ "channels": ["sms", "voice"], "status": "opted_out", "reason": "Asked on the phone" }'
```

`channels` takes `sms`, `voice`, `whatsapp` and `email`. A phone channel
covers every number of the contact; `email` covers every email. The
answer is the contact with `opt_out`, which lists the channels changed.

### What opts a number out

The person's whole message (case, punctuation and emoji ignored), or a tap:

| channel | opts out | opts back in |
|---|---|---|
| SMS | STOP, STOP ALL, UNSUBSCRIBE, CANCEL, END, QUIT, REVOKE, OPT OUT | START |
| WhatsApp | STOP, STOP ALL, UNSUBSCRIBE, OPT OUT, or a template's opt-out button ("Stop promotions") | START |

Each covers its own channel only. On WhatsApp, CANCEL, END and QUIT are
ordinary replies, so name an opt-out button with a word above (or give it one
as its `payload`); an opt-out button is never read as an opt-in. The keyword
or tap stays in the conversation with an opt-out note, nothing is sent in
reply, the AI agent does not answer it, and a later send to the number is
`409 opted_out`.

An opt-out is kept on the number, not on the contact. It holds for
everyone who has that number, and deleting the contact does not undo it.

`"status": "opted_in"` makes a channel reachable again, with two limits:

- If the person opted out themselves, for example by texting STOP, the
  answer is `409 stopped_by_person` and nothing changes. Only they can
  opt back in: on SMS, by texting START.
- If another contact with the same number sent STOP, that channel is in
  `opt_out.kept_opted_out` and stays opted out.

A channel the contact has no number or email for is `409 no_address`.
Either way, all the channels you name change together, or none do.

## Custom fields

`GET /v1/contact-fields` lists the fields the workspace defined in the
dashboard. Each `key` is a key of `attributes`, and a campaign reads it as
`{{contact.<key>}}`. Any key works in `attributes`, defined or not; values
are stored as text.

The dashboard sets `agent_access` and `agent_role`.

## Lists

A list is a set of contacts a campaign sends to
(`audience.list`). [Make a list](/platform-api/campaigns/#make-a-list) with
new rows. To add contacts you already have, pass their ids:

```bash
curl -X POST https://api.omniocto.com/v1/lists/cl-0000000001/contacts \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "contact_ids": ["ct-00000000a1", "ct-00000000a2"] }'
```

Up to 1,000 ids a request, with or without `contacts` rows. An id that is
not one of your contacts is skipped and listed in `import.errors`.
Nobody is in a list twice. Every field:
[Contact lists reference](/platform-api/reference/lists/).

### Create a list from contacts you have

`POST /v1/lists` takes the same `contact_ids`, so a list of people you have
already synced is one request:

```bash
curl -X POST https://api.omniocto.com/v1/lists \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "name": "VIP customers", "contact_ids": ["ct-00000000a1", "ct-00000000a2"] }'
```

It answers `201` with the list (`source: "api"`) and its `import`: an id
that is not one of your live contacts is skipped, counted in
`invalid_rows` and named in `import.errors`. `contacts` rows may come in
the same request. A request that names people and would add none of them
is `400 empty_audience_input`, with every skipped row and id in `errors`,
and no list is created.

### Rename a list

```bash
curl -X PATCH https://api.omniocto.com/v1/lists/cl-0000000001 \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY" -H "Content-Type: application/json" \
  -d '{ "name": "VIP customers, autumn" }'
```

Only the name changes; the answer is the list. Any list `GET /v1/lists`
shows can be renamed: a campaign's own list (`source: "campaign"`) keeps
its source and is still removed with its draft. Add contacts with
`POST /v1/lists/{id}/contacts`.

## Delete a contact

`DELETE /v1/contacts/{id}` deletes the contact. It leaves lists and future
campaigns; past messages and calls still show it. A number someone else
shares goes to that person, and its opt-outs stay.

## Errors you will meet

| code | means | do |
|---|---|---|
| `409 contact_exists` | Another contact has that number or email. | Use the contact in `contact`. |
| `409 stopped_by_person` | The person opted out themselves. | Only they can opt back in. |
| `409 no_address` | No number or email for a channel you named. | Add one, or leave that channel out. |
| `400 invalid_number` | A number is not E.164. | Send `+`, country code, number. |
| `404 not_found` | No such contact here, or it was deleted. | Check the id. |

All codes: [Errors](/platform-api/errors/#contacts).