Listar las conversaciones de un contacto: GET /contacts/:id/conversations
Si el CRM ya tiene el contactId, GET /api/v2/contacts/:id/conversations lista los hilos, del más nuevo al más viejo, con cursor. Scope contacts:read.
Respuesta corta: cuando el CRM ya tiene el contactId, los hilos de esa persona salen de GET https://api.waspytech.com/api/v2/contacts/:id/conversations. Auth: Authorization: Bearer wspy_.... Scope: contacts:read. La paginación es por cursor, del más nuevo al más viejo. No crea el chat y no manda un mensaje.
Sirve para el ERP que guardó el id al crear el contacto y, antes del aviso de despacho, necesita saber si hay un hilo abierto y si serviceWindowExpiresAt sigue vigente. La bandeja entera es otro pedido: conversaciones por API. Si solo tenés el teléfono, el camino es resolver por teléfono o abrir el chat desde el CRM. Asignar y cerrar, después de tener el id del hilo, está en asignar y cerrar.
La referencia pública está en contacts. Esa página muestra el GET y no documenta status, cursor, limit ni los campos. Lo de abajo es el handler de apps/api/src/routes/v2/contacts.ts.
La clave está en Scale (US$150 por mes) y Enterprise. Los planes están en Planes y el módulo, en la API.
El pedido
curl -sS "https://api.waspytech.com/api/v2/contacts/CONTACT_UUID/conversations?status=open&limit=20" \
-H "Authorization: Bearer $WASPY_API_KEY"
CONTACT_UUID es el id de GET /contacts, del 201 de POST /contacts, o del webhook contact.created. Cómo se crea la ficha está en contactos por API.
{
"data": [
{
"id": "conversation-uuid",
"contactId": "CONTACT_UUID",
"phoneNumberId": "channel-uuid",
"status": "open",
"assignedTo": null,
"lastMessageAt": "2026-11-07T14:10:00.000Z",
"lastMessagePreview": "¿Ya salió el 1847?",
"lastMessageDirection": "inbound",
"lastMessageStatus": "received",
"serviceWindowExpiresAt": "2026-11-08T14:10:00.000Z",
"createdAt": "2026-11-02T11:02:00.000Z"
}
],
"meta": { "requestId": "...", "cursor": null, "hasMore": false }
}
No viene el contacto anidado (name, phoneNumber). Ya lo tenés: este recurso es la lista de hilos. Un mismo contacto puede tener más de un hilo si habló por más de un canal.
| Campo | Para qué |
|---|---|
id | El conversationId del PATCH, del leído y de GET /conversations/:id/messages |
phoneNumberId | Canal. Es el UUID que después va en POST /messages |
status | open o closed |
assignedTo | UUID de la persona del equipo, o null |
lastMessageAt | Para ordenar y para armar el cursor de la página siguiente |
lastMessagePreview | El texto corto de la bandeja. No es el hilo completo |
serviceWindowExpiresAt | ISO-8601, o null si no hay ventana |
meta.hasMore | Si es true, hay otra página |
meta.cursor | Solo cuando hasMore es true. Se copia tal cual |
Paginación
limit va de 1 a 100. El default del schema es 20. limit=0 o limit=200 responden 400 VALIDATION_ERROR antes de llegar a la base.
La primera llamada no manda cursor. Si hasMore es true, la siguiente repite la URL con el cursor que vino.
async function threadsOf(contactId, apiKey) {
const out = [];
let cursor;
do {
const url = new URL(
'https://api.waspytech.com/api/v2/contacts/' + contactId + '/conversations',
);
url.searchParams.set('limit', '20');
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url, {
headers: { Authorization: 'Bearer ' + apiKey },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error('Waspy ' + res.status);
const body = await res.json();
out.push(...body.data);
cursor = body.meta.hasMore ? body.meta.cursor : undefined;
} while (cursor);
return out;
}
El cursor que devuelve el servidor es la fecha ISO de lastMessageAt, una barra vertical y el id del hilo. Copialo. Si el string no tiene las dos partes, el handler responde 400 INVALID_CURSOR (Cursor format is invalid).
Ese chequeo es más corto que el de GET /conversations. El listado de la bandeja pasa el cursor por un validador (fecha ISO que termina en Z, y UUID) antes de tocar Postgres. Este GET parte el string en la primera | y lo castea. No lo armes a mano ni le cambies la zona horaria. El mapa de los dos modelos está en paginación.
El orden es lastMessageAt descendente y, a igualdad, el id descendente. La página siguiente pide lo que quedó más viejo que el último ítem de esta.
Antes de mandar
- Si
dataviene vacío, el contacto existe y no tiene hilos. Este GET no abre una conversación. El primer mensaje, si la ventana no existe, es una plantilla. - Si hay un hilo y
serviceWindowExpiresAtes una fecha futura, el texto libre puede salir. Si esnullo ya pasó,POST /messagescontype: "text"responde 403SERVICE_WINDOW_EXPIRED. - El nombre de la plantilla se consulta aparte, con by-name, y solo se encola si
statusesapproved. - Para asignar el hilo o pasarlo a
closed, el id es el de esta lista. El contrato del PATCH está en asignar y cerrar.
status=open deja afuera los cerrados. Sin el query, vienen los dos. pending y resolved no están en el schema.
Errores
| HTTP | error.code | Cuándo | |
|---|---|---|---|
| 401 | UNAUTHORIZED | Falta el header, o la clave no existe | |
| 403 | FORBIDDEN | La clave tiene scopes y no incluye contacts:read | |
| 404 | CONTACT_NOT_FOUND | El UUID no es un contacto de tu cuenta | |
| 400 | VALIDATION_ERROR | El :id no es UUID, status no es open ni closed, o limit fuera de 1–100 | |
| 400 | INVALID_CURSOR | El cursor no trae fecha e id separados por | |
| 429 | RATE_LIMITED | Más de 120 pedidos por minuto en la cuenta |
GET /conversations/:id/messages, y ese sí pide messages:read.
Diez días de prueba, sin tarjeta, en el registro.
