Volver al blog
Desarrolladores

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.

· 11 min
Listar las conversaciones de un contacto: GET /contacts/:id/conversations

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.

CampoPara qué
idEl conversationId del PATCH, del leído y de GET /conversations/:id/messages
phoneNumberIdCanal. Es el UUID que después va en POST /messages
statusopen o closed
assignedToUUID de la persona del equipo, o null
lastMessageAtPara ordenar y para armar el cursor de la página siguiente
lastMessagePreviewEl texto corto de la bandeja. No es el hilo completo
serviceWindowExpiresAtISO-8601, o null si no hay ventana
meta.hasMoreSi es true, hay otra página
meta.cursorSolo 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

  1. Si data viene 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.
  2. Si hay un hilo y serviceWindowExpiresAt es una fecha futura, el texto libre puede salir. Si es null o ya pasó, POST /messages con type: "text" responde 403 SERVICE_WINDOW_EXPIRED.
  3. El nombre de la plantilla se consulta aparte, con by-name, y solo se encola si status es approved.
  4. 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

HTTPerror.codeCuándo
401UNAUTHORIZEDFalta el header, o la clave no existe
403FORBIDDENLa clave tiene scopes y no incluye contacts:read
404CONTACT_NOT_FOUNDEl UUID no es un contacto de tu cuenta
400VALIDATION_ERROREl :id no es UUID, status no es open ni closed, o limit fuera de 1–100
400INVALID_CURSOREl cursor no trae fecha e id separados por
429RATE_LIMITEDMás de 120 pedidos por minuto en la cuenta
Un contacto de otra cuenta no se distingue con un 403: es 404. Los mensajes de adentro del hilo son otro recurso, GET /conversations/:id/messages, y ese sí pide messages:read.

Diez días de prueba, sin tarjeta, en el registro.

Tu equipo atendiendo por WhatsApp en minutos

CRM facil pero potente. En 10 minutos tenes a tu equipo trabajando — no necesitas hablar con nadie.