Keep contacts
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. Every field: Contacts reference.
Create a contact
Section titled “Create a contact”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" } }'{ "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
Section titled “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). 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
Section titled “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
Section titled “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.
- First run:
GET /v1/contacts?updated_after=1970-01-01T00:00:00Z&limit=200, then eachnext_cursorasafteruntilhas_moreisfalse. Upsert every contact byid. - Remember the largest
updated_atyou processed. - Next run:
updated_afteris 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
Section titled “Change a contact”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
Section titled “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.
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.
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
Section titled “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:
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
Section titled “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 |
| 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_personand 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_outand 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
Section titled “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.
A list is a set of contacts a campaign sends to
(audience.list). Make a list with
new rows. To add contacts you already have, pass their ids:
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.
Create a list from contacts you have
Section titled “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:
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
Section titled “Rename a list”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
Section titled “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
Section titled “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.