VitrinaAPI

Responder hilos y resolver tickets

El hilo con un cliente, el caso que lo sobrevive y quién responde.

Si la pregunta es «¿qué le dijimos a esta persona?», la respuesta está en una conversación. Si es «¿en qué va este caso?», está en un ticket.

Una conversación es un hilo con una persona por un canal, sea un WhatsApp, un correo, un chat del sitio o una llamada. La misma persona escribiendo por WhatsApp y por correo son dos conversaciones.

Un ticket es el caso. Puede agrupar varias conversaciones, vive en una columna de un tablero de tipo ticket y tiene responsable y SLA. Sobrevive a que el cliente cambie de canal. No toda conversación necesita uno: una pregunta respondida en dos mensajes nunca abre un ticket.

Leer pide conversations:read; escribir, conversations:write. Los tickets tienen los suyos: tickets:read / tickets:write, más tickets:claim para la única operación en que quien llama se queda el caso. Enviar un mensaje pide además messages:send y pasa por la política de envíos.

La bandeja

curl "https://api.vitrinadev.com/api/v1/conversations?limit=20&include=counts" \
  -H "Authorization: Bearer $VITRINA_KEY"

Cada fila es la conversación con cuatro uniones que la bandeja dibuja: contact, ticket, lastMessage y tags.

{
  "id": "bbbbbbbb-0000-4000-8000-000000000001",
  "display_id": "C-132",
  "channel": "web",
  "status": "open",
  "handler": "human",
  "assignee_user_id": "11111111-0000-4000-8000-000000000001",
  "contact_id": "22222222-0000-4000-8000-000000000001",
  "ticket_id": "dddddddd-0000-4000-8000-000000000001",
  "last_message_date": "2026-09-22T11:08:10.882Z"
}

Los tres ejes

Los filtros de esta lista se confunden con facilidad.

convStatus filtra el ciclo de vida de la conversación: open, pending, snoozed, resolved, closed.

status filtra el ciclo de vida del ticket del que cuelga. Una conversación sin ticket es invisible para este filtro.

handler filtra quién responde: bot, human o external. Es independiente de los otros dos: una conversación puede estar open y contestada por la IA, o snoozed y asignada a una persona.

Los demás filtros son directos: channel, assigneeUserId, teamId, search, fromDate / toDate, unread_only, mentioned_to_me, unassigned.

filtered=true es la única forma de ver los hilos en cuarentena, y solo el texto literal true entra. Cualquier otro valor los excluye, que es el comportamiento por defecto.

?include=counts agrega un counts al lado de data con las mismas cuatro cifras que devuelve GET /conversations/counts. Así se refrescan los contadores y la lista en un solo viaje.

{ "data": { "myOpen": 3, "myUnread": 1, "unassigned": 2, "myMentions": 0 } }

Están acotadas a quien llama: myOpen, myUnread y myMentions son de la persona detrás de la credencial. Una API key las ve en 0, porque no hay persona detrás. Para los contadores de alguien, usa un token personal.

El identificador

id es el uuid y es la identidad: es lo que se guarda en cualquier referencia. display_id (C-132) es la etiqueta que la gente cita. Una ruta la acepta como atajo (GET /conversations/C-132 funciona), pero nunca aparece dentro de otro recurso ni de un evento. La misma regla vale para el ticket (T-6).

Leer el hilo

GET /conversations/{id} devuelve la conversación con toda la transcripción incrustada, sin límite. Si vas a releer la conversación cada cierto tiempo, pide ?exclude=messages y trae los mensajes aparte. Un hilo de dos años devuelve dos años de mensajes cada vez.

curl "https://api.vitrinadev.com/api/v1/conversations/bbbbbbbb-0000-4000-8000-000000000001/messages?limit=50" \
  -H "Authorization: Bearer $VITRINA_KEY"

Cada mensaje trae su autor:

{
  "id": "eeeeeeee-0000-4000-8000-000000000002",
  "conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
  "content": "Hola Rodrigo, te confirmo la visita del jueves a las 10:00.",
  "type": "text",
  "sender_type": "api_key",
  "delivery_status": "sent",
  "author": {
    "kind": "api_key",
    "id": "c1c1c1c1-0000-4000-8000-000000000001",
    "name": "CRM propio"
  }
}

