Conversations et contacts

Lisez les conversations d'un numéro connecté, agissez dessus, et renseignez-vous sur les personnes qui s'y trouvent. Tout ce qui figure sur cette page existe à la fois dans l'API et dans le tableau de bord, avec des corps et des réponses identiques.

À qui appartient la liste

Les conversations d'un numéro vivent sur le téléphone et chez WhatsApp. La plupart sont antérieures à Sendveo, et c'est depuis le combiné qu'on les marque comme lues, qu'on les archive et qu'on les met en sourdine. Sendveo ne possède pas cette liste et ne cherche pas à la posséder. Ce qu'il en garde est un miroir : un identifiant Sendveo stable par conversation, les derniers indicateurs rapportés, les noms des participants, et la comptabilité de synchronisation de l'historique que WhatsApp n'a nulle part où ranger.

Trois conséquences que vous remarquerez. Le compteur de non-lus, archived et muted leur appartiennent, et Sendveo écrase sa copie à chaque lecture : une conversation archivée depuis le téléphone revient archivée sans que Sendveo en ait été informé. Une conversation est adressable de deux façons : chatId est la chaîne propre à WhatsApp, inchangée depuis la V1, et id est le cht_… de Sendveo à côté ; toutes les routes ci-dessous acceptent l'un ou l'autre. Un identifiant de conversation sorti de nulle part donne un 404 : un identifiant doit vous être parvenu par la liste, par un message stocké ou par un webhook. Une conversation que vous n'avez jamais vue autrement que par un webhook fonctionne quand même : la ligne est construite à partir des messages que Sendveo détient, au lieu de répondre 404.

Lister les conversations

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

channelId veut dire « lequel de mes numéros ». Avec un seul numéro connecté, il est déduit ; avec plusieurs, il est réellement ambigu, si bien que l'omettre donne 400 sur channelId plutôt qu'une supposition qui listerait les conversations d'un numéro sous un autre.

cURL
curl "https://api.sendveo.com/v1/chats?channelId=chn_sales01&limit=50" \
  -H "X-API-KEY: sv_live_your_key_here"
La ressource Chat
{
  "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 vaut direct ou group. phone est le correspondant sur une conversation directe et null sur un groupe, et vaut aussi null pour un contact en mode confidentialité. muted: true avec mutedUntil: null signifie en sourdine sans date de fin, et une sourdine dont la date de fin est passée se lit muted: false. sync.status vaut never, started, running, done, error ou gone.

Une conversation

GET/v1/chats/:chatId

members n'apparaît que sur la lecture d'une conversation seule et sur la réponse à un changement de membres. La liste l'omet volontairement : l'inclure coûterait un appel amont supplémentaire par 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 }
    ]
  }
}

Archiver et mettre en sourdine

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

Chacune de ces routes renvoie la conversation telle qu'elle est désormais : il n'y a rien à réconcilier, affichez ce qui revient. N'oubliez pas que les indicateurs appartiennent à WhatsApp : le client peut en annuler n'importe lequel depuis son propre téléphone.

Filtrage et pagination

cursor appartient à WhatsApp, il est opaque, et vaut null quand il n'y a pas de page suivante. ?unread=true filtre en amont ; ?archived= et le filtre de groupe sont appliqués après le retour de la page, parce que WhatsApp ne propose pas de filtre de ce genre.

Une page filtrée peut donc être plus courte que limit, parfois vide, alors que cursor n'est pas encore nul. Continuez de paginer jusqu'à ce que cursor vaille null. Ne prenez pas une page courte pour la fin : c'est la seule erreur que ce point de terminaison invite à commettre, et elle masque des conversations en silence.

Synchronisation de l'historique

POST/v1/chats/:chatId/sync

GET /v1/messages?chatId=… renvoie ce que Sendveo détient, c'est-à-dire par défaut ce qui est arrivé par le webhook depuis la connexion du numéro. La synchronisation fait entrer des messages plus anciens dans ce stock, pour que l'historique y soit aussi.

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 indique combien de messages CET appel a ajoutés ; storedTotal est le cumul pour la conversation. hasMore: true veut dire rappelez la route : un curseur est conservé côté serveur, si bien que l'appel suivant reprend là où celui-ci s'est arrêté. La route est idempotente : chaque message est écrit via la même déduplication que le webhook en direct, donc une répétition n'enregistre rien et répond stored: 0.

Le débit est limité par conversation : une synchronisation par minute, et non par identifiant, parce que deux identifiants qui synchronisent une même conversation représentent une seule synchronisation de travail en amont. Au-delà, 429 RATE_LIMITED avec un Retry-After en secondes.

