Mover leads por el embudo
La oportunidad que el workspace trabaja y el tablero por el que avanza.
Alguien del equipo trabaja una oportunidad concreta hasta cerrarla, y esa oportunidad es un lead. Tiene un contacto, un valor, un responsable, y vive en una etapa de un embudo. Sirve igual para un paciente que consulta por un tratamiento, para quien pregunta por un auto o para una empresa que pide una cotización.
Un lead no es una conversación. La persona vuelve a escribir la semana siguiente por otro canal y sigue siendo la misma oportunidad. Por eso un lead abarca varias conversaciones, nunca una sola.
Leer pide leads:read; escribir, leads:write. Los embudos y las etapas tienen los suyos: pipelines:read / pipelines:write y stages:read / stages:write.
El tablero primero
Un lead necesita un embudo de tipo sales donde vivir. El workspace ya trae uno, y ?include=counts agrega cuántas tarjetas hay en cada tablero:
curl "https://api.vitrinadev.com/api/v1/pipelines?include=counts" \
-H "Authorization: Bearer $VITRINA_KEY"{
"data": [
{
"id": "44444444-0000-4000-8000-000000000001",
"tenant_id": "00000000-0000-4000-8000-000000000001",
"name": "Ventas",
"description": "Sigue una oportunidad comercial desde la primera consulta concreta hasta un cierre ganado, perdido o no calificado.",
"slug": "generic-sales",
"kind": "sales",
"is_fallback": true,
"template_key": "generic_sales",
"template_version": 1,
"template_locale": "es",
"card_count": 13,
"created_at": "2026-09-04T19:07:05.226Z",
"updated_at": "2026-09-04T19:07:05.226Z"
}
],
"meta": { "total": 1 }
}Tres cosas que conviene leer bien:
kind decide qué tarjetas acepta el tablero. sales lleva leads y ticket lleva tickets; el tercer valor, vehicle, es propio de un rubro y no aparece en el resto de este capítulo. Un lead solo puede vivir en un tablero sales; nombrar otro es un 400.
is_fallback marca el tablero por defecto de ese tipo. Ahí cae una tarjeta que nadie ruteó, y ahí se mueven las tarjetas cuando se borra otro tablero del mismo tipo. Hay exactamente uno por tipo. Poner is_fallback: true en otro lo promueve y degrada al actual; mandar false no hace nada, porque nunca puede quedar el workspace sin uno.
card_count es el trabajo en curso y nunca un total histórico: leads abiertos en un tablero de ventas, tickets sin resolver ni cerrar en uno de soporte. Un embudo que cerró todo lee 0. Sin el parámetro, la respuesta no trae card_count.
Las columnas
curl https://api.vitrinadev.com/api/v1/pipelines/44444444-0000-4000-8000-000000000001 \
-H "Authorization: Bearer $VITRINA_KEY"La respuesta trae el tablero con sus columnas anidadas en stage[], en orden de position. Cada una:
{
"id": "55555555-0000-4000-8000-000000000002",
"tenant_id": "00000000-0000-4000-8000-000000000001",
"pipeline_id": "44444444-0000-4000-8000-000000000001",
"name": "Contactado",
"description": "Alguien del equipo hizo contacto real y está entendiendo la necesidad.",
"slug": "contacted",
"position": 1,
"category": "open",
"is_terminal": false,
"won_state": null,
"sla_days": 3,
"allowed_transitions_to": [
"55555555-0000-4000-8000-000000000003",
"55555555-0000-4000-8000-000000000006",
"55555555-0000-4000-8000-000000000007"
],
"ai_agent_id": null,
"template_stage_key": "contacted",
"created_at": "2026-09-04T19:07:05.226Z"
}category es el vocabulario del que depende la lógica: open, won, lost, unqualified y, en tableros de tickets, resolved. No ramifiques por el nombre de la columna: «Ganado», «Cerrado feliz» y «Entregado» son decisiones de cada workspace, category: "won" no.
is_terminal y won_state se derivan de category y no se aceptan como entrada: mandarlos es un 400 que nombra la clave.
sla_days es el umbral de envejecimiento en días: un entero desde 1, o null para «sin límite». Omitir la clave deja el valor como está; mandar null lo borra.
allowed_transitions_to es el grafo: a qué columnas puede saltar una tarjeta desde aquí. Vacío significa «sin restricción». Un salto que el grafo no permite se rechaza al mover la tarjeta y nunca al guardar la columna.
Armar un tablero de una vez
Escribir las columnas a mano funciona (POST /stages, o POST /stages/bulk para hasta 100 de un viaje), pero la plataforma trae plantillas versionadas y localizadas:
curl "https://api.vitrinadev.com/api/v1/pipeline-templates?locale=es" \
-H "Authorization: Bearer $VITRINA_KEY"{
"data": [
{
"key": "generic_sales",
"version": 1,
"kind": "sales",
"vertical": "generic",
"name": "Ventas",
"description": "Pipeline de ventas general: sigue una oportunidad comercial desde la primera consulta concreta hasta un cierre ganado, perdido o no calificado.",
"stage_count": 9,
"locale": "es",
"locales_available": ["en", "es"]
}
],
"meta": { "total": 6 }
}vertical filtra el catálogo: sin el parámetro salen todas, con ?vertical=generic solo las que sirven en cualquier workspace. Cada rubro trae además las suyas, y las verás listadas en su propio grupo de esta documentación.
vertical acepta generic, automotive y healthcare. GET /pipeline-templates/{key}/preview devuelve exactamente el tablero que se crearía, sin escribir nada. POST /pipeline-templates/{key}/apply lo materializa en filas propias del workspace, editables como cualquier otra. Aplicar es idempotente por plantilla: la segunda llamada devuelve el tablero que ya existe con already_applied: true y no cambia nada.
Abrir un lead
curl -X POST https://api.vitrinadev.com/api/v1/leads \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8f1c4d2e-5a6b-4c7d-8e9f-0a1b2c3d4e5f" \
-d '{
"contact_id": "22222222-0000-4000-8000-000000000001",
"pipeline_id": "44444444-0000-4000-8000-000000000001",
"title": "María — cotización pedida por WhatsApp",
"source": "manual",
"intent": "buy",
"value_amount": 8990000,
"value_currency": "CLP",
"expected_close_at": "2026-10-15T00:00:00.000Z",
"owner_user_id": "11111111-0000-4000-8000-000000000001"
}'{
"data": {
"id": "99999999-0000-4000-8000-000000000001",
"display_id": "L-89",
"title": "María — cotización pedida por WhatsApp",
"status": "open",
"source": "manual",
"intent": "buy",
"contact_id": "22222222-0000-4000-8000-000000000001",
"pipeline_id": "44444444-0000-4000-8000-000000000001",
"stage_id": "55555555-0000-4000-8000-000000000001",
"owner_user_id": "11111111-0000-4000-8000-000000000001",
"team_id": null,
"value_amount": 8990000,
"value_currency": "CLP",
"expected_close_at": "2026-10-15T00:00:00.000Z",
"score": null,
"temperature": null,
"closed_at": null,
"won_lost_reason": null,
"contact": {
"id": "22222222-0000-4000-8000-000000000001",
"name": "María González",
"email": "[email protected]",
"phone": "+56912345001"
},
"stage": {
"id": "55555555-0000-4000-8000-000000000001",
"name": "Nuevo",
"slug": "new",
"position": 0,
"won_state": null,
"pipeline_id": "44444444-0000-4000-8000-000000000001"
},
"pipeline": {
"id": "44444444-0000-4000-8000-000000000001",
"kind": "sales",
"name": "Ventas"
}
}
}pipeline_id es opcional: sin él el lead cae en el tablero sales por defecto. stage_id también: cae en la primera columna.
source dice de dónde llegó (conversation, website, marketplace, manual, import, …) e intent dice a qué viene (buy, sell, financing, trade_in; por defecto buy). Los dos se fijan al crear y no están en el cuerpo de actualización.
Si mandas team_id y no owner_user_id, la rotación del equipo elige responsable.
Idempotency-Key está disponible en todos los POST publicados: reintentar con la misma llave devuelve la misma respuesta en vez de abrir un segundo lead. Está descrito en Errores.
id es un uuid; L-89 es una etiqueta
display_id es el ID visible: corto, legible, el que una persona dice en voz alta. Los endpoints que reciben {id} lo aceptan como atajo (GET /leads/L-89 funciona), y ahí termina su trabajo.
Nada lo guarda como referencia. Dentro de un cuerpo, de una respuesta o de un evento, un campo que apunta a otro registro (contact_id, pipeline_id, stage_id, owner_user_id) es siempre un uuid. Mandar L-89 donde va un uuid es un 400 que nombra el campo.
Desde una conversación
Cuando la oportunidad nace de un hilo que ya existe, POST /leads/from-conversation resuelve el contacto desde la conversación y la deja como origen del lead. Hereda el responsable del hilo si nadie nombra otro:
curl -X POST https://api.vitrinadev.com/api/v1/leads/from-conversation \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{ "conversation_id": "bbbbbbbb-0000-4000-8000-000000000001" }'Una lista completa
POST /leads/import acepta hasta 2000 filas y busca el contacto por correo o teléfono. El éxito parcial es el resultado normal y el status es 200 igual: el reporte dice fila por fila qué pasó.
{
"data": {
"total": 2,
"inserted": 1,
"failed": 1,
"rows": [
{ "row": 1, "ok": true, "lead_id": "99999999-0000-4000-8000-000000000001" },
{ "row": 2, "ok": false, "error": "No contact matched +56988887777" }
]
}
}Mover la tarjeta
curl -X PUT https://api.vitrinadev.com/api/v1/leads/L-89/stage \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"stage_id": "55555555-0000-4000-8000-000000000002",
"reason": "la llamamos y confirmó la hora"
}'La columna destino tiene que pertenecer al mismo tablero y ser alcanzable desde la actual. Si el grafo no lo permite, es un 400 y no se escribe nada.
Caer en una columna terminal cierra el lead: status, closed_at y won_lost_reason se derivan de la category de la columna donde aterrizó. Arrastrar la tarjeta a «Ganado» y llamar a PUT /leads/{id}/won hacen exactamente lo mismo.
Los tres desenlaces
| Endpoint | Qué significa | Evento |
|---|---|---|
PUT /leads/{id}/won | La oportunidad cerró a favor | lead.won |
PUT /leads/{id}/lost | Había oportunidad y no cerró | lead.lost |
PUT /leads/{id}/unqualify | Nunca hubo oportunidad | lead.unqualified |
unqualified no es un lost suave. Los leads no calificados quedan fuera de la tasa de cierre por los dos lados, así que descartar temprano no cuenta como perder.
Cada uno mueve el lead a la columna de esa categoría con la position más baja del tablero. Si el tablero no tiene ninguna, es un 400: agrega esa columna antes de reintentar.
/won es el único que hace algo con value_amount: ahí el número es el precio real de cierre, distinto de cualquier estimación anterior, y queda sellado con value_amount_close_confirmed_at. Omitirlo deja la estimación intacta.
PUT /leads/{id}/reopen devuelve el lead a la primera columna abierta y limpia closed_at. No borra el cierre: el registro de actividad lo conserva, así que un lead reabierto y vuelto a ganar se lee como los dos hechos que fue.
Cambiar de tablero
PUT /leads/{id}/pipeline reclasifica la oportunidad en otro tablero de ventas y la deja en su primera columna, volviendo a derivar el ciclo de vida de ahí. Avisa con lead.pipeline_changed, que lleva los dos cambios: el tablero y la columna.
Qué pide el lead
Un lead es la oportunidad; un interés es la cosa que se está pidiendo, y puede haber varias:
curl -X POST https://api.vitrinadev.com/api/v1/leads/L-89/interests \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"resource_type": "service",
"resource_id": "e1e1e1e1-0000-4000-8000-000000000001",
"quantity": 1,
"priority": "high",
"notes": "prefiere en la tarde"
}'resource_type es vehicle, property, product, service, repair_order o custom. resource_id apunta al registro cuando existe en Vitrina. Cuando no existe, title lo dice en palabras. Hay que mandar uno de los dos.
Las conversaciones que abarca una oportunidad
curl https://api.vitrinadev.com/api/v1/leads/L-89/conversations \
-H "Authorization: Bearer $VITRINA_KEY"{
"data": [
{
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"display_id": "C-122",
"channel": "whatsapp",
"status": "open",
"is_primary": true,
"is_origin": true,
"linked_at": "2026-09-18T13:44:02.115Z",
"linked_by_type": "system",
"last_message_date": "2026-09-21T14:01:58.000Z"
}
],
"meta": { "total": 1 }
}is_origin marca dónde empezó la oportunidad, un hecho histórico que no se mueve. is_primary marca el hilo por el que se trabaja ahora, que sí cambia. POST sobre el mismo par es idempotente: responde linked: false y no escribe una segunda línea en el registro.
Leer el embudo
Cuatro lecturas agregadas, todas opcionalmente acotadas a un tablero:
GET /leads/summary: cuántos abiertos, ganados, perdidos y no calificados, y cuánto valen.GET /leads/funnel: una fila por columna, con cuántos leads abiertos hay, cuánto valen y la mediana de horas que llevan ahí. Ese último número dice dónde se está atascando el embudo.GET /leads/kanban: el tablero con sus tarjetas ya anidadas, hasta 200 por columna.GET /leads/win-rate/{dimension}:won / (won + lost)agrupado porowner_user_id,team_idosource.
Los totales de dinero vienen desglosados por moneda:
{
"data": {
"open": 13,
"won": 2,
"lost": 1,
"unqualified": 0,
"total_value_open": 194077750,
"total_value_open_by_currency": { "CLP": 193990000, "USD": 42750 },
"is_mixed_currency_open": true
}
}is_mixed_currency_open avisa que el total plano suma montos en monedas distintas. Un workspace que trabaja en una sola moneda puede ignorarlo; uno que no, tiene que leer el desglose.
Dos «temperaturas» que no son la misma
?temperature= en GET /leads es un atajo sobre score: cold 0–39, warm 40–69, hot 70–100, unscored sin puntaje. min_score y max_score explícitos le ganan.
El campo temperature del lead es otra cosa: un juicio que alguien registró sobre si la oportunidad se está enfriando, con su temperature_reason y su temperature_at. No se deriva del puntaje, y que discrepen es información y nunca un error. En PUT /leads/{id} omitir la clave lo deja quieto, y mandar null retira el juicio, que es como se deshace un veredicto equivocado.
Quién vio qué
leads:read no es contacts:read. Los bloques contact, stage, pipeline y solicitud que vienen embebidos en un lead se recortan para quien no tiene el scope de lectura de ese bloque; los contact_id / stage_id / pipeline_id sueltos siempre están.
Y si el rol de la persona restringe la visibilidad de registros, la lista, el tablero y las lecturas agregadas muestran solo sus leads. No es un filtro que el cliente pueda quitar: un lead ajeno responde 404, no 403.
Los eventos
Cada cambio avisa por webhook. Los siete que publica este dominio:
| Evento | Cuándo |
|---|---|
lead.created | Se abrió una oportunidad, por cualquier vía |
lead.stage_changed | La tarjeta cambió de columna |
lead.pipeline_changed | La tarjeta cambió de tablero |
lead.assigned | Cambió el responsable o el equipo |
lead.won | Cerró a favor |
lead.lost | No cerró |
lead.unqualified | Nunca hubo oportunidad |
Todo movimiento de columna dispara lead.stage_changed: el PUT, el arrastre en el tablero, la herramienta de IA, la acción rápida que cierra el lead. Un receptor que quiera seguir el avance necesita ese evento y ninguno más. Hay dos excepciones. Un movimiento nulo, cuando la tarjeta ya estaba en esa columna, no dispara nada; y el cambio de tablero ya lo reporta lead.pipeline_changed.
El sobre lleva changes con el par from → to:
{
"id": "bc999b31-075f-4ae4-b789-b4000f557ff1",
"type": "lead.stage_changed",
"version": 1,
"created_at": "2026-09-22T08:39:25.338Z",
"tenant_id": "00000000-0000-4000-8000-000000000001",
"resource": {
"type": "lead",
"id": "99999999-0000-4000-8000-000000000001",
"url": "https://api.vitrinadev.com/api/v1/leads/99999999-0000-4000-8000-000000000001"
},
"changes": {
"stage": {
"from": "55555555-0000-4000-8000-000000000001",
"to": "55555555-0000-4000-8000-000000000002"
}
},
"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ó (stage, pipeline) y nunca por la columna que lo guarda: cada entrada ya es un par de identificadores. Viaja siempre, incluso cuando el sobre no lleva data, así que nunca contiene un dato personal.
El autor
author dice quién hizo el cambio. 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 en su lugar.
Una API key firma como ella misma ("kind": "api_key"), con el nombre que tenía en ese momento. Renombrarla o revocarla no reescribe la historia. El agente de IA es ai_agent y la plataforma actuando sola es system.
La misma autoría queda en el registro de actividad del lead, que se lee entero con GET /leads/{id}/activity y devuelve las líneas de la más nueva a la más vieja.
Borrar
DELETE /leads/{id} elimina la oportunidad y su historia; el contacto queda intacto. Es para un lead que nunca debió abrirse: un duplicado, una prueba. Uno que de verdad terminó va a /lost o a /unqualify, que conservan el registro y el motivo.
Borrar una columna o un tablero nunca borra sus tarjetas: se mueven primero, a lo que indique ?reassign_to=, o a la columna hermana / al tablero por defecto del tipo. La respuesta dice cuántas se movieron y a dónde. El tablero por defecto de un tipo no se puede borrar: promueve otro antes.