VitrinaAPI

Follow-ups

Follow-up recipes, each customer's Búsquedas, demand, Recontacto and its review queue.

Beta
This may change at any time, with a changelog entry and a notice to recent callers — see versioning.

Download the full API pública projection: openapi.json.

MethodPathWhat it does
GET/followupsList follow-up recipes
POST/followupsCreate 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}/activeActivate or deactivate a recipe
GET/followups/{id}/waitingWho this recipe is still to tell
GET/followups/{id}/waiting-countHow many subscriptions are waiting on this recipe
GET/followups/attentionWhat needs my attention in «Escribir primero»
GET/followups/busqueda-toleranceThe Búsqueda's soft-bound tolerance (partial match)
PUT/followups/busqueda-toleranceSet the Búsqueda's soft-bound tolerance
GET/followups/busquedasOpen wants, grouped by what is sought
GET/followups/busquedas/historyThe «Historial» tab — fulfilled + expired Búsquedas
GET/followups/channelsWhich channels follow-ups may use
PUT/followups/channelsToggle the channels follow-ups may use
GET/followups/demandList unmet demand groups
POST/followups/demandLog an unmet demand
PATCH/followups/demand/{id}Triage one demand row
POST/followups/demand/{id}/coverCover a demand group with Vitrina
GET/followups/demand/topicsList unmet demand by topic
PATCH/followups/demand/topics/{kind}Dismiss every open demand of a topic
POST/followups/demand/topics/{kind}/coverCover a whole demand topic with Vitrina
GET/followups/lead-reviewGet lead review config and the score calibration curve
PUT/followups/lead-reviewUpdate lead review configuration
GET/followups/overviewRecipes plus channel readiness for the settings screen
GET/followups/pauseSafe pause: live pauses and who changed them
POST/followups/pausePause what Vitrina writes first (all, or one sequence)
GET/followups/recontactoGet the Recontacto cadence configuration
PUT/followups/recontactoUpdate the Recontacto cadence configuration
GET/followups/recontacto/attemptsList Recontacto attempts (ledger and review queue)
POST/followups/recontacto/attempts/{id}/approveApprove a Recontacto draft and send it
POST/followups/recontacto/attempts/{id}/dismissDismiss a Recontacto draft
GET/followups/recontacto/guardsRules every follow-up respects, and today’s non-sends
GET/followups/recontacto/heldThe people behind one «esperando (por qué)» line
GET/followups/recontacto/next-stepsWhat happens next for each of a contact’s leads
GET/followups/recontacto/planWhat a customer who stops answering receives (simulated)
GET/followups/recontacto/plan/after-handoffThe «Ahora» block of the plan, on its own
GET/followups/recontacto/previewPreview today’s Recontacto sweep (dry run)
GET/followups/recontacto/readinessCan Recontacto actually send?
POST/followups/recontacto/runRun the Recontacto sweep now
GET/followups/recontacto/statsRecontacto results by situation, rung and channel
POST/followups/resumeResume a paused scope
GET/followups/subscriptionsList a contact’s follow-up subscriptions
POST/followups/subscriptionsSubscribe 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}/cancelCancel a Búsqueda with a reason
POST/followups/subscriptions/{id}/fulfillMark a Búsqueda fulfilled (the customer got a car)
POST/followups/subscriptions/{id}/let-expireDecline to renew a Búsqueda — «Dejar vencer»
POST/followups/subscriptions/{id}/renewRenew a Búsqueda — «Renovar 90 días»
GET/followups/template-requestsList open WhatsApp template requests
PATCH/followups/template-requests/{id}Dismiss a template request or mark it created
GET/followups/write-firstWhat 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"

Example response (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
    }
  ]
}

Answers: 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.

Body

FieldTypeRequiredConstraints
namestringyesmín. 1, máx. 120
descriptionstringyesmín. 1, máx. 2000
trigger_keyvehicle_in_stock \vehicle_price_drop \appointment_slot_available \
activeboolean—
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
  }'