status: "running" est normal, ce n'est pas un échec : la synchronisation de fond propre à WhatsApp peut encore parcourir la conversation pendant que cet appel renvoie la page qu'il pouvait déjà servir. Rappelez la route. status: "gone" signifie que la conversation n'existe plus en amont, stored vaut 0, et il n'y a rien à réessayer. Un statut que Sendveo ne reconnaît pas est rapporté comme running, jamais done : dire qu'une synchronisation est terminée alors que nous l'ignorons ferait passer un historique manquant pour un historique inexistant.

Contacts

GET/v1/contacts/:phone

Sendveo n'écrit jamais un contact nulle part. Il n'y a ni carnet d'adresses ni table de contacts : un contact est un numéro, et tout ce que l'on sait de lui est lu chez WhatsApp au moment où vous le demandez.

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"
  }
}

Un numéro qui n'est pas sur WhatsApp donne un 200 avec onWhatsApp: false, pas un 404. Les deux ne veulent pas dire la même chose et vous devez pouvoir les distinguer : « ce numéro n'existe pas sur WhatsApp » est une réponse sur le monde, alors qu'un 404 voudrait dire ici « ce n'est pas votre numéro » ou « cette route n'existe pas ». Quand onWhatsApp vaut false, rien d'autre sur l'objet n'a de sens. name est ce que le contact publie, à défaut le nom que Sendveo a résolu depuis une conversation avec lui ; about est sa ligne de statut lorsque ses réglages de confidentialité la divulguent.

Un numéro mal formé donne 400 VALIDATION_FAILED sur phone. Les écritures acceptées à l'envoi le sont aussi ici (+212661234567, 212661234567, 00212661234567, avec espaces, tirets, points ou parenthèses) ; une forme nationale commençant par un zéro est refusée, parce que sans pays c'est une supposition, et une supposition écrit à un inconnu.

Photos

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

Les deux routes de photo renvoient les octets, et toutes deux sont un proxy, jamais une redirection. La raison tient aux photos : WhatsApp rend un lien de photo qu'il qualifie de temporaire, sur son propre hôte ; intégrer ce lien vous donnerait des avatars qui cassent au rythme de quelqu'un d'autre et un nom d'hôte tiers dans votre DOM. L'URL Sendveo n'expire pas et ne nomme personne.

La réponse porte un Content-Type choisi par Sendveo, image/jpeg, image/png, image/webp ou image/gif, et non recopié : tout le reste est servi en application/octet-stream, si bien que le navigateur le télécharge au lieu de l'afficher. S'y ajoutent Cache-Control: private, no-store, X-Content-Type-Options: nosniff et une Content-Security-Policy verrouillée. Rien n'est stocké, donc chaque requête est un appel amont. Le plafond est de 2 Mo ; une photo plus lourde donne un 404 plutôt qu'un corps partiel.

Un contact sans photo, un contact qui la cache et un incident en amont sont indiscernables d'ici et répondent tous 404. Traitez cela comme « pas de photo » et affichez des initiales : ce n'est pas une erreur qui mérite un message. pictureUrl sur un contact vaut null quand WhatsApp dit qu'il n'y en a pas ; sur un groupe, pictureUrl est toujours présent, donc un 404 y est la réponse honnête.

Vérifier des numéros

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 }
  ]
}

Une réponse par ligne envoyée, dans l'ordre, y compris pour les lignes qui ne sont pas des numéros du tout : elles reviennent telles quelles avec onWhatsApp: false. Une colonne de tableur dont une cellule est mauvaise obtient une réponse pour chaque ligne, plutôt qu'un 400 à cause de la pire. Les numéros sont canonisés, et un doublon coûte un appel amont, pas deux.

Elle renvoie le booléen et rien d'autre : ni nom, ni photo. « Puis-je écrire à ce numéro » est une question d'acheminement et n'a pas besoin d'en être une de divulgation : demandez le profil d'un numéro avec lequel vous traitez vraiment, un par un. Au plus 50 numéros par appel et 20 appels par client et par minute ; une liste vide ou plus de 50 donne 400 sur numbers, et au-delà du plafond d'appels c'est 429 avec un Retry-After. C'est le seul point de terminaison ici que l'on puisse pointer vers des numéros avec lesquels vous n'avez aucune relation, d'où ses bornes, son compteur, et sa portée propre.

Portées

La lecture et l'écriture sont séparées, et la synchronisation de l'historique se range du côté écriture parce qu'elle ÉCRIT des messages dans votre stock : un identifiant en lecture seule ne doit pas pouvoir changer ce que renvoie GET /messages. *:read accorde chats:read et contacts:read comme toute autre portée de lecture. Les clés émises avant l'existence de ces portées ne les portent pas : émettez une nouvelle clé plutôt que de modifier une ancienne.

PortéeAccorde
chats:readLister et lire les conversations.
chats:manageArchiver, mettre en sourdine, et synchroniser l'historique.
contacts:readProfils de contacts, photos de contacts, et la vérification de présence sur WhatsApp.
API conversations et contacts WhatsApp · Sendveo