La regla del autor es la misma en todo Vitrina. Quien actúa a través de una aplicación conectada o de un token personal sigue siendo la autora. La credencial queda anotada al lado, en via: «Camila vía Claude», nunca «Claude». Una API key firma como ella misma, con el nombre que tenía en ese momento. Renombrarla o revocarla no reescribe la historia.

Un sender_type de system no es un mensaje que alguien mandó: son las líneas de actividad del hilo («Camila reasignó la conversación»).

La entrega

En los mensajes salientes hay cuatro campos que se leen juntos:

CampoQué dice
delivery_statussentdeliveredread, o failed, o retrying
next_attempt_atCuándo se reintenta (solo mientras está retrying)
delivery_attemptIntentos hechos; 1 es el envío original
delivery_errorEl motivo del proveedor, cuando quedó failed

retrying no es un fallo. El proveedor rechazó el envío y hay otro intento agendado. Solo se vuelve failed cuando se agota la escalera de reintentos: 1 min, 5 min, 15 min, 1 h, 3 h. En los mensajes entrantes los cuatro van en null o 0.

Responder

curl -X POST https://api.vitrinadev.com/api/v1/conversations/bbbbbbbb-0000-4000-8000-000000000001/messages \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"content":"Hola Rodrigo, te confirmo la visita del jueves a las 10:00."}'

Pide messages:send además de conversations:write, y pasa por la política de envíos. Enviar mensajes le dedica un capítulo entero. En dos líneas: un bloqueo responde 422 OUTBOUND_BLOCKED y no se puede pasar por encima. Una advertencia responde 409 OUTBOUND_WARNING, y se reenvía la misma solicitud con acknowledge nombrando cada código.

Las otras formas de poner algo delante del cliente piden lo mismo y responden lo mismo:

OperaciónPara qué
POST /conversations/{id}/templatesUna plantilla de WhatsApp aprobada: lo único que entra con la ventana de 24 horas cerrada
POST /conversations/{id}/flowsUn formulario interactivo de Meta. No reabre la ventana
POST /conversations/{id}/locationUn pin: una sucursal, un enlace de Maps o coordenadas
POST /conversations/{id}/attachmentsUn archivo (multipart/form-data)
POST /conversations/{id}/voiceUna nota de voz (multipart/form-data)

En las dos de multipart/form-data, acknowledge va como una lista separada por comas, nunca como un array JSON.

Programar un envío

En conversaciones de correo, send_at retiene la respuesta hasta esa hora (mínimo un minuto, máximo 30 días). La respuesta no es un mensaje:

{ "scheduled": true, "id": "15151515-0000-4000-8000-000000000001", "send_at": "2026-09-23T13:00:00+00:00", "status": "scheduled" }

La política de envíos lo juzga dos veces: al programarlo y otra vez cuando sale. Entre el lunes y el jueves el contacto puede haber pedido no recibir más mensajes, o haber quedado bajo una retención. Si al dispararse hay un bloqueo, el mensaje no sale. La fila queda en failed con el motivo, y el rechazo queda en los logs de auditoría.

Las advertencias que reconociste al programarlo no se vuelven a preguntar. Quedan anotadas en el registro de envíos.

GET /conversations/{id}/scheduled-messages lista las pendientes; DELETE /conversations/{id}/scheduled-messages/{scheduledId} cancela una. Cancelar no tiene vuelta: reprogramar es volver a redactarla.

Quién atiende

Dos ejes otra vez, y las cuatro operaciones los mueven juntos:

curl -X POST https://api.vitrinadev.com/api/v1/conversations/bbbbbbbb-0000-4000-8000-000000000001/assign \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"assignee_user_id":"11111111-0000-4000-8000-000000000001","handler":"human"}'

assign deja el hilo con la persona que nombras. assignee_user_id: null lo deja en la cola humana sin dueño; omitir la clave no toca al responsable actual.

claim se lo queda quien llama, así que pide una persona detrás de la credencial: una sesión o un token personal. Una sk_* recibe 403, porque no hay a quién asignársela; para eso está assign con un id. Reclamar un hilo que ya tiene dueño no es un error. La respuesta dice quién lo tiene, así que dos personas apretando a la vez reciben la misma respuesta en vez de una carrera.

