Chats and contacts

Read the conversations a connected number has, act on them, and look up the people in them. Everything on this page exists on both the API and the dashboard, with identical bodies and responses.

Who owns the list

A number conversations live on the phone and at WhatsApp. Most of them predate Sendveo entirely, and they are marked read, archived and muted from the handset. Sendveo does not own that list and does not try to. What it keeps is a mirror: a stable Sendveo id per conversation, the flags last reported, the attendee names, and the history-sync bookkeeping WhatsApp has nowhere to put.

Three consequences you will notice. The unread count, archived and muted are theirs, and Sendveo overwrites its copy on every read - a conversation archived on the phone comes back archived without Sendveo having been told. A conversation is addressable two ways: chatId is WhatsApp own string, unchanged since V1, and id is the Sendveo cht_… beside it; every route below accepts either. A chat id from nowhere is a 404 - an id has to have reached you through the list, a stored message or a webhook. A conversation you have only ever seen a webhook for still works: the row is built from the messages Sendveo holds rather than answering 404.

List conversations

GET/v1/chats?channelId=chn_sales01&limit=50

channelId means "which of my numbers". With exactly one connected number it is inferred; with several it is genuinely ambiguous, so omitting it is 400 on channelId rather than a guess that would list one number conversations under another.

cURL
curl "https://api.sendveo.com/v1/chats?channelId=chn_sales01&limit=50" \
  -H "X-API-KEY: sv_live_your_key_here"
The Chat resource
{
  "chats": [
    {
      "id": "cht_m8k3x7q2a1b4",
      "chatId": "120363001122334455@g.us",
      "channelId": "chn_sales01",
      "kind": "group",
      "name": "Atlas team - deliveries",
      "phone": null,
      "unreadCount": 1,
      "archived": false,
      "muted": true,
      "mutedUntil": null,
      "lastMessageAt": "2026-09-16T11:42:00Z",
      "lastMessagePreview": "The Rabat round leaves at 8am tomorrow.",
      "sync": { "status": "done", "lastSyncedAt": "2026-09-16T11:50:00Z", "storedMessages": 214 }
    }
  ],
  "cursor": "eyJvIjo1MH0",
  "channelId": "chn_sales01"
}

kind is direct or group. phone is the counterpart on a direct chat and null on a group, and also null for a contact in privacy mode. muted: true with mutedUntil: null means muted with no end date, and a mute whose end date has passed reads as muted: false. sync.status is one of never, started, running, done, error, gone.

One conversation

GET/v1/chats/:chatId

members is only on a single-conversation read and on the answer to a membership change. The list omits it on purpose: including it would be one extra upstream call per conversation.

200 OK
{
  "chat": {
    "id": "cht_m8k3x7q2a1b4",
    "chatId": "120363001122334455@g.us",
    "kind": "group",
    "members": [
      { "id": "cmb_1", "phone": "+212661234567", "name": null, "role": "super_admin",
        "self": true, "joinedAt": "2026-09-01T08:00:00Z", "leftAt": null },
      { "id": "cmb_2", "phone": "+212670111222", "name": "Khadija Alaoui", "role": "admin",
        "self": false, "joinedAt": "2026-09-01T08:00:00Z", "leftAt": null }
    ]
  }
}

Archive and mute

POST/v1/chats/:chatId/archive
DELETE/v1/chats/:chatId/archive
POST/v1/chats/:chatId/mute
DELETE/v1/chats/:chatId/mute

Each of these returns the conversation as it now stands, so there is nothing to reconcile: render what comes back. Remember the flags belong to WhatsApp, so the client can undo any of them from their own phone.

Filtering and paging

cursor is WhatsApp own, opaque, and null when there is no next page. ?unread=true filters upstream; ?archived= and the group filter are applied after the page comes back, because WhatsApp has no such filter.

So a filtered page can be shorter than limit, sometimes empty, while cursor is still non-null. Keep paging until cursor is null. Do not treat a short page as the end - that is the one mistake this endpoint invites, and it silently hides conversations.

History sync

POST/v1/chats/:chatId/sync

GET /v1/messages?chatId=… returns what Sendveo holds, which by default is what arrived through the webhook since the number was connected. Sync pulls older messages into that store so the history is there too.

cURL
curl -X POST https://api.sendveo.com/v1/chats/cht_m8k3x7q2a1b4/sync \
  -H "X-API-KEY: sv_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "limit": 100 }'
200 OK
{ "chatId": "cht_m8k3x7q2a1b4", "status": "done", "stored": 100, "storedTotal": 100, "hasMore": true }

stored is how many messages THIS call added; storedTotal is the running total for the conversation. hasMore: true means call it again - a cursor is saved server-side, so the next call continues where this one stopped. It is idempotent: every message is written through the same de-duplication the live webhook uses, so a repeat stores nothing and answers stored: 0.

