API reference

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_status out and the existing value stands. Send opted_in for 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 metadata when 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.

Was this guide useful? Report an issue