Seguimientos
Recetas de seguimiento, las Búsquedas de cada cliente, la demanda, Recontacto y su cola de revisión.
Descarga la proyección completa de la API pública: openapi.json.
| Método | Ruta | Qué hace |
|---|---|---|
GET | /followups | List follow-up recipes |
POST | /followups | Create a follow-up recipe |
DELETE | /followups/{id} | Delete a follow-up recipe |
GET | /followups/{id} | Get one follow-up recipe |
PUT | /followups/{id} | Edit a follow-up recipe |
POST | /followups/{id}/active | Activate or deactivate a recipe |
GET | /followups/{id}/waiting | Who this recipe is still to tell |
GET | /followups/{id}/waiting-count | How many subscriptions are waiting on this recipe |
GET | /followups/attention | What needs my attention in «Escribir primero» |
GET | /followups/busqueda-tolerance | The Búsqueda's soft-bound tolerance (partial match) |
PUT | /followups/busqueda-tolerance | Set the Búsqueda's soft-bound tolerance |
GET | /followups/busquedas | Open wants, grouped by what is sought |
GET | /followups/busquedas/history | The «Historial» tab — fulfilled + expired Búsquedas |
GET | /followups/channels | Which channels follow-ups may use |
PUT | /followups/channels | Toggle the channels follow-ups may use |
GET | /followups/demand | List unmet demand groups |
POST | /followups/demand | Log an unmet demand |
PATCH | /followups/demand/{id} | Triage one demand row |
POST | /followups/demand/{id}/cover | Cover a demand group with Vitrina |
GET | /followups/demand/topics | List unmet demand by topic |
PATCH | /followups/demand/topics/{kind} | Dismiss every open demand of a topic |
POST | /followups/demand/topics/{kind}/cover | Cover a whole demand topic with Vitrina |
GET | /followups/lead-review | Get lead review config and the score calibration curve |
PUT | /followups/lead-review | Update lead review configuration |
GET | /followups/overview | Recipes plus channel readiness for the settings screen |
GET | /followups/pause | Safe pause: live pauses and who changed them |
POST | /followups/pause | Pause what Vitrina writes first (all, or one sequence) |
GET | /followups/recontacto | Get the Recontacto cadence configuration |
PUT | /followups/recontacto | Update the Recontacto cadence configuration |
GET | /followups/recontacto/attempts | List Recontacto attempts (ledger and review queue) |
POST | /followups/recontacto/attempts/{id}/approve | Approve a Recontacto draft and send it |
POST | /followups/recontacto/attempts/{id}/dismiss | Dismiss a Recontacto draft |
GET | /followups/recontacto/guards | Rules every follow-up respects, and today’s non-sends |
GET | /followups/recontacto/held | The people behind one «esperando (por qué)» line |
GET | /followups/recontacto/next-steps | What happens next for each of a contact’s leads |
GET | /followups/recontacto/plan | What a customer who stops answering receives (simulated) |
GET | /followups/recontacto/plan/after-handoff | The «Ahora» block of the plan, on its own |
GET | /followups/recontacto/preview | Preview today’s Recontacto sweep (dry run) |
GET | /followups/recontacto/readiness | Can Recontacto actually send? |
POST | /followups/recontacto/run | Run the Recontacto sweep now |
GET | /followups/recontacto/stats | Recontacto results by situation, rung and channel |
POST | /followups/resume | Resume a paused scope |
GET | /followups/subscriptions | List a contact’s follow-up subscriptions |
POST | /followups/subscriptions | Subscribe a contact to a follow-up |
DELETE | /followups/subscriptions/{id} | Cancel one waiting subscription |
GET | /followups/subscriptions/{id} | One Búsqueda, in full (ADR 0109 §4) |
POST | /followups/subscriptions/{id}/cancel | Cancel a Búsqueda with a reason |
POST | /followups/subscriptions/{id}/fulfill | Mark a Búsqueda fulfilled (the customer got a car) |
POST | /followups/subscriptions/{id}/let-expire | Decline to renew a Búsqueda — «Dejar vencer» |
POST | /followups/subscriptions/{id}/renew | Renew a Búsqueda — «Renovar 90 días» |
GET | /followups/template-requests | List open WhatsApp template requests |
PATCH | /followups/template-requests/{id} | Dismiss a template request or mark it created |
GET | /followups/write-first | What Vitrina writes first, one sentence per moment |
GET /followups
List follow-up recipes
Each row carries two figures and its origin. waiting_count counts WANTS — subscriptions still open, which for a stock or price recipe means customers still waiting, since a Búsqueda stays open across notices (ADR 0109). kept_count counts NOTICES — a timer promise is kept once, when it fires; a Búsqueda is kept once per car it told the customer about. origin: platform marks a recipe Vitrina created for the workspace (the per-vertical defaults, and the one “Cubrir con Vitrina” mints), user one somebody here wrote. Both are editable and deletable.
curl https://api.vitrinadev.com/api/v1/followups \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"id": "7a7a7a7a-0000-4000-8000-000000000010",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"name": "Avisar cuando llegue el auto que busca",
"description": "Ofrécelo cuando el cliente pide un auto que no está en stock. Pregunta marca, modelo y presupuesto antes de suscribirlo.",
"trigger_key": "vehicle_in_stock",
"active": true,
"origin": "platform",
"created_at": "2026-10-01T14:00:00.000Z",
"updated_at": "2026-10-01T14:00:00.000Z",
"waiting_count": 3,
"kept_count": 5
},
{
"id": "7a7a7a7a-0000-4000-8000-000000000011",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"name": "Retomar la cotización en dos días",
"description": "Ofrécelo cuando el cliente pide tiempo para decidir. Fija la hora a la que quiere que lo retomen.",
"trigger_key": "after_delay",
"active": false,
"origin": "user",
"created_at": "2026-10-03T10:00:00.000Z",
"updated_at": "2026-10-03T10:00:00.000Z",
"waiting_count": 0,
"kept_count": 0
}
]
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /followups
Create a follow-up recipe
Creating a recipe schedules nothing on its own — it defines what should happen when trigger_key fires. Contacts start receiving it once they have subscriptions.
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
name | string | sí | mín. 1, máx. 120 |
description | string | sí | mín. 1, máx. 2000 |
trigger_key | vehicle_in_stock \ | vehicle_price_drop \ | appointment_slot_available \ |
active | boolean | — |
curl -X POST https://api.vitrinadev.com/api/v1/followups \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Retomar la cotización en dos días",
"description": "Ofrécelo cuando el cliente pide tiempo para decidir. Fija la hora a la que quiere que lo retomen.",
"trigger_key": "after_delay",
"active": true
}'Ejemplo de respuesta (201)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-000000000011",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"name": "Retomar la cotización en dos días",
"description": "Ofrécelo cuando el cliente pide tiempo para decidir. Fija la hora a la que quiere que lo retomen.",
"trigger_key": "after_delay",
"active": true,
"origin": "user",
"created_at": "2026-10-10T14:00:00.000Z",
"updated_at": "2026-10-10T14:00:00.000Z"
}
}Responde: 201 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /followups/{id}
Delete a follow-up recipe
Requires the recipe to be inactive — deleting an active one is a 409, so the subscription cancellation is always a deliberate separate step.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl -X DELETE https://api.vitrinadev.com/api/v1/followups/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Responde: 204 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/{id}
Get one follow-up recipe
The recipe row itself: name, the agent-facing description, trigger_key, active and origin. It carries no counters — use GET /followups for waiting_count / kept_count and GET /followups/\{id\}/waiting-count for the open subscriptions. 404 when the id is not a recipe of this workspace.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl https://api.vitrinadev.com/api/v1/followups/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-000000000010",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"name": "Avisar cuando llegue el auto que busca",
"description": "Ofrécelo cuando el cliente pide un auto que no está en stock. Pregunta marca, modelo y presupuesto antes de suscribirlo.",
"trigger_key": "vehicle_in_stock",
"active": true,
"origin": "platform",
"created_at": "2026-10-01T14:00:00.000Z",
"updated_at": "2026-10-01T14:00:00.000Z"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
PUT /followups/{id}
Edit a follow-up recipe
Partial patch; an empty body is a 400. Editing does not touch subscriptions already waiting on it.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
name | string | — | mín. 1, máx. 120 |
description | string | — | mín. 1, máx. 2000 |
trigger_key | vehicle_in_stock \ | vehicle_price_drop \ | appointment_slot_available \ |
active | boolean | — |
curl -X PUT https://api.vitrinadev.com/api/v1/followups/<id> \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Avisar cuando llegue el auto",
"active": true
}'Ejemplo de respuesta (200)
{
"data": {
"followup": {
"id": "7a7a7a7a-0000-4000-8000-000000000010",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"name": "Avisar cuando llegue el auto",
"description": "Ofrécelo cuando el cliente pide un auto que no está en stock. Pregunta marca, modelo y presupuesto antes de suscribirlo.",
"trigger_key": "vehicle_in_stock",
"active": true,
"origin": "platform",
"created_at": "2026-10-01T14:00:00.000Z",
"updated_at": "2026-10-01T14:00:00.000Z"
},
"cancelled_subscriptions": 0
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /followups/{id}/active
Activate or deactivate a recipe
Deactivating cancels every open subscription and the response reports how many — this is not a pause, and reactivating does not bring them back. Check /waiting-count first.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
active | boolean | sí |
curl -X POST https://api.vitrinadev.com/api/v1/followups/<id>/active \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"active": false
}'Ejemplo de respuesta (200)
{
"data": {
"followup": {
"id": "7a7a7a7a-0000-4000-8000-000000000010",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"name": "Avisar cuando llegue el auto que busca",
"description": "Ofrécelo cuando el cliente pide un auto que no está en stock. Pregunta marca, modelo y presupuesto antes de suscribirlo.",
"trigger_key": "vehicle_in_stock",
"active": false,
"origin": "platform",
"created_at": "2026-10-01T14:00:00.000Z",
"updated_at": "2026-10-01T14:00:00.000Z"
},
"cancelled_subscriptions": 3
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/{id}/waiting
Who this recipe is still to tell
The people behind the recipe’s waiting_count («2 clientes por avisar»): its open subscriptions, the same ones the count counts, soonest aviso first and then the oldest want. Each person carries subscription_id, contact_id, contact_name, conversation_id and conversation_display_id (the thread to open), fire_at and fire_at_label («dom 25 oct · 09:00», the workspace’s timezone; null for a Búsqueda, which waits for the stock), created_at and expires_at. total is the full count; truncated is true when more than the 200 listed are waiting.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl https://api.vitrinadev.com/api/v1/followups/<id>/waiting \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"total": 2,
"truncated": false,
"people": [
{
"subscription_id": "6f1c2a40-1b7e-4c55-9a2e-3d0f8e1b2c11",
"contact_id": "0b3e9d7a-5c21-4f8e-8a14-77c2d9e4f0a3",
"contact_name": "Camila Rojas",
"conversation_id": "9a7d3c10-2e4b-4f61-b8c5-1e0f2d3a4b5c",
"conversation_display_id": "C-312",
"fire_at": "2026-10-26T12:00:00.000Z",
"fire_at_label": "lun 26 oct · 09:00",
"created_at": "2026-10-22T15:04:00.000Z",
"expires_at": "2026-11-21T15:04:00.000Z"
}
]
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/{id}/waiting-count
How many subscriptions are waiting on this recipe
Exists so the deactivate/delete confirmation can be honest about what it is about to cancel — read it before the destructive call.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl https://api.vitrinadev.com/api/v1/followups/<id>/waiting-count \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"waiting": 3
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/attention
What needs my attention in «Escribir primero»
One read for the «Escribir primero» attention strip. pending.count is the single «esperan tu OK» number: proactive messages waiting for approval, merged across every rail that queues one (by_source splits it by rail). It is computed with the same predicate as GET /outbound/approvals, so it always equals that list’s total. ai_review_limit is today’s AI reviews (used of total) and resumes_at (ISO) once it is spent, else null. channel_problems lists disconnected channels that stop sends with the Avisos waiting on each. items are other things needing a person (templates). quiet holds the calm numbers: sent_today, in_queue (count and when the next one may go) and not_sent (count plus reasons). Every entry that leads somewhere carries action_target, a typed destination (record | list | page) the UI routes. Copy for reasons is in lang (default the workspace’s).
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
lang | query | es \ | en | no |
curl https://api.vitrinadev.com/api/v1/followups/attention \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"pending": {
"count": 5,
"by_source": [
{
"source": "recontacto",
"count": 4
},
{
"source": "service_lifecycle",
"count": 1
}
],
"action_target": {
"kind": "list",
"screen": "conversations",
"filter": {
"view": "inbox-approvals"
}
}
},
"ai_review_limit": {
"used": 200,
"total": 200,
"resumes_at": "2026-11-03T09:00:00-03:00"
},
"channel_problems": [
{
"kind": "email_not_connected",
"channel": "email",
"count": 3,
"action_target": {
"kind": "page",
"page": "settings.channels"
}
}
],
"items": [
{
"kind": "waiting_template",
"count": 2,
"action_target": {
"kind": "page",
"page": "settings.templates"
}
}
],
"quiet": {
"sent_today": 12,
"in_queue": {
"count": 7,
"until": "2026-11-03T09:00:00-03:00"
},
"not_sent": {
"count": 9,
"reasons": [
{
"reason": "Habló con un compañero hace poco",
"count": 9
}
]
}
}
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/busqueda-tolerance
The Búsqueda's soft-bound tolerance (partial match)
How far over the customer’s budget or kilometres a car may be and still notify them as a partial match (ADR 0109 §6), as the PERCENTAGES a settings form reads — budget_pct/km_pct, always present (defaulted to the platform’s 10 % / 15 % when unset). Not a flag: there is no "off", a workspace posture only.
curl https://api.vitrinadev.com/api/v1/followups/busqueda-tolerance \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"budget_pct": 10,
"km_pct": 15
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
PUT /followups/busqueda-tolerance
Set the Búsqueda's soft-bound tolerance
Partial patch; at least one bound is required. Each of budget_pct and km_pct is independently validated 0..MAX_BUSQUEDA_TOLERANCE_PCT (50) — a value outside that range is a 400, so a fat-fingered workspace setting cannot silently stop grading Búsquedas the way a malformed one would.
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
budget_pct | number | — | ≥ 0, ≤ 50 |
km_pct | number | — | ≥ 0, ≤ 50 |
curl -X PUT https://api.vitrinadev.com/api/v1/followups/busqueda-tolerance \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"budget_pct": 12
}'Ejemplo de respuesta (200)
{
"data": {
"budget_pct": 12,
"km_pct": 15
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/busquedas
Open wants, grouped by what is sought
Tenant-wide (not visibility-gated — a shared demand worklist, the same divergence GET /vehicles/\{id\}/busquedas documents), including a want whose contact has no lead at all. Grouped by name + year bounds ("Porsche 911 · 2016 en adelante"), each group counting DISTINCT customers. seller_id filters on the resolved owner (the contact’s open lead’s owner, else the origin conversation’s assignee); expires_within keeps only wants whose clock is at most 7, 30 or 90 days out. notified_from + notified_to (ISO instants, both or neither) keep only búsquedas whose customer notice settled in that window — exactly what a daily Búsquedas summary counted: notice=sent reached the customer (a notified vehicle pair in the window), notice=failed could not be sent, omitted = either. status=any lists every status (fulfilled, expired, cancelled), not only open wants; each row carries its status. contact.name / contact.phone are further gated on contacts:read — absent, not null, without it.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
seller_id | query | uuid | no | |
expires_within | query | 7 \ | 30 \ | 90 |
notified_from | query | string | no | date-time |
notified_to | query | string | no | date-time |
notice | query | sent \ | failed | no |
status | query | any | no |
curl https://api.vitrinadev.com/api/v1/followups/busquedas \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"group_key": "mazda cx-5|2020|",
"sought_label": "Mazda CX-5 · 2020 en adelante",
"count": 1,
"rows": [
{
"subscription_id": "7a7a7a7a-0000-4000-8000-000000000020",
"status": "open",
"contact": {
"id": "7a7a7a7a-0000-4000-8000-000000000030",
"name": "María González",
"phone": "+56 9 1234 5678"
},
"lead_id": "7a7a7a7a-0000-4000-8000-000000000050",
"owner": {
"user_id": "7a7a7a7a-0000-4000-8000-000000000060",
"name": "Carlos Pérez",
"avatar_url": null
},
"note": null,
"expires_at": "2027-01-08T14:00:00.000Z",
"customer_deadline_at": null,
"days_left": 90,
"last_notice_at": "2026-10-12T15:00:00.000Z",
"last_notice_vehicle": {
"vehicle_id": "7a7a7a7a-0000-4000-8000-000000000070",
"make": "Mazda",
"model": "CX-5",
"year": 2021,
"registration_number": "KRTZ45"
}
}
]
}
],
"meta": {
"total": 1
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/busquedas/history
The «Historial» tab — fulfilled + expired Búsquedas
Never cancelled: a seller-withdrawn want is not buying intelligence the same way a fulfilled or an expired one is. Each fulfilled row names the car that answered it. contact.name / contact.phone are further gated on contacts:read — absent, not null, without it.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
seller_id | query | uuid | no | |
status | query | fulfilled \ | expired | no |
curl https://api.vitrinadev.com/api/v1/followups/busquedas/history \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"subscription_id": "7a7a7a7a-0000-4000-8000-000000000020",
"status": "fulfilled",
"contact": {
"id": "7a7a7a7a-0000-4000-8000-000000000030",
"name": "María González",
"phone": "+56 9 1234 5678"
},
"lead_id": "7a7a7a7a-0000-4000-8000-000000000050",
"owner": {
"user_id": "7a7a7a7a-0000-4000-8000-000000000060",
"name": "Carlos Pérez",
"avatar_url": null
},
"sought_label": "Mazda CX-5 · 2020 en adelante",
"closed_at": "2026-10-14T16:00:00.000Z",
"fulfilled_vehicle": {
"vehicle_id": "7a7a7a7a-0000-4000-8000-000000000070",
"make": "Mazda",
"model": "CX-5",
"year": 2021,
"registration_number": "KRTZ45"
},
"fulfilment_source": "manual"
}
],
"meta": {
"total": 1
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/channels
Which channels follow-ups may use
The workspace toggles merged over the defaults: one boolean per channel (whatsapp, email, sms, voice), saying whether follow-ups may use it. This is permission only — whether a channel is actually connected is read from GET /followups/overview. sms is always false: there is no SMS provider yet. Requires followups:read.
curl https://api.vitrinadev.com/api/v1/followups/channels \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"whatsapp": true,
"email": true,
"sms": false,
"voice": false
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
PUT /followups/channels
Toggle the channels follow-ups may use
Partial patch; at least one channel is required. Setting sms: true is a 400 — SMS has no provider, so accepting the toggle would promise delivery that cannot happen. The other three are toggleable.
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
whatsapp | boolean | — | |
email | boolean | — | |
sms | boolean | — | |
voice | boolean | — |
curl -X PUT https://api.vitrinadev.com/api/v1/followups/channels \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": false
}'Ejemplo de respuesta (200)
{
"data": {
"whatsapp": true,
"email": false,
"sms": false,
"voice": false
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/demand
List unmet demand groups
“Avisos sin cubrir” — one row per GROUP (kind + criteria), with the customers waiting behind it, a human title, and a cover block saying whether Vitrina can cover the group and whether a WhatsApp template is ready for it. request_count is the number of distinct customers.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
status | query | open \ | planned \ | resolved \ |
curl https://api.vitrinadev.com/api/v1/followups/demand \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"id": "7a7a7a7a-0000-4000-8000-000000000080",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"kind": "vehicle",
"status": "open",
"criteria": {
"make": "Mazda",
"model": "CX-5",
"year_min": 2020
},
"raw_ask": "Busca una Mazda CX-5 2020 o más nueva",
"normalized_key": "vehicle:mazda:cx-5:2020",
"suggested_trigger": "vehicle_in_stock",
"request_count": 1,
"topic_id": null,
"last_contact_id": "7a7a7a7a-0000-4000-8000-000000000030",
"last_conversation_id": "7a7a7a7a-0000-4000-8000-000000000040",
"first_seen": "2026-10-08T15:00:00.000Z",
"last_seen": "2026-10-09T18:30:00.000Z",
"contacts": [
{
"contact_id": "7a7a7a7a-0000-4000-8000-000000000030",
"name": "María González",
"phone": "+56 9 1234 5678",
"lead_id": "7a7a7a7a-0000-4000-8000-000000000050",
"lead_display_id": "L-89",
"conversation_id": "7a7a7a7a-0000-4000-8000-000000000040",
"first_seen": "2026-10-08T15:00:00.000Z"
}
],
"title": "Mazda CX-5 · 2020",
"cover": {
"kind": "followup_subscription",
"available": true,
"template": "ready"
}
}
]
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /followups/demand
Log an unmet demand
Recorded when someone describes an ask the business cannot satisfy, so the gap is sized rather than lost. The group is keyed off kind + criteria, which are validated against that kind’s strict schema; normalized_key is required for kind: other and ignored otherwise. No contact is attached on this path.
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
kind | vehicle \ | vehicle_release \ | slot \ |
criteria | object | sí | |
summary | string | sí | mín. 2, máx. 500 |
normalized_key | string | null | — | mín. 2, máx. 120 |
curl -X POST https://api.vitrinadev.com/api/v1/followups/demand \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"kind": "vehicle",
"criteria": {
"make": "Mazda",
"model": "CX-5",
"year_min": 2020
},
"summary": "Busca una Mazda CX-5 2020 o más nueva"
}'Ejemplo de respuesta (201)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-000000000080",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"kind": "vehicle",
"status": "open",
"criteria": {
"make": "Mazda",
"model": "CX-5",
"year_min": 2020
},
"raw_ask": "Busca una Mazda CX-5 2020 o más nueva",
"normalized_key": "vehicle:mazda:cx-5:2020",
"suggested_trigger": "vehicle_in_stock",
"request_count": 1,
"topic_id": null,
"last_contact_id": null,
"last_conversation_id": "7a7a7a7a-0000-4000-8000-000000000040",
"first_seen": "2026-10-08T15:00:00.000Z",
"last_seen": "2026-10-09T18:30:00.000Z",
"covered": false
}
}Responde: 201 · 400 · 401 · 403 · 404 · 409 · 429
PATCH /followups/demand/{id}
Triage one demand row
id is a demand row, not a followup.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
status | open \ | planned \ | resolved \ |
curl -X PATCH https://api.vitrinadev.com/api/v1/followups/demand/<id> \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"status": "dismissed"
}'Ejemplo de respuesta (200)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-000000000080",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"kind": "vehicle",
"status": "dismissed",
"criteria": {
"make": "Mazda",
"model": "CX-5",
"year_min": 2020
},
"raw_ask": "Busca una Mazda CX-5 2020 o más nueva",
"normalized_key": "vehicle:mazda:cx-5:2020",
"suggested_trigger": "vehicle_in_stock",
"request_count": 1,
"topic_id": null,
"last_contact_id": "7a7a7a7a-0000-4000-8000-000000000030",
"last_conversation_id": "7a7a7a7a-0000-4000-8000-000000000040",
"first_seen": "2026-10-08T15:00:00.000Z",
"last_seen": "2026-10-09T18:30:00.000Z"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /followups/demand/{id}/cover
Cover a demand group with Vitrina
Turns the group into real promises: a vehicle group subscribes every customer to the platform vehicle_in_stock follow-up (created once per workspace); a slot group adds each of them to the clinic waitlist. The demand moves to planned. 409 when the kind has no cover recipe or the demand is not open. The response reports who was covered and who was skipped, with a reason each.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl -X POST https://api.vitrinadev.com/api/v1/followups/demand/<id>/cover \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"demand": {
"id": "7a7a7a7a-0000-4000-8000-000000000080",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"kind": "vehicle",
"status": "planned",
"criteria": {
"make": "Mazda",
"model": "CX-5",
"year_min": 2020
},
"raw_ask": "Busca una Mazda CX-5 2020 o más nueva",
"normalized_key": "vehicle:mazda:cx-5:2020",
"suggested_trigger": "vehicle_in_stock",
"request_count": 1,
"topic_id": null,
"last_contact_id": "7a7a7a7a-0000-4000-8000-000000000030",
"last_conversation_id": "7a7a7a7a-0000-4000-8000-000000000040",
"first_seen": "2026-10-08T15:00:00.000Z",
"last_seen": "2026-10-09T18:30:00.000Z",
"contacts": [
{
"contact_id": "7a7a7a7a-0000-4000-8000-000000000030",
"name": "María González",
"phone": "+56 9 1234 5678",
"lead_id": "7a7a7a7a-0000-4000-8000-000000000050",
"lead_display_id": "L-89",
"conversation_id": "7a7a7a7a-0000-4000-8000-000000000040",
"first_seen": "2026-10-08T15:00:00.000Z"
}
]
},
"covered": 1,
"skipped": [],
"followup_id": "7a7a7a7a-0000-4000-8000-000000000010",
"template": {
"status": "ready",
"name": "aviso_auto_disponible"
}
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/demand/topics
List unmet demand by topic
The same demand, grouped by KIND — the level the workspace acts at. One row per kind with customers (DISTINCT contacts across its groups, so the same person waiting on two cars counts once), the groups under demands, and the group cover block. Sorted by customers desc. Only open and planned are topics; the other two statuses are triage history and are read through GET /followups/demand.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
status | query | open \ | planned | no |
curl https://api.vitrinadev.com/api/v1/followups/demand/topics \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"kind": "vehicle",
"status": "open",
"customers": 1,
"demands": [
{
"id": "7a7a7a7a-0000-4000-8000-000000000080",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"kind": "vehicle",
"status": "open",
"criteria": {
"make": "Mazda",
"model": "CX-5",
"year_min": 2020
},
"raw_ask": "Busca una Mazda CX-5 2020 o más nueva",
"normalized_key": "vehicle:mazda:cx-5:2020",
"suggested_trigger": "vehicle_in_stock",
"request_count": 1,
"topic_id": null,
"last_contact_id": "7a7a7a7a-0000-4000-8000-000000000030",
"last_conversation_id": "7a7a7a7a-0000-4000-8000-000000000040",
"first_seen": "2026-10-08T15:00:00.000Z",
"last_seen": "2026-10-09T18:30:00.000Z",
"contacts": [
{
"contact_id": "7a7a7a7a-0000-4000-8000-000000000030",
"name": "María González",
"phone": "+56 9 1234 5678",
"lead_id": "7a7a7a7a-0000-4000-8000-000000000050",
"lead_display_id": "L-89",
"conversation_id": "7a7a7a7a-0000-4000-8000-000000000040",
"first_seen": "2026-10-08T15:00:00.000Z"
}
],
"title": "Mazda CX-5 · 2020",
"cover": {
"kind": "followup_subscription",
"available": true,
"template": "ready"
}
}
],
"cover": {
"kind": "followup_subscription",
"available": true,
"template": "ready"
},
"last_seen": "2026-10-09T18:30:00.000Z"
}
]
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
PATCH /followups/demand/topics/{kind}
Dismiss every open demand of a topic
open rows only, in one statement — a topic already covered keeps its planned groups. dismissed is the only status a topic accepts, and a kind with nothing open answers \{updated: 0\} rather than 404.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
kind | path | vehicle \ | vehicle_release \ | slot \ |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
status | dismissed | sí |
curl -X PATCH https://api.vitrinadev.com/api/v1/followups/demand/topics/<id> \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"status": "dismissed"
}'Ejemplo de respuesta (200)
{
"data": {
"updated": 2
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /followups/demand/topics/{kind}/cover
Cover a whole demand topic with Vitrina
Covers EVERY open demand of the kind by running the per-group cover on each, so every customer keeps the criteria THEY asked about. 409 when the kind has no cover recipe or nothing of it is open. A group that fails does not abort the pass — it comes back in demands_left_open, while covered/skipped count customers across the whole topic.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
kind | path | vehicle \ | vehicle_release \ | slot \ |
curl -X POST https://api.vitrinadev.com/api/v1/followups/demand/topics/<id>/cover \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"kind": "vehicle",
"covered": 1,
"skipped": [],
"followup_id": "7a7a7a7a-0000-4000-8000-000000000010",
"template": {
"status": "ready",
"name": "aviso_auto_disponible"
},
"demands_covered": 1,
"demands_left_open": 0
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/lead-review
Get lead review config and the score calibration curve
The reviewer reads a lead once and returns three verdicts: the score that gates Recontacto’s expensive rungs, the engagement temperature that paces and orders them, and a stage opinion. The calibration curve is the falsification: of the leads scored 80-90, how many actually closed won.
curl https://api.vitrinadev.com/api/v1/followups/lead-review \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"config": {
"enabled": true,
"stage_autonomy": "auto",
"auto_confidence": 0.6,
"half_life_days": 30,
"instructions": ""
},
"calibration": [
{
"bucket": 3,
"n": 14,
"won_rate": 0.07
},
{
"bucket": 8,
"n": 11,
"won_rate": 0.64
}
]
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
PUT /followups/lead-review
Update lead review configuration
Partial patch; an empty body is a 400. Sets whether the reviewer runs (enabled), how far a stage verdict may go on its own (stage_autonomy: auto, suggest or off), the confidence floor for an automatic move (auto_confidence, 0..1), the days of silence after which a score halves (half_life_days) and the workspace’s own addendum (instructions, up to 4000 characters), and the Jev agreement doors (agreement_doors: stages, by slug, that Jev may move a lead forward into when a measured question such as visit_to_buy clears its threshold; replaces the whole list). Answers the merged config plus the calibration curve. Requires followups:write.
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
enabled | boolean | — | |
stage_autonomy | auto \ | suggest \ | off |
auto_confidence | number | — | ≥ 0, ≤ 1 |
instructions | string | — | máx. 4000 |
half_life_days | integer | — | ≥ 1, ≤ 365 |
agreement_doors | object[] | — |
curl -X PUT https://api.vitrinadev.com/api/v1/followups/lead-review \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"stage_autonomy": "suggest",
"half_life_days": 21
}'Ejemplo de respuesta (200)
{
"data": {
"config": {
"enabled": true,
"stage_autonomy": "suggest",
"auto_confidence": 0.6,
"half_life_days": 21,
"instructions": ""
},
"calibration": [
{
"bucket": 3,
"n": 14,
"won_rate": 0.07
},
{
"bucket": 8,
"n": 11,
"won_rate": 0.64
}
]
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/overview
Recipes plus channel readiness for the settings screen
The list joined with per-channel enabled and connected state. The two differ: a channel can be enabled for follow-ups while no messaging account can actually deliver on it, which is what makes a recipe silently do nothing.
Each recipe additionally carries origin (platform = created by Vitrina — a per-vertical default or the one “Cubrir con Vitrina” mints — user = written here) and template, what a closed-window WhatsApp delivery of THAT recipe would do today: ready · pending (a managed draft is with Meta) · gave_up (Meta refused it enough times; author one in Canales) · missing (none yet — Vitrina asks for one at the first fire) · not_needed (no WhatsApp account, so there is nothing to want). It is per TRIGGER, so two recipes on the same trigger always agree and two on different triggers need not.
curl https://api.vitrinadev.com/api/v1/followups/overview \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"followups": [
{
"id": "7a7a7a7a-0000-4000-8000-000000000010",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"name": "Avisar cuando llegue el auto que busca",
"description": "Ofrécelo cuando el cliente pide un auto que no está en stock. Pregunta marca, modelo y presupuesto antes de suscribirlo.",
"trigger_key": "vehicle_in_stock",
"active": true,
"origin": "platform",
"created_at": "2026-10-01T14:00:00.000Z",
"updated_at": "2026-10-01T14:00:00.000Z",
"waiting_count": 3,
"kept_count": 5,
"template": "ready"
}
],
"channels": {
"whatsapp": {
"enabled": true,
"connected": true
},
"email": {
"enabled": true,
"connected": true,
"sender": {
"status": "ready",
"account": {
"id": "7a7a7a7a-0000-4000-8000-0000000000d0",
"name": "Correo ventas",
"address": "[email protected]",
"provider": "gmail"
},
"candidates": [
{
"id": "7a7a7a7a-0000-4000-8000-0000000000d0",
"name": "Correo ventas",
"address": "[email protected]",
"provider": "gmail"
}
]
}
},
"sms": {
"enabled": false,
"connected": false
},
"voice": {
"enabled": false,
"connected": false
}
},
"whatsapp_template_ready": true,
"template_requests": [
{
"id": "7a7a7a7a-0000-4000-8000-000000000090",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"followup_id": "7a7a7a7a-0000-4000-8000-000000000010",
"status": "open",
"fallback_channel": "email",
"request_count": 4,
"first_seen": "2026-10-05T12:00:00.000Z",
"last_seen": "2026-10-09T17:00:00.000Z",
"followup_name": "Avisar cuando llegue el auto que busca"
}
]
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/pause
Safe pause: live pauses and who changed them
Escribir primero. pauses holds the live pause of each scope (all, builtin:al-llegar, builtin:si-no-responde, builtin:avisos, or a custom sequence id); history lists the latest changes, newest first, with who paused (pausedBy) and who or what resumed (resumedBy, resumeReason = manual or until). The workspace switch (recontacto.enabled) keeps working on its own.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
history_limit | query | integer | no | ≥ 1, ≤ 100 |
curl https://api.vitrinadev.com/api/v1/followups/pause \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"pauses": [
{
"id": "0198f0c0-0000-7000-8000-000000000001",
"scope": "builtin:si-no-responde",
"queuePolicy": "keep",
"pausedAt": "2026-11-02T14:00:00.000Z",
"pausedBy": {
"kind": "supabase_user",
"id": "7a7a7a7a-0000-4000-8000-000000000060",
"via": null
},
"until": "2026-11-09T12:00:00.000Z",
"resumedAt": null,
"resumedBy": null,
"resumeReason": null,
"discardedCount": 0
}
],
"history": [
{
"id": "0198f0c0-0000-7000-8000-000000000001",
"scope": "builtin:si-no-responde",
"queuePolicy": "keep",
"pausedAt": "2026-11-02T14:00:00.000Z",
"pausedBy": {
"kind": "supabase_user",
"id": "7a7a7a7a-0000-4000-8000-000000000060",
"via": null
},
"until": "2026-11-09T12:00:00.000Z",
"resumedAt": null,
"resumedBy": null,
"resumeReason": null,
"discardedCount": 0
}
]
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /followups/pause
Pause what Vitrina writes first (all, or one sequence)
Pausing again with the same scope replaces until and queuePolicy. until resumes by itself (checked every minute). queuePolicy: keep leaves queued messages queued, and they go out on resume through the usual contact hours (from 9:00); discard cancels them with reason paused_discarded (never deleted) and cancels whatever falls due meanwhile; a standing Búsqueda stays open. A pause never stops the AI from answering conversations a customer opened, and never blocks a message a person approves by hand.
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
scope | string | sí | mín. 1, máx. 80 |
until | string | — | date-time |
queuePolicy | keep \ | discard | sí |
curl -X POST https://api.vitrinadev.com/api/v1/followups/pause \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"scope": "builtin:si-no-responde",
"until": "2026-11-09T12:00:00.000Z",
"queuePolicy": "keep"
}'Ejemplo de respuesta (200)
{
"data": {
"id": "0198f0c0-0000-7000-8000-000000000001",
"scope": "builtin:si-no-responde",
"queuePolicy": "keep",
"pausedAt": "2026-11-02T14:00:00.000Z",
"pausedBy": {
"kind": "supabase_user",
"id": "7a7a7a7a-0000-4000-8000-000000000060",
"via": null
},
"until": "2026-11-09T12:00:00.000Z",
"resumedAt": null,
"resumedBy": null,
"resumeReason": null,
"discardedCount": 0
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/recontacto
Get the Recontacto cadence configuration
Situations are code (never_replied, stalled, lost, plus the seen-without-reply nudge); everything inside one is editable. A rung waits after_hours since the PREVIOUS rung (rung 1: since the silence) and rides one of whatsapp | instagram | email | voice; unit is the editor’s display preference and the engine never reads it. There is no per-lead cap — the rails are the cap; daily_cap is the volume control. max_evaluations_per_step bounds how many times a rung may be reconsidered without contacting anyone. pacing is «Ritmo» (every pacing rule, off by default except entry_max_silence_days 45, max_paths_per_lead 3 and max_messages_per_person_90d 6): min_gap_hours (R2), max_per_day (R5; a healthcare workspace reads 1 when none is stored, and null keeps 1), pause_after_unanswered_days (R1), approval_after_silence_days (R9), approval_fallback_hours (R11), entry_max_silence_days (R10), hot_leads_faster (R6), max_paths_per_lead (R12, per person across all their leads and chat rows; only paths written to since the person’s last reply count, so a reply resets it), max_messages_per_person_90d (R13: unanswered Recontacto messages one person may get in any 90 days, every rail together). unanswered_message.template_after_window is R7 (on). min_hours_between_touches is an alias of pacing.min_gap_hours. after_handoff is «Después de pasarlo a una persona» (A9.2): a conversation is silent from an AI handoff of any kind or a teammate takeover until «Devolver a la IA» or the close. while_silent (stored as followups + auto_send_after_hours): draft_and_remind (default: each due step a draft for whoever handles it, sent by hand), draft_auto_send (the draft sends itself after auto_send_after_hours, 1 to 144, inside contact hours; a teammate’s promised time wins), remind_only (no draft; whoever handles it is reminded) or nothing. ai_answers_waiting: never (default: whoever handles it is reminded at 30 minutes, then an admin), after_minutes (the AI answers after ai_answers_waiting_after_minutes, 5 to 720) or after_quiet_days (only a customer back after 7 quiet days). ai_answers_waiting_after_hours is the older spelling (N = after_minutes with N × 60, null = never). ai_answers_waiting_keep_owner (default false): with after_minutes, the AI answers once per wait only what it knows, or sends one short status line when the open question waits on the team, or nothing; the conversation stays with the person (handler and assignee unchanged). A person whose lead is in a late stage (visit scheduled or done, a sale under way) gets no follow-ups for 14 days after entering it or their last message (G52); then the ladder resumes through the backlog queue.
curl https://api.vitrinadev.com/api/v1/followups/recontacto \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"enabled": false,
"daily_cap": 200,
"lost_lookback_days": 90,
"max_evaluations_per_step": 3,
"re_evaluation_after_days": 1,
"min_hours_between_touches": null,
"excluded_template_ids": [],
"voice_examples": [],
"situations": {
"never_replied": {
"enabled": true,
"steps": [
{
"after_hours": 72,
"unit": "d",
"channel": "whatsapp",
"autonomy": "auto"
}
],
"lose_after_days": 7
},
"stalled": {
"enabled": true,
"steps": [
{
"after_hours": 72,
"unit": "d",
"channel": "whatsapp",
"autonomy": "assignee"
},
{
"after_hours": 168,
"unit": "d",
"channel": "email",
"autonomy": "assignee"
},
{
"after_hours": 336,
"unit": "d",
"channel": "voice",
"autonomy": "approval",
"min_score": 60
}
],
"lose_after_days": 14
},
"lost": {
"enabled": false,
"steps": [
{
"after_hours": 168,
"unit": "d",
"channel": "whatsapp",
"autonomy": "approval"
}
],
"lose_after_days": null
}
},
"unanswered_message": {
"enabled": false,
"autonomy": "assignee",
"steps": [
{
"after_hours": 3
},
{
"after_hours": 6
}
],
"conversation_lifetime_cap": 6,
"template_after_window": true
},
"pacing": {
"min_gap_hours": null,
"max_per_day": null,
"pause_after_unanswered_days": null,
"approval_after_silence_days": null,
"approval_fallback_hours": null,
"entry_max_silence_days": 45,
"hot_leads_faster": false,
"max_paths_per_lead": 3,
"max_messages_per_person_90d": 6
},
"after_handoff": {
"followups": "draft_for_owner",
"auto_send_after_hours": null,
"while_silent": "draft_and_remind",
"ai_answers_waiting": "never",
"ai_answers_waiting_after_minutes": 30,
"ai_answers_waiting_after_hours": null,
"ai_answers_waiting_keep_owner": false
},
"last_run_at": null
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
PUT /followups/recontacto
Update the Recontacto cadence configuration
Partial patch. A patch that LOOSENS outbound additionally requires campaigns:write: enabling Recontacto or the chat rail; any step set to auto or assignee; setting pacing.approval_fallback_hours; clearing or raising pacing.max_per_day, pacing.min_gap_hours (or min_hours_between_touches), pacing.pause_after_unanswered_days or pacing.approval_after_silence_days; raising pacing.max_paths_per_lead, pacing.max_messages_per_person_90d or pacing.entry_max_silence_days; turning unanswered_message.template_after_window or pacing.hot_leads_faster on; after_handoff letting the draft send itself where it did not (or sooner), or the AI answer a waiting customer where it did not (never → after_quiet_days → after_minutes, or fewer minutes). Tightening needs only followups:write. min_hours_between_touches and pacing.min_gap_hours are one setting; pacing.min_gap_hours wins when both are sent.
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
enabled | boolean | — | |
daily_cap | integer | null | — | ≥ 1, ≤ 5000 |
daily_review_budget | integer | — | ≥ 50, ≤ 5000 |
lost_lookback_days | integer | — | ≥ 7, ≤ 365 |
max_evaluations_per_step | integer | — | ≥ 1, ≤ 10 |
re_evaluation_after_days | integer | — | ≥ 1, ≤ 90 |
min_hours_between_touches | integer | null | — | ≥ 1, ≤ 720 |
excluded_template_ids | uuid[] | — | |
voice_examples | string[] | — | |
situations | object | — | |
unanswered_message | object | — | |
pacing | object | — | |
after_handoff | object | — |
curl -X PUT https://api.vitrinadev.com/api/v1/followups/recontacto \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"daily_cap": 100,
"pacing": {
"min_gap_hours": 24,
"max_per_day": 1
}
}'Ejemplo de respuesta (200)
{
"data": {
"enabled": false,
"daily_cap": 100,
"lost_lookback_days": 90,
"max_evaluations_per_step": 3,
"re_evaluation_after_days": 1,
"min_hours_between_touches": 24,
"excluded_template_ids": [],
"voice_examples": [],
"situations": {
"never_replied": {
"enabled": true,
"steps": [
{
"after_hours": 72,
"unit": "d",
"channel": "whatsapp",
"autonomy": "auto"
}
],
"lose_after_days": 7
},
"stalled": {
"enabled": true,
"steps": [
{
"after_hours": 72,
"unit": "d",
"channel": "whatsapp",
"autonomy": "assignee"
},
{
"after_hours": 168,
"unit": "d",
"channel": "email",
"autonomy": "assignee"
},
{
"after_hours": 336,
"unit": "d",
"channel": "voice",
"autonomy": "approval",
"min_score": 60
}
],
"lose_after_days": 14
},
"lost": {
"enabled": false,
"steps": [
{
"after_hours": 168,
"unit": "d",
"channel": "whatsapp",
"autonomy": "approval"
}
],
"lose_after_days": null
}
},
"unanswered_message": {
"enabled": false,
"autonomy": "assignee",
"steps": [
{
"after_hours": 3
},
{
"after_hours": 6
}
],
"conversation_lifetime_cap": 6,
"template_after_window": true
},
"pacing": {
"min_gap_hours": 24,
"max_per_day": 1,
"pause_after_unanswered_days": null,
"approval_after_silence_days": null,
"approval_fallback_hours": null,
"entry_max_silence_days": 45,
"hot_leads_faster": false,
"max_paths_per_lead": 3,
"max_messages_per_person_90d": 6
},
"after_handoff": {
"followups": "draft_for_owner",
"auto_send_after_hours": null,
"while_silent": "draft_and_remind",
"ai_answers_waiting": "never",
"ai_answers_waiting_after_minutes": 30,
"ai_answers_waiting_after_hours": null,
"ai_answers_waiting_keep_owner": false
},
"last_run_at": null
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/recontacto/attempts
List Recontacto attempts (ledger and review queue)
One row per cadence rung (with no_send_code, the «no enviados» drill, each row also carries step_label, the step as the hero names it). exhausted means the rung spent its evaluation budget without ever reaching the customer — never read it as "we chased them". waiting_template means it is parked on a WhatsApp template in Meta review: no budget spent, the ladder did not move, and the template hooks own the row. queued is an overdue step held by the backlog rule (G24): it waits for its release slot (at most 10 per workspace per day), is never composed and never counts as a try until released; release_estimate_at says when it is expected to go out. A failed row carries failure: reason, the owner’s sentence in lang, and next (retry, wait_for_reply when WhatsApp cannot deliver to the person and we wait until they write, retry_on when Meta holds marketing back from the number for an experiment (130472) and we try again by ourselves on retry_on, fix_template, or none; retry_on is null otherwise). statuses filters several statuses at once; limit and cursor page the history (meta.pagination.nextCursor), and meta.pagination.total counts every row the filter matches.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
status | query | proposed \ | dismissed \ | sent \ |
situation | query | never_replied \ | stalled \ | lost \ |
contact_id | query | uuid | no | |
lead_id | query | uuid | no | |
no_send_code | query | string | no | patrón ^[a-z0-9_]+(,[a-z0-9_]+)*$ |
not_sent_since | query | string | no | date-time |
statuses | query | string | no | patrón ^[a-z_]+(,[a-z_]+)*$ |
limit | query | integer | no | ≥ 1, ≤ 200 |
cursor | query | string | no | máx. 120 |
lang | query | es \ | en | no |
curl https://api.vitrinadev.com/api/v1/followups/recontacto/attempts \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"id": "7a7a7a7a-0000-4000-8000-0000000000a0",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"contact_id": "7a7a7a7a-0000-4000-8000-000000000030",
"lead_id": "7a7a7a7a-0000-4000-8000-000000000050",
"conversation_id": null,
"assignee_user_id": "7a7a7a7a-0000-4000-8000-000000000060",
"situation": "stalled",
"step": 1,
"channel": "whatsapp",
"status": "proposed",
"reason": "Pidió cotización hace 6 días y no respondió; el auto que le interesaba sigue disponible.",
"draft_message": "Hola María, ¿pudiste revisar la cotización? El auto sigue disponible y puedo agendarte una visita.",
"email_subject": null,
"template_id": null,
"template_params": null,
"outbound_action_id": "7a7a7a7a-0000-4000-8000-0000000000b0",
"read_message_id": null,
"score_at_attempt": 72,
"contact_attempts": 0,
"evaluation_attempts": 1,
"last_evaluated_at": "2026-10-10T13:00:00.000Z",
"no_send_code": null,
"no_send_reason": null,
"error": null,
"sent_at": null,
"dismissed_at": null,
"outcome": null,
"outcome_at": null,
"outcome_value_amount": null,
"created_at": "2026-10-10T13:00:00.000Z",
"lead_title": "Mazda CX-5 2021",
"contact_name": "María González",
"contact_phone": "+56 9 1234 5678",
"contact_email": "[email protected]",
"lead_conversation_id": "7a7a7a7a-0000-4000-8000-000000000040",
"template_name": null,
"last_inbound_at": "2026-10-04T16:20:00.000Z"
}
],
"meta": {
"pagination": {
"total": 1,
"nextCursor": null,
"limit": 200
}
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /followups/recontacto/attempts/{id}/approve
Approve a Recontacto draft and send it
Approval and delivery are the same call. It refuses with 409 when the customer answered after the draft was composed, or when the proposal is older than its shelf life. id is an attempt.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl -X POST https://api.vitrinadev.com/api/v1/followups/recontacto/attempts/<id>/approve \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-0000000000a0",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"contact_id": "7a7a7a7a-0000-4000-8000-000000000030",
"lead_id": "7a7a7a7a-0000-4000-8000-000000000050",
"conversation_id": null,
"assignee_user_id": "7a7a7a7a-0000-4000-8000-000000000060",
"situation": "stalled",
"step": 1,
"channel": "whatsapp",
"status": "sent",
"reason": "Pidió cotización hace 6 días y no respondió; el auto que le interesaba sigue disponible.",
"draft_message": "Hola María, ¿pudiste revisar la cotización? El auto sigue disponible y puedo agendarte una visita.",
"email_subject": null,
"template_id": null,
"template_params": null,
"outbound_action_id": "7a7a7a7a-0000-4000-8000-0000000000b0",
"read_message_id": null,
"score_at_attempt": 72,
"contact_attempts": 1,
"evaluation_attempts": 1,
"last_evaluated_at": "2026-10-10T13:00:00.000Z",
"no_send_code": null,
"no_send_reason": null,
"error": null,
"sent_at": "2026-10-10T14:05:00.000Z",
"dismissed_at": null,
"outcome": null,
"outcome_at": null,
"outcome_value_amount": null,
"created_at": "2026-10-10T13:00:00.000Z"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /followups/recontacto/attempts/{id}/dismiss
Dismiss a Recontacto draft
Discards a proposed draft without sending anything: the attempt moves to dismissed and the queued outbound action is cancelled. id is an attempt. Unlike approve it originates no message, so it needs only followups:write; a caller who cannot see the lead the attempt belongs to gets a 404.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl -X POST https://api.vitrinadev.com/api/v1/followups/recontacto/attempts/<id>/dismiss \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-0000000000a0",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"contact_id": "7a7a7a7a-0000-4000-8000-000000000030",
"lead_id": "7a7a7a7a-0000-4000-8000-000000000050",
"conversation_id": null,
"assignee_user_id": "7a7a7a7a-0000-4000-8000-000000000060",
"situation": "stalled",
"step": 1,
"channel": "whatsapp",
"status": "dismissed",
"reason": "Pidió cotización hace 6 días y no respondió; el auto que le interesaba sigue disponible.",
"draft_message": "Hola María, ¿pudiste revisar la cotización? El auto sigue disponible y puedo agendarte una visita.",
"email_subject": null,
"template_id": null,
"template_params": null,
"outbound_action_id": "7a7a7a7a-0000-4000-8000-0000000000b0",
"read_message_id": null,
"score_at_attempt": 72,
"contact_attempts": 0,
"evaluation_attempts": 1,
"last_evaluated_at": "2026-10-10T13:00:00.000Z",
"no_send_code": "no_reason",
"no_send_reason": "descartado por una persona",
"error": null,
"sent_at": null,
"dismissed_at": "2026-10-10T14:05:00.000Z",
"outcome": null,
"outcome_at": null,
"outcome_value_amount": null,
"created_at": "2026-10-10T13:00:00.000Z"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/recontacto/guards
Rules every follow-up respects, and today’s non-sends
Rendered from the guard registry: guards is the «Siempre se respeta» list (one paragraph each, tenant values in **bold**, an optional link), guards_summary its collapsed line, and log today’s non-sends grouped by the guard that stopped them (a code the registry does not know comes back with guard: null and a generic text). held is «esperando (por qué)»: today’s due steps a sweep held without a ledger row (daily cap, one ladder per person, an agreed reminder, the hour after another automated message…), grouped the same way and counted in leads or threads still waiting; today.held totals it. emphasis: alert marks a guard that is stopping every message today (no active agent). lang picks es or en; default the workspace’s.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
lang | query | es \ | en | no |
curl https://api.vitrinadev.com/api/v1/followups/recontacto/guards \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"locale": "es",
"guards_summary": "Siempre se respeta · horario de 09:00 a 21:00, no contactar, ventana de WhatsApp y 15 reglas más",
"guards": [
{
"guard": "G1",
"text": "Solo escribimos entre **09:00** y **21:00**. Lo que cae fuera sale a las **09:00** siguientes.",
"link": null,
"shown_as": [
"S",
"Row"
],
"emphasis": "normal",
"note": null
},
{
"guard": "G3",
"text": "Nunca a quien pidió no recibir mensajes, a contactos silenciados ni a cuentas grandes.",
"link": {
"label": "No contactar",
"href": "/campaigns/suppressions"
},
"shown_as": [
"S",
"Log"
],
"emphasis": "normal",
"note": null
}
],
"log": [
{
"guard": "G7",
"text": "La IA escribe cada mensaje leyendo la conversación. Si no hay un paso pendiente o el borrador no pasa los controles, no sale y queda anotado por qué.",
"count": 5,
"codes": [
{
"code": "no_reason",
"count": 3
},
{
"code": "repeated_greeting",
"count": 2
}
]
}
],
"today": {
"since": "2026-10-22T03:00:00.000Z",
"sent": 12,
"not_sent": 5
}
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/recontacto/held
The people behind one «esperando (por qué)» line
Today’s held subjects for the given guard codes (comma-separated, the codes of one /guards held line): each lead or thread a sweep held without a ledger row and that has not been sent since, with its contact, step and when the hold lifts when known (held_until), and step_label, the step as the «Si no responde» hero names it («Conversó y dejó de responder · paso 3 de 5»; null when the plan does not draw it). Each row also says what holds it, as /next-steps does: guard, label (the owner’s words; for G52 the predicate that matches now, «tiene una visita agendada»), reason (G52 only: visit_scheduled | appointment | visit_note | visited | sale_in_progress, else null), until and until_label («jue 5 nov») when it lifts on a date, else null, and until_text when it lifts on an event instead (G50 «hasta que la devuelvan a la IA o se cierre la conversación», a G52 stage «hasta que la oportunidad cambie de etapa o se cierre»), else null. lang picks es or en. One row per person: their latest hold. Same day and same predicate as the held counts, newest first, at most 100.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
codes | query | string | sí | mín. 1, máx. 500, patrón ^[a-z0-9_]+(,[a-z0-9_]+)*$ |
lang | query | es \ | en | no |
curl https://api.vitrinadev.com/api/v1/followups/recontacto/held \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"code": "no_reachable_address",
"lead_id": "7b0f3c5e-2a51-4d7e-9a61-3f2c8e1d4b90",
"conversation_id": null,
"contact_id": "01a1095d-6c2e-4f3b-8a7d-5e9b0c1d2f34",
"contact_name": "Rocío",
"contact_phone": null,
"contact_email": null,
"lead_title": "iPhone 15 Pro 256 GB",
"situation": "never_replied",
"step": 1,
"last_at": "2026-10-24T14:00:12.000Z",
"held_until": null,
"step_label": "Nunca respondió nuestro primer mensaje · paso 1 de 4",
"guard": "G25",
"label": "sin un canal por el que escribirle: sale cuando su ficha tenga teléfono o correo",
"reason": null,
"until": null,
"until_label": null,
"until_text": null
}
]
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/recontacto/next-steps
What happens next for each of a contact’s leads
The contact page’s «Próximo seguimiento»: for each open or lost lead of contact_id (or the one lead_id), newest first, the next thing the Recontacto engine does, computed by the same ladder walk and decision the sweep runs (contact hours, the night deferral, R12, the answer-debt gate). state is message (a follow-up is planned: step of steps_total, at, channel, what, autonomy, deferred_from_label when contact hours moved it, due_now when the next sweep takes it; with today’s daily cap (G2) or review budget (G41) spent, a step due today moves to tomorrow’s opening and deferred_reason says which). at is never outside contact hours: a step due at night (or a G8 re-look that opens at midnight) moves to the next opening. deferred_codes lists every reason at moved (outside_contact_hours, re_evaluation, daily_cap_reached, review_budget_reached), lose (no more messages; marked lost at at), answer_debt (the customer wrote last), path_cap (R12), person_ladder (G13: the person’s ladder runs on a newer lead), undeliverable (G35: WhatsApp refused our messages; nothing goes out until they write), held (the sweep holds the step when it comes due, asked at that moment through the sweep’s own functions: G25 no_reachable_address (no phone or email for the step’s channel), G52 a visit fixed, the store visited or a sale under way; G50 handed to a person for a complaint or a step only a person can do; G49 «Nada»; G34, G36, R13 / G38, R5, G43; and the composer’s channel resolution: no_deliverable_channel (e.g. Instagram past its window), template_missing, policy_blocked, account_restricted. held carries the guard, code and until when known; never a send time), draft (a draft of the step is written and waits on a person: draft = attempt_id, auto_send_at and auto_send_at_label when G49 sends it itself, null when only a person sends it), queued (G24 backlog queue: queue = position (1-based), waiting, per_day, label; at is the estimated release), paused (the master switch is off) or none. sentence is the whole answer in one line, in lang (default the workspace’s). held is today’s latest hold of a due step (guard, code, since, last_seen, until, sweeps), or null. step_label names the step as the hero does («Conversó y dejó de responder · paso 3 de 5»), and sentence uses it. at is the lead sweep’s hourly tick (the grid /plan draws). handoff_draft is set when the step becomes a draft for the person handling a handed-off conversation (G49: approver_user_id, auto_send_at, auto_send_at_label, window_note; auto_send_at is capped inside the channel’s 24 h window exactly as the engine stores it, null when only a person can send it), else null. When the step resolves to another channel than configured (an Instagram step past its window goes as a WhatsApp template), channel and what name the resolved one. conversations is the chat rail’s next reminder on the person’s open WhatsApp / Instagram threads, from the chat sweep’s own candidate query and hold spacing: the same fields as a lead plus conversation_id, thread_channel and situation: unanswered_message. handoff is the person’s open conversation the AI handed to a person, or null: conversation_id, owner_user_id, owner_name, mode (after_handoff.followups), class (informational | operational | sensitive: why it was handed over; since 2026-10-25 it no longer decides anything: every handed-off or taken conversation is silent until «Devolver a la IA», its steps follow mode, and the AI answers a waiting customer only when after_handoff.ai_answers_waiting allows it), draft (the pending draft: attempt_id, auto_send_at, auto_send_at_label, promise when a teammate’s promised time set it, window_note: when it must be sent by on a free-text WhatsApp or Instagram draft), waiting (the customer wrote last: since, ai_answers_at, ai_answers_at_label), next (kind draft | step | held | none, at_label, label: the next step as one short fact) and sentence, the state line («Lo atiende Diego · borrador listo · se envía solo mañana 16:30 si nadie lo revisa»). With no draft and nobody waiting, the line names the person’s actual next step: «borrador para quien lo atiende · {fecha}», the hold’s own words, or «sin seguimiento automático: le toca a {owner}».
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
contact_id | query | uuid | no | |
lead_id | query | uuid | no | |
lang | query | es \ | en | no |
curl https://api.vitrinadev.com/api/v1/followups/recontacto/next-steps \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"timezone": "America/Santiago",
"leads": [
{
"lead_id": "d0125f39-6f0a-4c1e-9b7a-2a4f1c3e8b10",
"state": "message",
"situation": "never_replied",
"step": 2,
"steps_total": 4,
"at": "2026-10-23T20:00:00-03:00",
"at_label": "vie 23 oct · 20:00",
"elapsed_label": "8 horas",
"due_now": false,
"deferred_from_label": null,
"deferred_reason": null,
"channel": "whatsapp",
"what": "WhatsApp",
"autonomy": "auto",
"autonomy_label": "Automático",
"sentence": "Próximo seguimiento: paso 2 de 4 · WhatsApp · vie 23 oct · 20:00",
"handoff_draft": null
},
{
"lead_id": "5b0e7c21-9d3a-4f6e-8c1b-3a2d4e5f6a7b",
"state": "held",
"situation": "stalled",
"step": 1,
"steps_total": 2,
"at": null,
"at_label": null,
"elapsed_label": null,
"due_now": false,
"deferred_from_label": null,
"deferred_reason": null,
"channel": "whatsapp",
"what": null,
"autonomy": null,
"autonomy_label": null,
"sentence": "En pausa: visita agendada, ya visitó o venta en curso (se retoma el jue 5 nov)",
"held": {
"guard": "G52",
"code": "bottom_funnel",
"label": "visita agendada, ya visitó o venta en curso",
"since": "2026-10-23T15:10:00-03:00",
"last_seen": "2026-10-23T15:10:00-03:00",
"until": "2026-11-05T13:00:00-03:00",
"sweeps": 0
},
"handoff_draft": null
}
],
"handoff": {
"conversation_id": "01a1639b-75bf-79d7-8e5a-93c5000d6319",
"owner_user_id": "829e61ea-2cb9-43dc-80fd-18baf78eaaed",
"owner_name": "Diego Navarrete",
"mode": "draft_for_owner",
"class": "informational",
"sensitive": false,
"draft": {
"attempt_id": "7c2f1e0a-3b4d-4e5f-8a9b-0c1d2e3f4a5b",
"auto_send_at": "2026-10-25T16:30:00-03:00",
"auto_send_at_label": "mañana 16:30",
"promise": false
},
"waiting": null,
"next": {
"kind": "draft",
"at_label": "mañana 16:30",
"label": "borrador · mañana 16:30"
},
"sentence": "Lo atiende Diego · borrador listo · se envía solo mañana 16:30 si nadie lo revisa"
}
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/recontacto/plan
What a customer who stops answering receives (simulated)
The «Si no responde» ladder, computed by the engine itself: a synthetic lead is stepped along the sweeps’ clocks (hourly for the lead rails, every 15 minutes for the chat rows, only inside contact hours) through the real decision function and pacing holds, so every row’s at is when the engine would actually send it. scenarios holds conversed («Conversó y dejó de responder»: chat rows, then the WhatsApp/email/call rows, both counted from t0), never_replied («Nunca respondió nuestro primer mensaje»: t0 is our first message; the lost rail is never reached) and lost («Ya está perdido», counted from the loss); ?scenario= returns just one. start is t0 (default now); start_options are the example clock’s picks. A moved row carries deferred_code (outside_contact_hours, previous_late, unanswered_hold, min_gap, max_per_day, automated_gap, person_ceiling, daily_cap) with its sentence, and deferred_codes lists every reason that moved it, and hot_at when R6 moves it for a hot lead; a drawn step the engine never sends has sends: false and not_sent_reason. Also: header_sentence, each scenario’s hero sentence, pacing_summary (the collapsed «Ritmo» line: the rules that are on) and pacing (every «Ritmo» rule R1–R13 with on and value), limits_sentence (every bound on what one person can receive, with the workspace values), row warnings for the editor (a first contact under 12 h, approval rows’ real send rate, Instagram past 24 h), guards from the registry (G21 highlight when a sweep hit its candidate ceiling in the last 24 h), approval_fallback.available (false for a clinic whose clinical response times are undeclared; hours is R11), today (sent, not sent, went to approval, awaiting approval, and the R8 counter would_need_approval of due, failed (follow-ups sent today that did not arrive) and failed_actionable (of those, the ones a person can retry or fix; never a number WhatsApp cannot deliver to), and cap: what the day used of the daily cap and the AI review budget, the spent one in reached with its sentence and resumes_at; while one is spent, rows at «now» that fall today move to tomorrow with deferred_code: daily_cap; and backlog, the whole queue’s drain: queued, per_day, days (first to last estimated release, inclusive), last_release_on and sentence «En cola: N, se reparte en ~D días (hasta el …)», null when nothing is queued) and queue (each queued backlog step’s estimated release day, 10 per tenant per day; contact_id or lead_id narrows it), and after_handoff («Después de pasarlo a una persona»: followups, auto_send_after_hours, ai_answers_waiting_after_hours, reminder_minutes and the block’s collapsed line summary, controls (which of followups, auto_send_after_hours, ai_answers_waiting_after_hours do anything here: the AI-answer control is false at a clinic, where D73 and the teammate-reply resume run instead), and now, the live state: conversations with the team after an AI handoff or a teammate takeover, by_class (informational, operational, sensitive, and takeover for chats a teammate took without a handoff), drafts (waiting in «Por aprobar»: only drafts whose action is still awaiting approval; a rejected one leaves at once), upcoming_drafts («Próximos borradores»: people whose next step is a draft not written yet), drafts_self_sending, customers_waiting, the fact line sentence and people (one per conversation: contact_id, contact_name, conversation_id, conversation_display_id, owner_user_id, owner_name, class, trigger (ai_handoff | teammate), has_draft, draft_auto_send_at, draft_auto_send_at_label, waiting_since, waited_hours, waited_label, promise_overdue, promise_label, next (kind draft | step | held | none, at_label, label: the next step as one short fact, from the same facts as sentence), sentence; a broken promise sorts first), truncated; null when the read failed). today.not_sent counts people with a step that ended without a send today and no send at all today. All copy is in lang (default the workspace’s). after_handoff_now=false leaves after_handoff.now null (read it from /followups/recontacto/plan/after-handoff). That screen read (after_handoff_now=false with no scenario, start, contact_id or lead_id) is served from a per-workspace copy at most 60 s old (refreshed in the background after 10 s), dropped by the same writes as the «Ahora» block, and carries as_of (when the read behind it started) and age_seconds; every other read is fresh.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
scenario | query | conversed \ | never_replied \ | lost |
start | query | string | no | date-time |
lang | query | es \ | en | no |
contact_id | query | uuid | no | |
lead_id | query | uuid | no | |
after_handoff_now | query | true \ | false | no |
curl https://api.vitrinadev.com/api/v1/followups/recontacto/plan \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"header_sentence": "4 mensajes en 5 días; a los 12 días se marca perdido y a los 42 días, uno más con tu aprobación.",
"start": "2026-10-21T16:00:00-03:00",
"start_options": [
{
"start": "2026-10-21T16:00:00-03:00",
"label": "mié 21 oct · 16:00"
},
{
"start": "2026-10-21T20:00:00-03:00",
"label": "mié 21 oct · 20:00"
}
],
"timezone": "America/Santiago",
"contact_hours": {
"start_hour": 9,
"end_hour": 21
},
"enabled": true,
"scenarios": [
{
"scenario": "conversed",
"title": "Conversó y dejó de responder",
"enabled": true,
"sentence": "Le escribimos 3 horas y 8 horas después de nuestro último mensaje, por el mismo chat (si cae fuera del horario, a las 09:00 siguientes). Después, por WhatsApp, a los 2 y a los 5 días. Si sigue sin responder, a los 12 días lo marcamos perdido, y a los 42 días le escribimos una última vez, con tu aprobación.",
"start_label": "Si nuestro último mensaje sale",
"items": [
{
"kind": "message",
"n": 2,
"rail": "unanswered_message",
"step": 2,
"elapsed_hours": 8,
"elapsed_label": "8 horas",
"at": "2026-10-22T09:00:00-03:00",
"at_label": "jue 22 oct · 09:00",
"deferred_from": "2026-10-22T00:00:00-03:00",
"deferred_from_label": "jue 00:00",
"deferred_reason": "fuera de horario",
"deferred_code": "outside_contact_hours",
"deferred_detail": "Fuera de horario: sale a las 09:00. Las horas de noche no cuentan como atraso.",
"sends": true,
"not_sent_reason": null,
"channel": "same_chat",
"what": "Recordatorio en el mismo chat",
"detail": "La IA retoma lo que quedó pendiente; si no queda nada pendiente, no sale. Solo en chats abiertos de WhatsApp o Instagram.",
"autonomy_effective": "auto",
"autonomy_label": "Automático",
"autonomy_reason": null,
"needs_template": false,
"warnings": [],
"hot_at": null,
"hot_at_label": null,
"gap_label": "5 horas después del anterior"
},
{
"kind": "window_marker",
"elapsed_label": "24 horas",
"text": "se cierra la ventana de WhatsApp (se cuenta desde el último mensaje del cliente): desde aquí, solo plantillas aprobadas"
},
{
"kind": "lost",
"elapsed_hours": 288,
"elapsed_label": "12 días",
"at": "2026-11-02T16:00:00-03:00",
"at_label": "lun 2 nov · 16:00",
"title": "Lo marcamos perdido",
"detail": "Si sigue sin responder 7 días después del último mensaje.",
"lose_after_days": 7
},
{
"kind": "end",
"elapsed_hours": null,
"elapsed_label": null,
"at": null,
"at_label": null,
"title": "Fin de este camino.",
"detail": "Si vuelve a escribir y después se queda en silencio, empieza otro.",
"lose_after_days": null
}
],
"footnote": "Los mensajes por día salen en la revisión de cada hora (hasta 1 hora después); los del chat, hasta 15 minutos después."
}
],
"pacing_summary": "Ritmo · hasta 3 caminos sin respuesta por persona cada 90 días; no empezamos con silencios de más de 45 días; después de 24 horas, plantilla aprobada.",
"pacing": [
{
"rule": "R12",
"on": true,
"value": 3,
"text": "hasta 3 caminos sin respuesta por persona cada 90 días"
},
{
"rule": "R10",
"on": true,
"value": 45,
"text": "no empezamos con silencios de más de 45 días"
},
{
"rule": "R7",
"on": true,
"value": null,
"text": "después de 24 horas, plantilla aprobada"
},
{
"rule": "R1",
"on": false,
"value": null,
"text": "si no responde a un seguimiento, los otros caminos esperan N días (apagado)"
}
],
"limits_sentence": "Nunca son infinitos: nunca más de 12 mensajes sin respuesta a una misma persona cada 90 días, sumando todas sus oportunidades, cada camino termina (dejó de responder: 4 mensajes, perdido a los 12 días; nunca respondió: 4 mensajes, perdido a los 12 días; ya perdido: 1 más y fin), como máximo 3 caminos sin respuesta por persona cada 90 días, 4 recordatorios en el chat por conversación sin respuesta, hasta 200 mensajes por día que tus clientes no pidieron y nunca dos mensajes automáticos a la misma persona con menos de 1 hora.",
"guards_summary": "Siempre se respeta · horario de 09:00 a 21:00, no contactar, ventana de WhatsApp y 15 reglas más",
"guards": [
{
"guard": "G21",
"text": "Revisamos hasta **2000** oportunidades por vuelta, primero las de silencio más reciente.",
"link": null,
"shown_as": [
"S"
],
"emphasis": "highlight",
"note": "**Hoy a las 10:07** una revisión llegó al tope: revisamos **2.000** de **2.340** clientes; el resto entra en las próximas revisiones."
}
],
"approval_fallback": {
"available": true,
"reason": null,
"hours": null
},
"after_handoff": {
"followups": "draft_for_owner",
"auto_send_after_hours": null,
"while_silent": "draft_and_remind",
"ai_answers_waiting": "never",
"ai_answers_waiting_after_minutes": 30,
"ai_answers_waiting_after_hours": null,
"ai_answers_waiting_keep_owner": false,
"reminder_minutes": 30,
"controls": {
"followups": true,
"auto_send_after_hours": true,
"ai_answers_waiting_after_hours": true,
"while_silent": true,
"ai_answers_waiting": true
},
"summary": "Después de pasarlo a una persona · borrador para quien lo atiende, sale solo si lo envía; si el cliente espera, le avisamos a quien lo atiende. La IA no le escribe a una conversación que atiende una persona hasta que alguien la devuelve a la IA.",
"now": {
"conversations": 2,
"by_class": {
"informational": 1,
"operational": 1,
"sensitive": 0,
"takeover": 0
},
"drafts": 1,
"upcoming_drafts": 0,
"drafts_self_sending": 1,
"customers_waiting": 0,
"sentence": "Ahora: 2 conversaciones con el equipo · 1 borrador por enviarse solo",
"people": [
{
"contact_id": "3f1c2b4a-5d6e-4f70-8a91-b2c3d4e5f607",
"contact_name": "Nando",
"conversation_id": "01a1639b-75bf-79d7-8e5a-93c5000d6319",
"conversation_display_id": "C-363",
"owner_user_id": "829e61ea-2cb9-43dc-80fd-18baf78eaaed",
"owner_name": "Diego Navarrete",
"class": "informational",
"trigger": "ai_handoff",
"has_draft": true,
"draft_auto_send_at": "2026-10-25T16:30:00-03:00",
"draft_auto_send_at_label": "mañana 16:30",
"waiting_since": null,
"waited_hours": null,
"waited_label": null,
"promise_overdue": false,
"promise_label": null,
"next": {
"kind": "draft",
"at_label": "mañana 16:30",
"label": "borrador · mañana 16:30"
},
"sentence": "Lo atiende Diego Navarrete · borrador listo · se envía solo mañana 16:30 si nadie lo revisa"
},
{
"contact_id": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d",
"contact_name": "Karen Olguin",
"conversation_id": "01a16d2e-9f3a-7b1c-8d2e-3f4a5b6c7d8e",
"conversation_display_id": "C-384",
"owner_user_id": "829e61ea-2cb9-43dc-80fd-18baf78eaaed",
"owner_name": "Diego Navarrete",
"class": "operational",
"trigger": "ai_handoff",
"has_draft": false,
"draft_auto_send_at": null,
"draft_auto_send_at_label": null,
"waiting_since": null,
"waited_hours": null,
"waited_label": null,
"promise_overdue": true,
"promise_label": "el vie 23 desde las 10:00 (no se cumplió)",
"next": {
"kind": "none",
"at_label": null,
"label": "le toca a Diego"
},
"sentence": "Lo atiende Diego · algo que solo puede hacer el equipo: la IA no escribe sola · la IA prometió que le escribirían el vie 23 desde las 10:00 (no se cumplió)"
}
],
"truncated": false
}
},
"today": {
"since": "2026-10-21T03:00:00.000Z",
"sent": 66,
"not_sent": 31,
"went_to_approval": 0,
"awaiting_approval": 0,
"would_need_approval": 9,
"due": 31,
"failed": 2,
"failed_actionable": 1,
"backlog": {
"queued": 1,
"per_day": 10,
"days": 1,
"last_release_on": "2026-10-22",
"sentence": "En cola: 1, sale todo el jue 22 oct"
}
},
"queue": {
"per_day": 10,
"released_today": 4,
"waiting": 1,
"steps": [
{
"attempt_id": "7a7a7a7a-0000-4000-8000-0000000000a0",
"lead_id": "7a7a7a7a-0000-4000-8000-000000000050",
"contact_id": "7a7a7a7a-0000-4000-8000-000000000030",
"situation": "stalled",
"step": 2,
"due_at": "2026-10-12T19:00:00.000Z",
"position": 0,
"release_on": "2026-10-29",
"release_at": "2026-10-29T09:00:00-03:00",
"label": "En cola: sale el jue 29 oct"
}
]
}
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/recontacto/plan/after-handoff
The «Ahora» block of the plan, on its own
Exactly the plan’s after_handoff.now: the open conversations with the team now (conversations, drafts_self_sending, customers_waiting, by_class, sentence, people, truncated), or null when the read failed. It costs one next-step read per handed-off person, so a screen asks /followups/recontacto/plan?after_handoff_now=false and reads this block separately. lang picks es or en (default the workspace’s). Served from a per-workspace copy at most 60 s old (refreshed in the background after 10 s); a successful write on /conversations, /messages, /leads, /outbound/approvals, /outbound-holds, /followups or /tenant/settings drops it. as_of is when the read behind the answer started and age_seconds its age («actualizado hace N s»). The plan’s own after_handoff.now is always read fresh.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
lang | query | es \ | en | no |
curl https://api.vitrinadev.com/api/v1/followups/recontacto/plan/after-handoff \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"conversations": 2,
"by_class": {
"informational": 1,
"operational": 1,
"sensitive": 0,
"takeover": 0
},
"drafts": 1,
"upcoming_drafts": 0,
"drafts_self_sending": 1,
"customers_waiting": 0,
"sentence": "Ahora: 2 conversaciones con el equipo · 1 borrador por enviarse solo",
"people": [
{
"contact_id": "3f1c2b4a-5d6e-4f70-8a91-b2c3d4e5f607",
"contact_name": "Nando",
"conversation_id": "01a1639b-75bf-79d7-8e5a-93c5000d6319",
"conversation_display_id": "C-363",
"owner_user_id": "829e61ea-2cb9-43dc-80fd-18baf78eaaed",
"owner_name": "Diego Navarrete",
"class": "informational",
"trigger": "ai_handoff",
"has_draft": true,
"draft_auto_send_at": "2026-10-25T16:30:00-03:00",
"draft_auto_send_at_label": "mañana 16:30",
"waiting_since": null,
"waited_hours": null,
"waited_label": null,
"promise_overdue": false,
"promise_label": null,
"next": {
"kind": "draft",
"at_label": "mañana 16:30",
"label": "borrador · mañana 16:30"
},
"sentence": "Lo atiende Diego Navarrete · borrador listo · se envía solo mañana 16:30 si nadie lo revisa"
},
{
"contact_id": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d",
"contact_name": "Karen Olguin",
"conversation_id": "01a16d2e-9f3a-7b1c-8d2e-3f4a5b6c7d8e",
"conversation_display_id": "C-384",
"owner_user_id": "829e61ea-2cb9-43dc-80fd-18baf78eaaed",
"owner_name": "Diego Navarrete",
"class": "operational",
"trigger": "ai_handoff",
"has_draft": false,
"draft_auto_send_at": null,
"draft_auto_send_at_label": null,
"waiting_since": null,
"waited_hours": null,
"waited_label": null,
"promise_overdue": true,
"promise_label": "el vie 23 desde las 10:00 (no se cumplió)",
"next": {
"kind": "none",
"at_label": null,
"label": "le toca a Diego"
},
"sentence": "Lo atiende Diego · algo que solo puede hacer el equipo: la IA no escribe sola · la IA prometió que le escribirían el vie 23 desde las 10:00 (no se cumplió)"
}
],
"truncated": false,
"as_of": "2026-10-21T19:00:00.000Z",
"age_seconds": 12
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/recontacto/preview
Preview today’s Recontacto sweep (dry run)
The sweep’s selection with every side effect removed: same query, same decision function, same ordering under the daily cap, and the same send-time guards the sweep applies before composing (one ladder per person, R2, R1, an agreed reminder, WhatsApp undeliverable, the per-person ceiling, opt-outs and no reachable channel): a step one of them stops is a skip row, and a chat row it stops carries skip_reason and skip_label. truncated says whether evaluated is the query ceiling rather than the whole base. today.would_need_approval of today.due is the R8 status line: of the steps due now, how many a person handles, i.e. would go to approval under «Automático salvo asignado». goes_at is when the rows that go today actually go (now inside contact hours, else the next opening); limit_reached names a spent daily limit (daily_cap, review_budget) and resumes_at when deferred rows go. Every lead row and chat row carries its contact_id; a skip row carries skip_label, the guard registry’s words for skip_reason, in lang (default the workspace’s).
curl https://api.vitrinadev.com/api/v1/followups/recontacto/preview \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"evaluated": 1,
"truncated": false,
"total_candidates": 1,
"candidates": [
{
"lead_id": "7a7a7a7a-0000-4000-8000-000000000050",
"contact_id": "7a7a7a7a-0000-4000-8000-000000000030",
"lead_title": "Mazda CX-5 2021",
"contact_name": "María González",
"contact_phone": "+56 9 1234 5678",
"contact_email": "[email protected]",
"score": 72,
"temperature": "warm",
"value_amount": 17500000,
"situation": "stalled",
"action": "attempt",
"step": 1,
"channel": "whatsapp",
"mode": "approval",
"skip_reason": null,
"skip_label": null,
"deferred": false
}
],
"daily_cap": 200,
"sent_today": 0,
"tried_today": 0,
"automated_today": 0,
"unanswered_message": {
"enabled": false,
"evaluated": 0,
"candidates": []
},
"today": {
"would_need_approval": 0,
"due": 1
}
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/recontacto/readiness
Can Recontacto actually send?
WhatsApp connection, how many APPROVED templates Vitrina can fill, and the 30-day counts of sends stuck for want of a template versus sends the model deliberately abstained from. auto_template describes the template Vitrina drafted and submitted on the tenant’s behalf (pending → in Meta review, approved, gave_up → three rejections, a human must author one) and how many rungs are parked waiting on it.
curl https://api.vitrinadev.com/api/v1/followups/recontacto/readiness \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"whatsapp_connected": true,
"usable_templates": 2,
"template_names": [
"reactivar_cotizacion",
"seguimiento_visita"
],
"wrong_category_templates": 0,
"skips_missing_template_30d": 0,
"abstentions_30d": 4,
"auto_template": {
"status": "pending",
"template_id": "7a7a7a7a-0000-4000-8000-0000000000c0",
"template_name": "reactivar_conversacion",
"language": "es",
"submitted_at": "2026-10-09T12:00:00.000Z",
"rejection_reason": null,
"rejected_count": 0,
"waiting_attempts": 2
}
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /followups/recontacto/run
Run the Recontacto sweep now
Forces both sweeps regardless of schedule, enablement or quiet hours. Only a caller who also holds campaigns:write triggers the auto-send legs; everyone else gets a review-queue-only sweep. waiting_template counts rungs parked on a template Vitrina just asked Meta for — neither sent nor skipped, and no budget spent.
curl -X POST https://api.vitrinadev.com/api/v1/followups/recontacto/run \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"evaluated": 12,
"truncated": false,
"attempted": 5,
"sent": 0,
"queued": 5,
"skipped": 7,
"lost": 0,
"capped": 0,
"waiting_template": 0,
"answer_debt": 0,
"contact_deduped": 0,
"unreachable": 0,
"transient": 0,
"expired": 0,
"halted": false,
"unanswered_message": {
"evaluated": 0,
"sent": 0,
"queued": 0,
"skipped": 0,
"transient": 0,
"halted": false
}
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/recontacto/stats
Recontacto results by situation, rung and channel
One row per (situation, rung, channel) over the last since_days (default 30, max 365), counting only rungs that were actually sent: sent, answered (live: sends the customer has written after, by now), replied (the judged outcome: any inbound after the send, including reactivated and won), won and recovered_value, the value of leads closed won after the nudge. Outcomes are judged a week after the send, so the latest rungs read as unreplied. Requires followups:read.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
since_days | query | integer | no | ≥ 1, ≤ 365 |
curl https://api.vitrinadev.com/api/v1/followups/recontacto/stats \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"situation": "stalled",
"step": 1,
"channel": "whatsapp",
"sent": 18,
"replied": 6,
"won": 1,
"recovered_value": 17500000
}
]
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /followups/resume
Resume a paused scope
Idempotent: resumed is false when the scope was not paused. Messages kept by the pause go out as their own schedules and the contact hours allow.
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
scope | string | sí | mín. 1, máx. 80 |
curl -X POST https://api.vitrinadev.com/api/v1/followups/resume \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"scope": "builtin:si-no-responde"
}'Ejemplo de respuesta (200)
{
"data": {
"scope": "builtin:si-no-responde",
"resumed": true,
"pause": {
"id": "0198f0c0-0000-7000-8000-000000000001",
"scope": "builtin:si-no-responde",
"queuePolicy": "keep",
"pausedAt": "2026-11-02T14:00:00.000Z",
"pausedBy": {
"kind": "supabase_user",
"id": "7a7a7a7a-0000-4000-8000-000000000060",
"via": null
},
"until": "2026-11-09T12:00:00.000Z",
"resumedAt": "2026-11-03T09:00:00.000Z",
"resumedBy": null,
"resumeReason": "manual",
"discardedCount": 0
}
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/subscriptions
List a contact’s follow-up subscriptions
contact_id is required — this is the chat panel’s per-contact view, not a tenant-wide list.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
contact_id | query | uuid | sí |
curl https://api.vitrinadev.com/api/v1/followups/subscriptions \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"id": "7a7a7a7a-0000-4000-8000-000000000020",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"followup_id": "7a7a7a7a-0000-4000-8000-000000000010",
"contact_id": "7a7a7a7a-0000-4000-8000-000000000030",
"conversation_id": "7a7a7a7a-0000-4000-8000-000000000040",
"status": "open",
"criteria": {
"make": "Mazda",
"model": "CX-5",
"year_min": 2020,
"max_price": 18000000
},
"fire_at": null,
"fired_at": null,
"fired_channel": null,
"pending_event": null,
"pending_fire_at": null,
"pending_meta": null,
"pending_reason": null,
"preferred_channel": "whatsapp",
"created_by_kind": "human_user",
"created_by_id": "7a7a7a7a-0000-4000-8000-000000000060",
"fulfiller_kind": "ai_agent",
"fulfiller_user_id": null,
"consent_message_id": null,
"outbound_action_id": null,
"satisfied_by_message_id": null,
"failure_reason": null,
"terminal_reason": null,
"defer_count": 0,
"notice_count": 0,
"notice_failure_notified_cause": null,
"last_notice_at": null,
"last_notice_unkept_at": null,
"busqueda_swept_at": null,
"expiry_warned_at": null,
"expires_at": "2027-01-08T14:00:00.000Z",
"customer_deadline_at": null,
"fulfilled_at": null,
"fulfilled_by": null,
"fulfilled_vehicle_id": null,
"fulfilment_source": null,
"cancelled_at": null,
"cancelled_by": null,
"cancelled_by_kind": null,
"cancel_reason_code": null,
"cancel_reason": null,
"created_at": "2026-10-10T14:00:00.000Z",
"followup_name": "Avisar cuando llegue el auto que busca",
"trigger_key": "vehicle_in_stock"
}
]
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /followups/subscriptions
Subscribe a contact to a follow-up
Upsert, not insert: re-subscribing a contact that already has an open subscription to the same recipe updates it rather than duplicating, and still answers 201. Two rejections worth knowing — an inactive recipe is a 409, and an after_delay recipe without fire_at is a 400, since there would be no moment to fire at. fulfiller_kind decides who performs it: the AI agent, or the operator themselves. customer_deadline_at records the deadline the CUSTOMER gave for a stock or price want — it expires on whichever comes first, that date or the platform’s 90 days.
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
followup_id | uuid | sí | |
contact_id | uuid | sí | |
conversation_id | string | null | — | |
criteria | object | — | |
fire_at | string | null | — | date-time |
preferred_channel | whatsapp \ | email \ | sms \ |
fulfiller_kind | ai_agent \ | human_self | — |
customer_deadline_at | string | null | — | date-time |
curl -X POST https://api.vitrinadev.com/api/v1/followups/subscriptions \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"followup_id": "7a7a7a7a-0000-4000-8000-000000000010",
"contact_id": "7a7a7a7a-0000-4000-8000-000000000030",
"conversation_id": "7a7a7a7a-0000-4000-8000-000000000040",
"criteria": {
"make": "Mazda",
"model": "CX-5",
"year_min": 2020,
"max_price": 18000000
},
"preferred_channel": "whatsapp",
"fulfiller_kind": "ai_agent"
}'Ejemplo de respuesta (201)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-000000000020",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"followup_id": "7a7a7a7a-0000-4000-8000-000000000010",
"contact_id": "7a7a7a7a-0000-4000-8000-000000000030",
"conversation_id": "7a7a7a7a-0000-4000-8000-000000000040",
"status": "open",
"criteria": {
"make": "Mazda",
"model": "CX-5",
"year_min": 2020,
"max_price": 18000000
},
"fire_at": null,
"fired_at": null,
"fired_channel": null,
"pending_event": null,
"pending_fire_at": null,
"pending_meta": null,
"pending_reason": null,
"preferred_channel": "whatsapp",
"created_by_kind": "human_user",
"created_by_id": "7a7a7a7a-0000-4000-8000-000000000060",
"fulfiller_kind": "ai_agent",
"fulfiller_user_id": null,
"consent_message_id": null,
"outbound_action_id": null,
"satisfied_by_message_id": null,
"failure_reason": null,
"terminal_reason": null,
"defer_count": 0,
"notice_count": 0,
"notice_failure_notified_cause": null,
"last_notice_at": null,
"last_notice_unkept_at": null,
"busqueda_swept_at": null,
"expiry_warned_at": null,
"expires_at": "2027-01-08T14:00:00.000Z",
"customer_deadline_at": null,
"fulfilled_at": null,
"fulfilled_by": null,
"fulfilled_vehicle_id": null,
"fulfilment_source": null,
"cancelled_at": null,
"cancelled_by": null,
"cancelled_by_kind": null,
"cancel_reason_code": null,
"cancel_reason": null,
"created_at": "2026-10-10T14:00:00.000Z"
}
}Responde: 201 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /followups/subscriptions/{id}
Cancel one waiting subscription
Cancels this contact’s subscription only; the recipe is untouched. id is a subscription.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl -X DELETE https://api.vitrinadev.com/api/v1/followups/subscriptions/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Responde: 204 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/subscriptions/{id}
One Búsqueda, in full (ADR 0109 §4)
The lead ficha, the vehicle panel’s «Abrir búsqueda» and a workspace-list row all open into this: the contact, the resolved owner, the criteria decomposed into display labels, days left, every car the (Búsqueda, vehicle) pair ledger has already paired (what pre-fills the fulfil picker), and — once closed — the fulfilment or cancellation record. A timer Seguimiento here is a 400 (this view is a Búsqueda’s). contact.name / contact.phone are further gated on contacts:read — absent, not null, without it.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl https://api.vitrinadev.com/api/v1/followups/subscriptions/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-000000000020",
"status": "open",
"trigger_key": "vehicle_in_stock",
"contact": {
"id": "7a7a7a7a-0000-4000-8000-000000000030",
"name": "María González",
"phone": "+56 9 1234 5678"
},
"lead_id": "7a7a7a7a-0000-4000-8000-000000000050",
"owner": {
"user_id": "7a7a7a7a-0000-4000-8000-000000000060",
"name": "Carlos Pérez",
"avatar_url": null
},
"criteria": {
"make": "Mazda",
"model": "CX-5",
"year_min": 2020,
"max_price": 18000000
},
"note": null,
"summary": {
"name": "Mazda CX-5",
"years": "2020 en adelante",
"budget": "hasta $18.000.000",
"km": null,
"soughtLabel": "Mazda CX-5 · 2020 en adelante",
"summary": "Mazda CX-5 · 2020 en adelante · hasta $18.000.000",
"groupKey": "mazda cx-5|2020|"
},
"expires_at": "2027-01-08T14:00:00.000Z",
"customer_deadline_at": null,
"days_left": 90,
"notice_count": 1,
"last_notice_at": "2026-10-12T15:00:00.000Z",
"notified_vehicles": [
{
"vehicle_id": "7a7a7a7a-0000-4000-8000-000000000070",
"make": "Mazda",
"model": "CX-5",
"year": 2021,
"registration_number": "KRTZ45",
"verdict": "calza",
"state": "notified",
"notified_at": "2026-10-12T15:00:00.000Z",
"dismissed_at": null
}
],
"fulfilled_at": null,
"fulfilled_vehicle": null,
"fulfilment_source": null,
"cancelled_at": null,
"cancel_reason_code": null,
"cancel_reason": null,
"cancelled_by_kind": null
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /followups/subscriptions/{id}/cancel
Cancel a Búsqueda with a reason
The seller withdraws one open Búsqueda. Distinct from DELETE /followups/subscriptions/\{id\}, which merely unschedules and records no reason: "bought elsewhere" and "could not be reached" are different facts, and the demand list is read to decide what stock to buy. The customer’s own opt-out is not this endpoint — it cancels every open Búsqueda of that contact in the rail’s scope (ADR 0108 §5).
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
reason_code | bought_elsewhere \ | no_longer_interested \ | unreachable \ |
reason | string | null | — | máx. 500 |
curl -X POST https://api.vitrinadev.com/api/v1/followups/subscriptions/<id>/cancel \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason_code": "bought_elsewhere",
"reason": "Compró en otra automotora la semana pasada"
}'Ejemplo de respuesta (200)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-000000000020",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"followup_id": "7a7a7a7a-0000-4000-8000-000000000010",
"contact_id": "7a7a7a7a-0000-4000-8000-000000000030",
"conversation_id": "7a7a7a7a-0000-4000-8000-000000000040",
"status": "cancelled",
"criteria": {
"make": "Mazda",
"model": "CX-5",
"year_min": 2020,
"max_price": 18000000
},
"fire_at": null,
"fired_at": null,
"fired_channel": null,
"pending_event": null,
"pending_fire_at": null,
"pending_meta": null,
"pending_reason": null,
"preferred_channel": "whatsapp",
"created_by_kind": "human_user",
"created_by_id": "7a7a7a7a-0000-4000-8000-000000000060",
"fulfiller_kind": "ai_agent",
"fulfiller_user_id": null,
"consent_message_id": null,
"outbound_action_id": null,
"satisfied_by_message_id": null,
"failure_reason": null,
"terminal_reason": null,
"defer_count": 0,
"notice_count": 0,
"notice_failure_notified_cause": null,
"last_notice_at": null,
"last_notice_unkept_at": null,
"busqueda_swept_at": null,
"expiry_warned_at": null,
"expires_at": "2027-01-08T14:00:00.000Z",
"customer_deadline_at": null,
"fulfilled_at": null,
"fulfilled_by": null,
"fulfilled_vehicle_id": null,
"fulfilment_source": null,
"cancelled_at": "2026-10-14T16:00:00.000Z",
"cancelled_by": "7a7a7a7a-0000-4000-8000-000000000060",
"cancelled_by_kind": "user",
"cancel_reason_code": "bought_elsewhere",
"cancel_reason": "Compró en otra automotora la semana pasada",
"created_at": "2026-10-10T14:00:00.000Z"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /followups/subscriptions/{id}/fulfill
Mark a Búsqueda fulfilled (the customer got a car)
A stock-arrival or price-drop subscription IS the customer’s Búsqueda (ADR 0109): it stays OPEN across notices — one notice per matching car, at most one a day — and ends when the car turns up, the customer withdraws, or its clock runs out. This is the "turned up" end, and the unit that answered it is required: it is what keeps the demand list true and the sale attributable. It also happens by itself when a reservation or a sale of a car the want was told about is recorded for that contact. A timer Seguimiento is a 400 (it is owed once and done when it is sent), a want that is no longer open a 409.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
vehicle_id | uuid | sí |
curl -X POST https://api.vitrinadev.com/api/v1/followups/subscriptions/<id>/fulfill \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"vehicle_id": "7a7a7a7a-0000-4000-8000-000000000070"
}'Ejemplo de respuesta (200)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-000000000020",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"followup_id": "7a7a7a7a-0000-4000-8000-000000000010",
"contact_id": "7a7a7a7a-0000-4000-8000-000000000030",
"conversation_id": "7a7a7a7a-0000-4000-8000-000000000040",
"status": "fulfilled",
"criteria": {
"make": "Mazda",
"model": "CX-5",
"year_min": 2020,
"max_price": 18000000
},
"fire_at": null,
"fired_at": null,
"fired_channel": null,
"pending_event": null,
"pending_fire_at": null,
"pending_meta": null,
"pending_reason": null,
"preferred_channel": "whatsapp",
"created_by_kind": "human_user",
"created_by_id": "7a7a7a7a-0000-4000-8000-000000000060",
"fulfiller_kind": "ai_agent",
"fulfiller_user_id": null,
"consent_message_id": null,
"outbound_action_id": null,
"satisfied_by_message_id": null,
"failure_reason": null,
"terminal_reason": null,
"defer_count": 0,
"notice_count": 1,
"notice_failure_notified_cause": null,
"last_notice_at": "2026-10-12T15:00:00.000Z",
"last_notice_unkept_at": null,
"busqueda_swept_at": null,
"expiry_warned_at": null,
"expires_at": "2027-01-08T14:00:00.000Z",
"customer_deadline_at": null,
"fulfilled_at": "2026-10-14T16:00:00.000Z",
"fulfilled_by": "7a7a7a7a-0000-4000-8000-000000000060",
"fulfilled_vehicle_id": "7a7a7a7a-0000-4000-8000-000000000070",
"fulfilment_source": "manual",
"cancelled_at": null,
"cancelled_by": null,
"cancelled_by_kind": null,
"cancel_reason_code": null,
"cancel_reason": null,
"created_at": "2026-10-10T14:00:00.000Z"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /followups/subscriptions/{id}/let-expire
Decline to renew a Búsqueda — «Dejar vencer»
The pre-expiry warning bell’s second action (ADR 0109 §5): the owner looked and chose not to renew. The want’s status and clock are untouched — it stays open and the existing TTL sweep still closes it on schedule; this only records the deliberate choice, distinct from a want that lapses with nobody having looked. No body. Only an OPEN want can be declined (a 409 otherwise).
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl -X POST https://api.vitrinadev.com/api/v1/followups/subscriptions/<id>/let-expire \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-000000000020",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"followup_id": "7a7a7a7a-0000-4000-8000-000000000010",
"contact_id": "7a7a7a7a-0000-4000-8000-000000000030",
"conversation_id": "7a7a7a7a-0000-4000-8000-000000000040",
"status": "open",
"criteria": {
"make": "Mazda",
"model": "CX-5",
"year_min": 2020,
"max_price": 18000000
},
"fire_at": null,
"fired_at": null,
"fired_channel": null,
"pending_event": null,
"pending_fire_at": null,
"pending_meta": null,
"pending_reason": null,
"preferred_channel": "whatsapp",
"created_by_kind": "human_user",
"created_by_id": "7a7a7a7a-0000-4000-8000-000000000060",
"fulfiller_kind": "ai_agent",
"fulfiller_user_id": null,
"consent_message_id": null,
"outbound_action_id": null,
"satisfied_by_message_id": null,
"failure_reason": null,
"terminal_reason": null,
"defer_count": 0,
"notice_count": 0,
"notice_failure_notified_cause": null,
"last_notice_at": null,
"last_notice_unkept_at": null,
"busqueda_swept_at": null,
"expiry_warned_at": null,
"expires_at": "2027-01-08T14:00:00.000Z",
"customer_deadline_at": null,
"fulfilled_at": null,
"fulfilled_by": null,
"fulfilled_vehicle_id": null,
"fulfilment_source": null,
"cancelled_at": null,
"cancelled_by": null,
"cancelled_by_kind": null,
"cancel_reason_code": null,
"cancel_reason": null,
"created_at": "2026-10-10T14:00:00.000Z"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /followups/subscriptions/{id}/renew
Renew a Búsqueda — «Renovar 90 días»
The pre-expiry warning bell’s first action (ADR 0109 §5), and the same act from the list’s history on an already-expired want: the clock moves to NOW + 90 days, capped at the customer’s own deadline when they gave one and it falls earlier — never past it. Renewing an EXPIRED want re-opens it (status: open); renewing an OPEN one just extends it. Either way the pre-expiry warning is re-armed for the new window. No body. A want that is fulfilled or cancelled is a 409.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl -X POST https://api.vitrinadev.com/api/v1/followups/subscriptions/<id>/renew \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-000000000020",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"followup_id": "7a7a7a7a-0000-4000-8000-000000000010",
"contact_id": "7a7a7a7a-0000-4000-8000-000000000030",
"conversation_id": "7a7a7a7a-0000-4000-8000-000000000040",
"status": "open",
"criteria": {
"make": "Mazda",
"model": "CX-5",
"year_min": 2020,
"max_price": 18000000
},
"fire_at": null,
"fired_at": null,
"fired_channel": null,
"pending_event": null,
"pending_fire_at": null,
"pending_meta": null,
"pending_reason": null,
"preferred_channel": "whatsapp",
"created_by_kind": "human_user",
"created_by_id": "7a7a7a7a-0000-4000-8000-000000000060",
"fulfiller_kind": "ai_agent",
"fulfiller_user_id": null,
"consent_message_id": null,
"outbound_action_id": null,
"satisfied_by_message_id": null,
"failure_reason": null,
"terminal_reason": null,
"defer_count": 0,
"notice_count": 0,
"notice_failure_notified_cause": null,
"last_notice_at": null,
"last_notice_unkept_at": null,
"busqueda_swept_at": null,
"expiry_warned_at": null,
"expires_at": "2027-01-12T16:00:00.000Z",
"customer_deadline_at": null,
"fulfilled_at": null,
"fulfilled_by": null,
"fulfilled_vehicle_id": null,
"fulfilment_source": null,
"cancelled_at": null,
"cancelled_by": null,
"cancelled_by_kind": null,
"cancel_reason_code": null,
"cancel_reason": null,
"created_at": "2026-10-10T14:00:00.000Z"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/template-requests
List open WhatsApp template requests
Follow-ups outside WhatsApp’s 24-hour window need an approved template. A recipe that wants one it does not have raises a request here. Always returns the open ones — there is no status filter.
curl https://api.vitrinadev.com/api/v1/followups/template-requests \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"id": "7a7a7a7a-0000-4000-8000-000000000090",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"followup_id": "7a7a7a7a-0000-4000-8000-000000000010",
"status": "open",
"fallback_channel": "email",
"request_count": 4,
"first_seen": "2026-10-05T12:00:00.000Z",
"last_seen": "2026-10-09T17:00:00.000Z",
"followup_name": "Avisar cuando llegue el auto que busca"
}
]
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
PATCH /followups/template-requests/{id}
Dismiss a template request or mark it created
id is a template request. Marking it created only clears the nudge — it submits nothing to Meta.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
status | open \ | created \ | dismissed |
curl -X PATCH https://api.vitrinadev.com/api/v1/followups/template-requests/<id> \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"status": "dismissed"
}'Ejemplo de respuesta (200)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-000000000090",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"followup_id": "7a7a7a7a-0000-4000-8000-000000000010",
"status": "dismissed",
"fallback_channel": "email",
"request_count": 4,
"first_seen": "2026-10-05T12:00:00.000Z",
"last_seen": "2026-10-09T17:00:00.000Z"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /followups/write-first
What Vitrina writes first, one sentence per moment
The «Escribir primero» header: one sentence per proactive tab, generated from the workspace’s real settings, never fixed copy. tabs always holds, in this order, first_touch («Al llegar»: which lead sources get a first message, on which channel, after the first-touch delay), recontacto («Si no responde»: exactly GET /followups/recontacto/plan header_sentence), followups («Avisos»: the active follow-up recipes, grouped by trigger) and post_atencion («Después de atenderlo»: armed service protocols and, for clinics, the presupuesto follow-up). on is false when that rail sends nothing today. pointers lists the rails that also write first but are set elsewhere, only when they do: appointment_reminders, automation_rules (active rules that send a message), surveys (the review request), waitlist (clinics only) and campaigns (always). Each item carries the screen it links to (href). Copy is in lang (default the workspace’s).
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
lang | query | es \ | en | no |
curl https://api.vitrinadev.com/api/v1/followups/write-first \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"locale": "es",
"tabs": [
{
"tab": "first_touch",
"label": "Al llegar",
"sentence": "Un lead del sitio web recibe un WhatsApp a los 5 min si no nos escribió antes.",
"on": true,
"href": "/settings/first-touch"
},
{
"tab": "recontacto",
"label": "Si no responde",
"sentence": "4 mensajes en 5 días; a los 12 días se marca perdido y a los 42 días, uno más con tu aprobación.",
"on": true,
"href": "/settings/seguimientos?tab=recontacto"
},
{
"tab": "followups",
"label": "Avisos",
"sentence": "1 aviso activo: lo que el cliente pidió que le recordáramos.",
"on": true,
"href": "/settings/seguimientos?tab=followups"
},
{
"tab": "post_atencion",
"label": "Después de atenderlo",
"sentence": "Sin pautas activas.",
"on": false,
"href": "/settings/seguimientos?tab=post-atencion"
}
],
"pointers": [
{
"pointer": "appointment_reminders",
"label": "Recordatorios de cita",
"sentence": "Un recordatorio 2 horas antes de cada cita.",
"href": "/settings/recordatorios"
},
{
"pointer": "campaigns",
"label": "Campañas",
"sentence": "Solo salen cuando lanzas una campaña; ninguna en curso.",
"href": "/campaigns"
}
]
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429