Contacts
Add and update the people you message, and attach your own data to them.
Contacts
A contact is a person in your workspace, identified by their phone number. Sending a message to a new number already creates one (see Messages), so you only need these endpoints to add people ahead of time or to attach your own data to them.
Add or update a contact
POST /api/v1/contacts
{
"phone": "+923001234567",
"name": "Ayesha Khan",
"consent_status": "opted_in",
"metadata": { "source": "website", "tier": "gold" }
}
| Field | Required | Notes |
|---|---|---|
phone |
yes | E.164 — a +, then 8–15 digits. This is the identity of the contact |
name |
no | Up to 160 characters |
consent_status |
no | One of unknown, opted_in, opted_out |
metadata |
no | A JSON object of your own key/values |
This is an upsert, keyed on the phone number
There is one contact per number per workspace. Post a number that already
exists and you update it rather than creating a duplicate — so an import you run
twice leaves one contact, not two. There is no separate "update contact" call
and no contact id in the request: the number is the key.
What an upsert will not quietly do
Two omissions are deliberate, because the alternative is silent data loss on a routine re-sync:
- It never downgrades consent. Leave
consent_statusout and the existing value stands. Sendopted_infor someone already opted in, nothing changes. Consent is only ever moved by an explicit value you send — a nightly sync that forgets the field cannot un-consent your audience. - It preserves existing
metadatawhen you omit it. Omitting the field keeps what is already stored. To change it, send the object you want; to clear a value, send it explicitly. What you cannot do by accident is erase it by saying nothing.
metadata must be an object, not a string
"metadata": { "source": "website" } // correct
"metadata": "{\"source\":\"website\"}" // rejected — this is a string
Send metadata as a real JSON object with Content-Type: application/json. A
stringified JSON blob, or the field sent as form data, arrives as a string and
is refused:
{
"message": "The metadata field must be an array.",
"errors": { "metadata": ["The metadata field must be an array."] }
}
Response
HTTP/1.1 201 Created
{
"data": {
"id": "b4f1c0e2-6d3a-4c1b-9f7e-2a1d8c0b5e33",
"phone": "+923001234567",
"name": "Ayesha Khan",
"consent_status": "opted_in",
"consent_updated_at": "2026-07-22T09:14:02+00:00",
"metadata": { "source": "website", "tier": "gold" },
"updated_at": "2026-07-22T09:14:02+00:00"
}
}
The id is the contact's UUID. You can use it as contact_id when sending a
message, instead of repeating the number.
Retrying safely
Send an Idempotency-Key and a retry after a timeout cannot create or re-apply
the contact twice — the first response is replayed. Because the write is an
upsert, a retry without a key is still safe against duplicates, but the key is
what makes it safe against a half-applied change. See
Errors and idempotency.
List your contacts
GET /api/v1/contacts
Returns the workspace's contacts, newest first, in pages.
| Query | Default | Notes |
|---|---|---|
page |
1 |
Which page to return |
per_page |
25 |
Page size |
{
"data": [
{
"id": "b4f1c0e2-6d3a-4c1b-9f7e-2a1d8c0b5e33",
"phone": "+923001234567",
"name": "Ayesha Khan",
"consent_status": "opted_in",
"consent_updated_at": "2026-07-22T09:14:02+00:00",
"metadata": { "source": "website", "tier": "gold" },
"updated_at": "2026-07-22T09:14:02+00:00"
}
],
"links": { "first": "…", "last": "…", "prev": null, "next": "…" },
"meta": { "current_page": 1, "per_page": 25, "total": 184 }
}
Scopes
GET /contacts needs contacts:read; POST /contacts needs contacts:write.
A key created without choosing scopes has both. Scopes are fixed when the key is
issued and cannot be edited afterwards — a key made without contacts:write
will keep rejecting POST /contacts until you create a new
one with the scope. See Authentication.
What this surface does not include
There is no delete-contact and no bulk endpoint in the API, and consent is only
set through the consent_status above — there is no separate consent call. A
contact who has opted out is refused every outbound message, transactional sends
included; that block lives with the send, described under
Messages.