Example response (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"
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl -X DELETE https://api.vitrinadev.com/api/v1/followups/<id> \
  -H "Authorization: Bearer $VITRINA_KEY"

Answers: 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.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl https://api.vitrinadev.com/api/v1/followups/<id> \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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"
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
idpathuuidyes

Body

FieldTypeRequiredConstraints
namestring—mín. 1, máx. 120
descriptionstring—mín. 1, máx. 2000
trigger_keyvehicle_in_stock \vehicle_price_drop \appointment_slot_available \
activeboolean—
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
  }'

Example response (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
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
idpathuuidyes

Body

FieldTypeRequiredConstraints
activebooleanyes
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
  }'

Example response (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
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl https://api.vitrinadev.com/api/v1/followups/<id>/waiting \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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"
      }
    ]
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl https://api.vitrinadev.com/api/v1/followups/<id>/waiting-count \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": {
    "waiting": 3
  }
}

Answers: 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).

ParameterInTypeRequiredConstraints
langqueryes \enno
curl https://api.vitrinadev.com/api/v1/followups/attention \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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
          }
        ]
      }
    }
  }
}

Answers: 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"

Example response (200)

{
  "data": {
    "budget_pct": 10,
    "km_pct": 15
  }
}

Answers: 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.

Body

FieldTypeRequiredConstraints
budget_pctnumber—≥ 0, ≤ 50
km_pctnumber—≥ 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
  }'

Example response (200)

{
  "data": {
    "budget_pct": 12,
    "km_pct": 15
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
seller_idqueryuuidno
expires_withinquery7 \30 \90
notified_fromquerystringnodate-time
notified_toquerystringnodate-time
noticequerysent \failedno
statusqueryanyno
curl https://api.vitrinadev.com/api/v1/followups/busquedas \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
seller_idqueryuuidno
statusqueryfulfilled \expiredno
curl https://api.vitrinadev.com/api/v1/followups/busquedas/history \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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
  }
}

Answers: 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"

Example response (200)

{
  "data": {
    "whatsapp": true,
    "email": true,
    "sms": false,
    "voice": false
  }
}

Answers: 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.

Body

FieldTypeRequiredConstraints
whatsappboolean—
emailboolean—
smsboolean—
voiceboolean—
curl -X PUT https://api.vitrinadev.com/api/v1/followups/channels \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": false
  }'

Example response (200)

{
  "data": {
    "whatsapp": true,
    "email": false,
    "sms": false,
    "voice": false
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
statusqueryopen \planned \resolved \
curl https://api.vitrinadev.com/api/v1/followups/demand \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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"
      }
    }
  ]
}

Answers: 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.

Body

FieldTypeRequiredConstraints
kindvehicle \vehicle_release \slot \
criteriaobjectyes
summarystringyesmín. 2, máx. 500
normalized_keystring | 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"
  }'

Example response (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
  }
}

Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429

PATCH /followups/demand/{id}

Triage one demand row

id is a demand row, not a followup.

ParameterInTypeRequiredConstraints
idpathuuidyes

Body

FieldTypeRequiredConstraints
statusopen \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"
  }'

Example response (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"
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl -X POST https://api.vitrinadev.com/api/v1/followups/demand/<id>/cover \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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"
    }
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
statusqueryopen \plannedno
curl https://api.vitrinadev.com/api/v1/followups/demand/topics \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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"
    }
  ]
}

Answers: 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.

ParameterInTypeRequiredConstraints
kindpathvehicle \vehicle_release \slot \

Body

FieldTypeRequiredConstraints
statusdismissedyes
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"
  }'

Example response (200)

{
  "data": {
    "updated": 2
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
kindpathvehicle \vehicle_release \slot \
curl -X POST https://api.vitrinadev.com/api/v1/followups/demand/topics/<id>/cover \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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
  }
}

Answers: 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"

Example response (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
      }
    ]
  }
}

