Conversaciones y contactos

Lee las conversaciones que tiene un número conectado, actúa sobre ellas y consulta a las personas que hay en ellas. Todo lo de esta página existe tanto en la API como en el panel, con cuerpos y respuestas idénticos.

De quién es la lista

Las conversaciones de un número viven en el teléfono y en WhatsApp. La mayoría son anteriores a Sendveo, y se marcan como leídas, se archivan y se silencian desde el propio móvil. Sendveo no es dueño de esa lista y no pretende serlo. Lo que guarda es un espejo: un id de Sendveo estable por conversación, los últimos indicadores comunicados, los nombres de los participantes y la contabilidad de la sincronización de historial que WhatsApp no tiene dónde guardar.

Tres consecuencias que vas a notar. El contador de no leídos, archived y muted son suyos, y Sendveo sobrescribe su copia en cada lectura: una conversación archivada en el teléfono vuelve archivada sin que a Sendveo se le haya dicho nada. Una conversación se direcciona de dos maneras: chatId es la cadena propia de WhatsApp, sin cambios desde la V1, e id es el cht_… de Sendveo que va a su lado; todas las rutas de abajo aceptan cualquiera de los dos. Un id de conversación salido de la nada es un 404: un id tiene que haberte llegado por la lista, por un mensaje guardado o por un webhook. Una conversación de la que solo has visto un webhook funciona igualmente: la fila se construye a partir de los mensajes que Sendveo conserva, en lugar de responder 404.

Listar conversaciones

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

channelId significa "cuál de mis números". Con un único número conectado se infiere; con varios es genuinamente ambiguo, así que omitirlo da 400 en channelId en vez de una suposición que listaría las conversaciones de un número bajo otro.

cURL
curl "https://api.sendveo.com/v1/chats?channelId=chn_sales01&limit=50" \
  -H "X-API-KEY: sv_live_your_key_here"
El recurso 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 es direct o group. phone es la contraparte en un chat directo y null en un grupo, y también null para un contacto en modo privacidad. muted: true con mutedUntil: null significa silenciada sin fecha de fin, y un silencio cuya fecha de fin ya pasó se lee como muted: false. sync.status es uno de never, started, running, done, error, gone.

Una conversación

GET/v1/chats/:chatId

members solo aparece en la lectura de una conversación concreta y en la respuesta a un cambio de miembros. La lista lo omite a propósito: incluirlo costaría una llamada extra aguas arriba por cada conversación.

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

Archivar y silenciar

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

Cada una de estas devuelve la conversación tal como queda, así que no hay nada que reconciliar: pinta lo que vuelve. Recuerda que los indicadores son de WhatsApp, así que el cliente puede deshacer cualquiera de ellos desde su propio teléfono.

Filtrado y paginación

cursor es propio de WhatsApp, es opaco y vale null cuando no hay página siguiente. ?unread=true filtra aguas arriba; ?archived= y el filtro de grupos se aplican después de que vuelva la página, porque WhatsApp no ofrece ese filtro.

Así que una página filtrada puede ser más corta que limit, a veces vacía, mientras cursor sigue sin ser nulo. Sigue paginando hasta que cursor sea null. No tomes una página corta por el final: ese es el único error que invita a cometer este endpoint, y esconde conversaciones en silencio.

Sincronización del historial

POST/v1/chats/:chatId/sync

GET /v1/messages?chatId=… devuelve lo que Sendveo conserva, que por defecto es lo que llegó por el webhook desde que se conectó el número. La sincronización trae mensajes más antiguos a ese almacén para que el historial también esté ahí.

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 es cuántos mensajes añadió ESTA llamada; storedTotal es el total acumulado de la conversación. hasMore: true significa vuelve a llamarla: se guarda un cursor en el servidor, así que la siguiente llamada continúa donde paró esta. Es idempotente: cada mensaje se escribe con la misma deduplicación que usa el webhook en vivo, así que una repetición no guarda nada y responde stored: 0.

Está limitado por conversación: una sincronización por minuto, no por credencial, porque dos credenciales sincronizando una misma conversación son una sola sincronización de trabajo aguas arriba. Pasado el límite, 429 RATE_LIMITED con un Retry-After en segundos.