handoff lo pasa a las personas dejando que las reglas de asignación decidan a quién, que es lo que lo distingue de assign.

return-to-ai lo devuelve a la IA: handler vuelve a bot y el responsable se limpia.

El ciclo de vida

OperaciónQué significa
POST /conversations/{id}/snooze«Vuelve a aparecer a tal hora». Exactamente uno de until (ISO 8601, futuro) o minutes
POST /conversations/{id}/pending«Esperando al cliente», sin reloj: vuelve cuando la persona escriba
POST /conversations/{id}/resolve«Atendido». Un mensaje nuevo la reabre
POST /conversations/{id}/closeTerminal. El cierre automático llega solo, un rato después de resolver
POST /conversations/{id}/reopenVuelve a open y limpia los sellos. Un hilo closed no se reabre: cerrar es terminal, y la respuesta lo devuelve tal cual, closed

Resolver y cerrar bajan al ticket, pero solo cuando ninguna otra conversación de ese ticket sigue activa. Resolver el hilo de WhatsApp de un caso que también corre por correo deja el ticket abierto y no dispara ticket.resolved. El caso no terminó.

Tickets

Se abre un ticket sobre una conversación cuando el caso va a durar:

curl -X POST https://api.vitrinadev.com/api/v1/conversations/bbbbbbbb-0000-4000-8000-000000000001/tickets \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"reason":"El cliente pide reagendar su visita"}'
{
  "data": [
    {
      "id": "dddddddd-0000-4000-8000-000000000001",
      "display_id": "T-6",
      "status": "open",
      "handler": "human",
      "assignee_user_id": "11111111-0000-4000-8000-000000000001",
      "contact_id": "22222222-0000-4000-8000-000000000001",
      "origin_conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
      "current_stage_id": "55555555-0000-4000-8000-000000000011",
      "reason": "El cliente pide reagendar su visita",
      "opened_by": "human",
      "opened_via": "admin_ui",
      "resolved_by": null,
      "resolved_at": null
    }
  ]
}

Pide tickets:write. Sin stage_id ni pipeline_id cae en el tablero de tickets por defecto del workspace. Con pipeline_id cae en la primera columna de ese tablero, que tiene que ser de tipo ticket. Una conversación que ya tiene ticket devuelve ese mismo y nunca abre un segundo.

Un caso, varios canales

El cliente preguntó por WhatsApp y después escribió un correo. Son dos conversaciones y un solo caso:

curl -X POST https://api.vitrinadev.com/api/v1/conversations/bbbbbbbb-0000-4000-8000-000000000002/attach-to-ticket \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"ticket_id":"dddddddd-0000-4000-8000-000000000001"}'

Después de eso, GET /tickets/{id}/conversations lista las dos. GET /tickets/{id}/messages las entrelaza en una sola línea de tiempo, con cada mensaje marcado con el canal por el que llegó. Ahí van también las líneas de sistema de las asignaciones y los cambios de estado. Es una vista para leer; los campos de entrega y el autor de cada mensaje están en GET /conversations/{id}/messages.

detach-from-ticket deshace el vínculo y deja el ticket en pie.

El tablero

GET /tickets/stage/{stageId} devuelve las tarjetas de una columna, paginadas: así se lee un tablero, una llamada por columna, con las columnas saliendo de GET /pipelines/{id}.

PUT /tickets/{id}/stage mueve la tarjeta. El movimiento se rechaza con 400 si allowed_transitions_to de la columna actual no nombra el destino, o si el destino es de otro tablero.

GET /tickets/status-counts y /today-counts dan las cifras por estado.

{
  "data": {
    "total": 5,
    "open": 1,
    "pending": 0,
    "snoozed": 0,
    "resolved": 0,
    "closed": 4,
    "closing": 0,
    "human": 1,
    "marketing": 0
  }
}

closing no es un estado que un ticket tenga: cuenta los resueltos cuyo cierre automático está corriendo.

Dos cosas que sorprenden

PUT /tickets/{id}/close resuelve, no cierra. Deja status en resolved, pasa el manejo a human y sella resolved_by / resolved_at. El estado terminal llega con el cierre automático un rato después, o con PUT /tickets/{id}/status mandando closed. Es también la operación que dispara ticket.resolved. PUT /tickets/{id}/status con resolved cambia la columna sin sellar la atribución, y no emite el evento.