Answers: 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.

Body

FieldTypeRequiredConstraints
enabledboolean—
stage_autonomyauto \suggest \off
auto_confidencenumber—≥ 0, ≤ 1
instructionsstring—máx. 4000
half_life_daysinteger—≥ 1, ≤ 365
agreement_doorsobject[]—
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
  }'

Example response (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
      }
    ]
  }
}

Answers: 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"

Example response (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"
      }
    ]
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
history_limitqueryintegerno≥ 1, ≤ 100
curl https://api.vitrinadev.com/api/v1/followups/pause \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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
      }
    ]
  }
}

Answers: 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.

Body

FieldTypeRequiredConstraints
scopestringyesmín. 1, máx. 80
untilstring—date-time
queuePolicykeep \discardyes
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"
  }'

Example response (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
  }
}

Answers: 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"

Example response (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
  }
}

Answers: 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.

Body

FieldTypeRequiredConstraints
enabledboolean—
daily_capinteger | null—≥ 1, ≤ 5000
daily_review_budgetinteger—≥ 50, ≤ 5000
lost_lookback_daysinteger—≥ 7, ≤ 365
max_evaluations_per_stepinteger—≥ 1, ≤ 10
re_evaluation_after_daysinteger—≥ 1, ≤ 90
min_hours_between_touchesinteger | null—≥ 1, ≤ 720
excluded_template_idsuuid[]—
voice_examplesstring[]—
situationsobject—
unanswered_messageobject—
pacingobject—
after_handoffobject—
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
    }
  }'

Example response (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
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
statusqueryproposed \dismissed \sent \
situationquerynever_replied \stalled \lost \
contact_idqueryuuidno
lead_idqueryuuidno
no_send_codequerystringnopatrón ^[a-z0-9_]+(,[a-z0-9_]+)*$
not_sent_sincequerystringnodate-time
statusesquerystringnopatrón ^[a-z_]+(,[a-z_]+)*$
limitqueryintegerno≥ 1, ≤ 200
cursorquerystringnomáx. 120
langqueryes \enno
curl https://api.vitrinadev.com/api/v1/followups/recontacto/attempts \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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
    }
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl -X POST https://api.vitrinadev.com/api/v1/followups/recontacto/attempts/<id>/approve \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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"
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl -X POST https://api.vitrinadev.com/api/v1/followups/recontacto/attempts/<id>/dismiss \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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"
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
langqueryes \enno
curl https://api.vitrinadev.com/api/v1/followups/recontacto/guards \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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
    }
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
codesquerystringyesmín. 1, máx. 500, patrón ^[a-z0-9_]+(,[a-z0-9_]+)*$
langqueryes \enno
curl https://api.vitrinadev.com/api/v1/followups/recontacto/held \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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
    }
  ]
}

Answers: 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}».

ParameterInTypeRequiredConstraints
contact_idqueryuuidno
lead_idqueryuuidno
langqueryes \enno
curl https://api.vitrinadev.com/api/v1/followups/recontacto/next-steps \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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"
    }
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
scenarioqueryconversed \never_replied \lost
startquerystringnodate-time
langqueryes \enno
contact_idqueryuuidno
lead_idqueryuuidno
after_handoff_nowquerytrue \falseno
curl https://api.vitrinadev.com/api/v1/followups/recontacto/plan \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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"
        }
      ]
    }
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
langqueryes \enno
curl https://api.vitrinadev.com/api/v1/followups/recontacto/plan/after-handoff \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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
  }
}

Answers: 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"

Example response (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
    }
  }
}

Answers: 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"

Example response (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
    }
  }
}

Answers: 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"

Example response (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
    }
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
since_daysqueryintegerno≥ 1, ≤ 365
curl https://api.vitrinadev.com/api/v1/followups/recontacto/stats \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": [
    {
      "situation": "stalled",
      "step": 1,
      "channel": "whatsapp",
      "sent": 18,
      "replied": 6,
      "won": 1,
      "recovered_value": 17500000
    }
  ]
}

