Skip to content

Keep contacts

Download .md

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.

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

You knowAsk
the numberGET /v1/contacts?phone=%2B15555550100
the emailGET /v1/contacts?email=ana@example.com
part of a name, company, tag, number, email or noteGET /v1/contacts?query=acme
a tag, exactlyGET /v1/contacts?tag=vip
an attribute’s valueGET /v1/contacts?attribute_key=plan&attribute_value=gold
the idGET /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.

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.

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.

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

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.

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

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

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

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

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

channelopts outopts back in
SMSSTOP, STOP ALL, UNSUBSCRIBE, CANCEL, END, QUIT, REVOKE, OPT OUTSTART
WhatsAppSTOP, 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.

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:

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

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

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

Terminal window
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 /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.

codemeansdo
409 contact_existsAnother contact has that number or email.Use the contact in contact.
409 stopped_by_personThe person opted out themselves.Only they can opt back in.
409 no_addressNo number or email for a channel you named.Add one, or leave that channel out.
400 invalid_numberA number is not E.164.Send +, country code, number.
404 not_foundNo such contact here, or it was deleted.Check the id.

All codes: Errors.