Rate limited per conversation: one sync per minute, not per credential, because two credentials syncing one conversation are one sync worth of work upstream. Past it, 429 RATE_LIMITED with a Retry-After in seconds.

status: "running" is normal and not a failure: WhatsApp own background sync can still be walking the conversation while this call returns the page it could already serve. Call again. status: "gone" means the conversation no longer exists upstream, stored is 0, and there is nothing to retry. A status Sendveo does not recognise is reported as running, never done: saying a sync finished when we do not know would make missing history read as "there is none".

Contacts

GET/v1/contacts/:phone

Sendveo never writes a contact anywhere. There is no address book and no contact table: a contact is a number, and everything known about it is read from WhatsApp at the moment you ask.

cURL
curl https://api.sendveo.com/v1/contacts/%2B212670111222 \
  -H "X-API-KEY: sv_live_your_key_here"
200 OK
{
  "contact": {
    "phone": "+212670111222",
    "onWhatsApp": true,
    "name": "Khadija Alaoui",
    "business": false,
    "about": "Available",
    "pictureUrl": "https://api.sendveo.com/v1/contacts/%2B212670111222/picture"
  }
}

A number that is not on WhatsApp is a 200 with onWhatsApp: false, not a 404. The two mean different things and you have to be able to tell them apart: "there is no such number on WhatsApp" is an answer about the world, while a 404 here would mean "not your number" or "no such route". When onWhatsApp is false, nothing else on the object is meaningful. name is what the contact publishes, falling back to the name Sendveo resolved from a conversation with them; about is their status line when their privacy settings disclose it.

A malformed number is 400 VALIDATION_FAILED on phone. The same spellings a send accepts are accepted here (+212661234567, 212661234567, 00212661234567, with spaces, dashes, dots or parentheses); a national form with a leading zero is refused, because without a country it is a guess, and a guess messages a stranger.

Pictures

GET/v1/contacts/:phone/picture
GET/v1/groups/:chatId/picture

Both picture routes answer the bytes, and both are a proxy and never a redirect. The reason is specific to pictures: WhatsApp hands back a picture link it describes as temporary, on its own host, so embedding that link would give you avatars that break on somebody else schedule and a third-party hostname in your DOM. The Sendveo URL does not expire and names nobody.

The response carries a Sendveo Content-Type - image/jpeg, image/png, image/webp or image/gif - chosen rather than echoed: anything else is served as application/octet-stream so a browser downloads it instead of rendering it. Plus Cache-Control: private, no-store, X-Content-Type-Options: nosniff and a locked-down Content-Security-Policy. Nothing is stored, so every request is one upstream call. The cap is 2 MB; a larger picture is a 404 rather than a partial body.

A contact with no picture, a contact hiding it, and an upstream hiccup are indistinguishable from here and all answer 404. Treat it as "no picture" and show initials - it is not an error worth a message. pictureUrl on a contact is null when WhatsApp says there is none; a group pictureUrl is always present, so a 404 there is the honest answer.

Check numbers

POST/v1/contacts/lookup
cURL
{ "numbers": ["+212670111222", "212670111222", "+212699999999", "not a number"] }
200 OK
{
  "results": [
    { "phone": "+212670111222", "onWhatsApp": true },
    { "phone": "+212670111222", "onWhatsApp": true },
    { "phone": "+212699999999", "onWhatsApp": false },
    { "phone": "not a number",  "onWhatsApp": false }
  ]
}

One answer per row you sent, in order, including for rows that are not numbers at all, which come back as given with onWhatsApp: false. A spreadsheet column with one bad cell gets an answer for every row rather than a 400 for the worst one. Numbers are canonicalised, and a duplicate costs one upstream call, not two.

It returns the boolean and nothing else: no name, no picture. "Can I message this number" is a routing question and does not need to be a disclosure one - ask for the profile of a number you actually deal with, one at a time. At most 50 numbers per call and 20 calls per client per minute; an empty list or more than 50 is 400 on numbers, and past the call cap it is 429 with a Retry-After. This is the one endpoint here that can be pointed at numbers you have no relationship with, which is why it is bounded, metered, and carries its own scope.

Scopes

Read and write are split, and history sync sits on the write side because it WRITES messages into your store: a read-only credential must not be able to change what GET /messages returns. *:read grants chats:read and contacts:read like every other read scope. Keys issued before these scopes existed do not carry them - issue a new key rather than editing an old one.

ScopeGrants
chats:readList and read conversations.
chats:manageArchive, mute, and history sync.
contacts:readContact profiles, contact pictures, and the on-WhatsApp lookup.
WhatsApp chats and contacts API · Sendveo docs