Answers: 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.

Body

FieldTypeRequiredConstraints
scopestringyesmí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"
  }'

Example response (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
    }
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
contact_idqueryuuidyes
curl https://api.vitrinadev.com/api/v1/followups/subscriptions \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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"
    }
  ]
}

Answers: 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.

Body

FieldTypeRequiredConstraints
followup_iduuidyes
contact_iduuidyes
conversation_idstring | null—
criteriaobject—
fire_atstring | null—date-time
preferred_channelwhatsapp \email \sms \
fulfiller_kindai_agent \human_self—
customer_deadline_atstring | 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"
  }'

Example response (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"
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl -X DELETE https://api.vitrinadev.com/api/v1/followups/subscriptions/<id> \
  -H "Authorization: Bearer $VITRINA_KEY"

Answers: 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.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl https://api.vitrinadev.com/api/v1/followups/subscriptions/<id> \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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
  }
}

Answers: 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).

ParameterInTypeRequiredConstraints
idpathuuidyes

Body

FieldTypeRequiredConstraints
reason_codebought_elsewhere \no_longer_interested \unreachable \
reasonstring | 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"
  }'

Example response (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"
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
idpathuuidyes

Body

FieldTypeRequiredConstraints
vehicle_iduuidyes
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"
  }'

Example response (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"
  }
}

Answers: 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).

ParameterInTypeRequiredConstraints
idpathuuidyes
curl -X POST https://api.vitrinadev.com/api/v1/followups/subscriptions/<id>/let-expire \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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"
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl -X POST https://api.vitrinadev.com/api/v1/followups/subscriptions/<id>/renew \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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"
  }
}

Answers: 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"

Example response (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"
    }
  ]
}

Answers: 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.

ParameterInTypeRequiredConstraints
idpathuuidyes

Body

FieldTypeRequiredConstraints
statusopen \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"
  }'

Example response (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"
  }
}

Answers: 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).

ParameterInTypeRequiredConstraints
langqueryes \enno
curl https://api.vitrinadev.com/api/v1/followups/write-first \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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"
      }
    ]
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

On this page

GET /followupsPOST /followupsDELETE /followups/{id}GET /followups/{id}PUT /followups/{id}POST /followups/{id}/activeGET /followups/{id}/waitingGET /followups/{id}/waiting-countGET /followups/attentionGET /followups/busqueda-tolerancePUT /followups/busqueda-toleranceGET /followups/busquedasGET /followups/busquedas/historyGET /followups/channelsPUT /followups/channelsGET /followups/demandPOST /followups/demandPATCH /followups/demand/{id}POST /followups/demand/{id}/coverGET /followups/demand/topicsPATCH /followups/demand/topics/{kind}POST /followups/demand/topics/{kind}/coverGET /followups/lead-reviewPUT /followups/lead-reviewGET /followups/overviewGET /followups/pausePOST /followups/pauseGET /followups/recontactoPUT /followups/recontactoGET /followups/recontacto/attemptsPOST /followups/recontacto/attempts/{id}/approvePOST /followups/recontacto/attempts/{id}/dismissGET /followups/recontacto/guardsGET /followups/recontacto/heldGET /followups/recontacto/next-stepsGET /followups/recontacto/planGET /followups/recontacto/plan/after-handoffGET /followups/recontacto/previewGET /followups/recontacto/readinessPOST /followups/recontacto/runGET /followups/recontacto/statsPOST /followups/resumeGET /followups/subscriptionsPOST /followups/subscriptionsDELETE /followups/subscriptions/{id}GET /followups/subscriptions/{id}POST /followups/subscriptions/{id}/cancelPOST /followups/subscriptions/{id}/fulfillPOST /followups/subscriptions/{id}/let-expirePOST /followups/subscriptions/{id}/renewGET /followups/template-requestsPATCH /followups/template-requests/{id}GET /followups/write-first