status: "running" es normal y no un fallo: la sincronización en segundo plano propia de WhatsApp puede seguir recorriendo la conversación mientras esta llamada devuelve la página que ya podía servir. Vuelve a llamar. status: "gone" significa que la conversación ya no existe aguas arriba, stored es 0 y no hay nada que reintentar. Un estado que Sendveo no reconoce se informa como running, nunca como done: decir que una sincronización terminó cuando no lo sabemos haría que un historial incompleto se leyera como "no hay historial".

Contactos

GET/v1/contacts/:phone

Sendveo nunca escribe un contacto en ninguna parte. No hay agenda ni tabla de contactos: un contacto es un número, y todo lo que se sabe de él se lee de WhatsApp en el momento en que lo pides.

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 número que no está en WhatsApp es un 200 con onWhatsApp: false, no un 404. Significan cosas distintas y tienes que poder distinguirlas: "no existe ese número en WhatsApp" es una respuesta sobre el mundo, mientras que un 404 aquí querría decir "no es tu número" o "no existe esa ruta". Cuando onWhatsApp es false, nada más del objeto tiene sentido. name es lo que el contacto publica, y si no, el nombre que Sendveo resolvió a partir de una conversación con él; about es su línea de estado cuando sus ajustes de privacidad la muestran.

Un número mal formado es 400 VALIDATION_FAILED en phone. Aquí se aceptan las mismas grafías que acepta un envío (+212661234567, 212661234567, 00212661234567, con espacios, guiones, puntos o paréntesis); una forma nacional que empieza por cero se rechaza, porque sin país es una suposición, y una suposición escribe a un desconocido.

Fotos

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

Las dos rutas de foto responden los bytes, y ambas son un proxy y nunca una redirección. La razón es propia de las fotos: WhatsApp devuelve un enlace de foto que él mismo describe como temporal, en su propio host, así que incrustar ese enlace te daría avatares que se rompen al ritmo de otro y un nombre de host ajeno en tu DOM. La URL de Sendveo no caduca y no nombra a nadie.

La respuesta lleva un Content-Type elegido por Sendveo, image/jpeg, image/png, image/webp o image/gif, no copiado: cualquier otra cosa se sirve como application/octet-stream para que el navegador la descargue en vez de mostrarla. Más Cache-Control: private, no-store, X-Content-Type-Options: nosniff y una Content-Security-Policy muy cerrada. No se guarda nada, así que cada petición es una llamada aguas arriba. El tope es de 2 MB; una foto mayor es un 404 en vez de un cuerpo parcial.

Un contacto sin foto, un contacto que la oculta y un tropiezo aguas arriba son indistinguibles desde aquí y los tres responden 404. Trátalo como "sin foto" y muestra las iniciales: no es un error que merezca un mensaje. pictureUrl en un contacto es null cuando WhatsApp dice que no hay; en un grupo pictureUrl siempre está, así que ahí un 404 es la respuesta honesta.

Comprobar números

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

Una respuesta por cada fila que envías, en orden, incluidas las filas que no son números en absoluto, que vuelven tal cual con onWhatsApp: false. Una columna de hoja de cálculo con una celda mala recibe respuesta para cada fila, en vez de un 400 por la peor. Los números se canonizan, y un duplicado cuesta una llamada aguas arriba, no dos.

Devuelve el booleano y nada más: ni nombre, ni foto. "¿Puedo escribir a este número?" es una pregunta de enrutamiento y no hace falta que sea una de divulgación: pide el perfil de un número con el que realmente tratas, de uno en uno. Como mucho 50 números por llamada y 20 llamadas por cliente y minuto; una lista vacía o más de 50 es 400 en numbers, y pasado el tope de llamadas es 429 con un Retry-After. Este es el único endpoint de aquí que se puede apuntar a números con los que no tienes ninguna relación, y por eso está acotado, medido y lleva su propio ámbito.

Ámbitos

La lectura y la escritura están separadas, y la sincronización de historial cae del lado de la escritura porque ESCRIBE mensajes en tu almacén: una credencial de solo lectura no debe poder cambiar lo que devuelve GET /messages. *:read concede chats:read y contacts:read como cualquier otro ámbito de lectura. Las claves emitidas antes de que existieran estos ámbitos no los llevan: emite una clave nueva en lugar de editar una antigua.

ÁmbitoConcede
chats:readListar y leer conversaciones.
chats:manageArchivar, silenciar y sincronizar el historial.
contacts:readPerfiles de contactos, fotos de contactos y la comprobación de presencia en WhatsApp.
API de conversaciones y contactos de WhatsApp · Sendveo