Toda escritura de ticket responde un array de un elemento, { "data": [ticket] }, como el de más arriba. La lectura (GET /tickets/{id}) responde el objeto.

Etiquetar y anotar

Etiquetas (tags) son el vocabulario suelto: cualquiera con tags:write crea una, y adjuntar por nombre la crea la primera vez. El nombre se convierte en slug, así que «Garantía», «garantia» y «Garantía » son la misma.

curl -X POST https://api.vitrinadev.com/api/v1/conversations/bbbbbbbb-0000-4000-8000-000000000001/tags \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"name":"Garantía"}'

Se piden con tags:read / tags:write, nunca con los de conversación. Una credencial que lee el hilo pero no la taxonomía recibe 403 aquí y sigue leyendo la conversación. Desprender una etiqueta borra el vínculo, nunca la etiqueta.

Atributos personalizados guardan los datos que tu operación necesita y Vitrina no trae. Primero la definición, una vez:

curl -X POST https://api.vitrinadev.com/api/v1/custom-attributes \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"entity_type":"conversation","key":"numero_de_caso","label":"Número de caso","data_type":"text"}'

Después los valores, con PUT /conversations/{id}/attributes, que actualiza sin reemplazar: las claves que mandas se escriben y el resto queda como estaba. Borrar un valor es DELETE /conversations/{id}/attributes/{key}. Mandar null guarda un null, que es otra cosa.

key y entity_type de una definición no se pueden editar. Borrar una definición no borra los valores salvo que pidas ?purge_values=true.

Notas (POST /conversations/{id}/notes) son internas y nunca llegan al cliente. Las @menciones en el cuerpo avisan a quien nombras; editar una nota no vuelve a leerlas.

Los eventos

Cinco, además de los cuatro de ticket:

EventoCuándo
conversation.createdSe abrió un hilo: el cliente escribió, o lo abrió el workspace
conversation.assignedCambió de manos: responsable, handler o ambos
conversation.resolvedDejó de ser trabajo abierto
message.receivedEl cliente mandó un mensaje
message.sentEl workspace le mandó uno

Una importación histórica no dispara nada, así que sincronizar meses de conversaciones pasadas no genera eventos.

conversation.assigned es un evento para dos cambios, porque nunca ocurre uno sin el otro. Pasarle un hilo a una persona también mueve el manejo a human, y devolverlo a la IA limpia al responsable. El sobre los lleva juntos:

{
  "type": "conversation.assigned",
  "version": 1,
  "resource": {
    "type": "conversation",
    "id": "bbbbbbbb-0000-4000-8000-000000000001",
    "url": "https://api.vitrinadev.com/api/v1/conversations/bbbbbbbb-0000-4000-8000-000000000001"
  },
  "changes": {
    "assignee": { "from": null, "to": "11111111-0000-4000-8000-000000000001" },
    "handler": { "from": "bot", "to": "human" }
  },
  "author": {
    "kind": "member",
    "id": "11111111-0000-4000-8000-000000000001",
    "name": "Camila Rojas",
    "via": { "kind": "connected_app", "name": "Claude" }
  },
  "data_omitted": "not_requested"
}

changes se indexa por lo que cambió (assignee, handler, status) y nunca por la columna que lo guarda.

conversation.resolved sigue la misma regla en la otra dirección: se dispara al resolver, y al cerrar solo si el hilo no estaba ya resuelto. El cierre automático de algo ya resuelto no dispara nada: es la ventana de gracia terminándose.

message.sent se dispara cuando el mensaje se guarda. En la mayoría de los canales ese es el momento en que Vitrina se compromete con el envío, y no aquel en que el proveedor lo confirma. delivery_status lleva lo que se sabía entonces. Un fallo posterior o un acuse de entrega se ven en la fila del mensaje, nunca como un segundo evento.

Como todos los eventos, viajan con el aviso y sin los datos salvo que la suscripción los pida y su dueño tenga permiso para leerlos. El capítulo de webhooks explica el sobre y la firma.

Lo que no está publicado

El panel compuesto de la bandeja, el indicador de escritura y el control del agente de IA sobre un hilo no son parte de la API pública. Cada sección del panel tiene su propio endpoint publicado.

Las etiquetas de ticket (/labels) tampoco están publicadas todavía.

En esta página