VitrinaAPI

Reabrir una conversación fría con una plantilla

La ventana de 24 horas, la plantilla que la abre y los dos veredictos

Un cliente escribió hace cuatro días y nadie alcanzó a responderle. Tu integración manda por fin el mensaje que faltaba, y la API contesta 422. No lo decidió Vitrina: WhatsApp cerró la ventana de atención y solo una plantilla aprobada vuelve a abrirla.

Al final de esta receta vas a saber leer la negativa, elegir la plantilla correcta y dejar el mensaje en la conversación, con el rastro de quién autorizó el envío.

Trampa

La categoría de la plantilla decide la base legal, no tu intención

Una plantilla MARKETING se juzga como marketing aunque tu código la use para confirmar algo. Arrastra con ella el consentimiento de marketing del contacto, y a quien nunca lo dio la API le responde 422. Ningún acknowledge levanta ese bloqueo. Para retomar una conversación de servicio, elige una plantilla UTILITY.

Antes de empezar

  • Una API key con conversations:write y messages:send. Son dos permisos distintos: el primero opera la bandeja, el segundo pone un mensaje delante de un cliente (Enviar mensajes).
  • Una plantilla aprobada por Meta, en la misma cuenta de canal que la conversación.
  • El id de la conversación. GET /contacts/{id}/conversations lista las de un contacto.

1. Lee por qué la API se niega

Un texto libre sobre una conversación fría no sale:

curl -X POST https://api.vitrinadev.com/api/v1/conversations/$CONVERSATION/messages \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "content": "Hola Camila, ya te podemos confirmar. ¿Seguimos?" }'
{
  "error": {
    "reasons": ["template_missing", "window_closed"],
    "hint": "La ventana de 24 horas está cerrada: usa una plantilla aprobada (POST /conversations/{id}/templates).",
    "code": "OUTBOUND_BLOCKED",
    "message": "This message was not sent: the outbound policy blocks it (template_missing, window_closed). A block cannot be acknowledged — see error.hint for what to do instead.",
    "details": {
      "reasons": [
        {
          "code": "template_missing",
          "kind": "bloqueo",
          "hint": "La ventana de 24 horas está cerrada: usa una plantilla aprobada (POST /conversations/{id}/templates).",
          "detail": { "channel": "whatsapp", "windowOpen": false }
        },
        {
          "code": "window_closed",
          "kind": "bloqueo",
          "hint": "La ventana de 24 horas está cerrada: usa una plantilla aprobada (POST /conversations/{id}/templates).",
          "detail": { "channel": "whatsapp", "lastInboundAt": "2026-09-19T14:32:11.482+00:00" }
        }
      ]
    },
    "requestId": "98162dba-0df1-4a70-9a5d-1834cadb4170"
  }
}

Son dos códigos para una sola situación, y cada uno trae el dato que lo explica. window_closed viene con lastInboundAt: el último mensaje del cliente, que es desde donde corren las 24 horas. No cuenta lo último que enviaste tú, y una conversación en la que el cliente nunca escribió tiene una ventana que jamás se abrió. template_missing dice qué falta para pasar igual.

kind: "bloqueo" es la parte que cambia tu código. Un bloqueo es un «no» que ya dio alguien más: el cliente, la ley o el proveedor del canal. No se puede pasar por encima, y hint dice qué hacer en su lugar. La otra mitad de la política, la advertencia, aparece en el paso 3.

2. Elige una plantilla que se pueda enviar

curl "https://api.vitrinadev.com/api/v1/whatsapp-templates?status=APPROVED" \
  -H "Authorization: Bearer $VITRINA_KEY"
{
  "data": [
    {
      "id": "4a1e0001-0000-4000-8000-000000000001",
      "messaging_account_id": "0c822e50-8257-45b1-9501-ef5f2662ad2a",
      "name": "retomar_solicitud",
      "language": "es_CL",
      "category": "UTILITY",
      "body_text": "Hola {{nombre}}, quedamos en retomar tu solicitud {{referencia}}. ¿La seguimos ahora?",
      "header_text": null,
      "footer_text": "Responde BAJA para dejar de recibir estos mensajes",
      "params": ["nombre", "referencia"],
      "param_meta": {},
      "param_binding": {},
      "components": null,
      "status": "APPROVED",
      "rejection_reason": null,
      "managed_for": null,
      "managed_key": null,
      "synced_at": "2026-09-23T11:50:18.736Z",
      "created_at": "2026-09-23T11:50:18.736Z",
      "updated_at": "2026-09-23T11:50:18.736Z",
      "param_spec": {
        "slots": [
          { "key": "nombre", "component": "body" },
          { "key": "referencia", "component": "body" }
        ],
        "unsupported": [],
        "supported": true
      }
    },
    {
      "id": "4a1e0001-0000-4000-8000-000000000002",
      "messaging_account_id": "0c822e50-8257-45b1-9501-ef5f2662ad2a",
      "name": "novedades_del_mes",
      "language": "es_CL",
      "category": "MARKETING",
      "body_text": "Hola {{nombre}}, este mes tenemos novedades para ti. ¿Te las cuento?",
      "header_text": null,
      "footer_text": "Responde BAJA para dejar de recibir estos mensajes",
      "params": ["nombre"],
      "param_meta": {},
      "param_binding": {},
      "components": null,
      "status": "APPROVED",
      "rejection_reason": null,
      "managed_for": null,
      "managed_key": null,
      "synced_at": "2026-09-23T11:50:18.736Z",
      "created_at": "2026-09-23T11:50:18.736Z",
      "updated_at": "2026-09-23T11:50:18.736Z",
      "param_spec": {
        "slots": [{ "key": "nombre", "component": "body" }],
        "unsupported": [],
        "supported": true
      }
    }
  ],
  "meta": { "total": 2 }
}

param_spec es el campo que decide si tu código puede enviarla:

  • slots nombra cada valor que tienes que entregar, con la clave exacta que espera params. Un slot sin valor responde 422 template_params_incomplete, y eso incluye el slot de header_text cuando la plantilla tiene uno.
  • supported: false significa que Vitrina no puede enviar esa plantilla, porque usa un header multimedia o variables dentro de un botón. La política la detiene antes de enviarla.
  • status puede haber cambiado en Meta sin que nadie lo mire. Lo que ves aquí es el espejo local, con synced_at diciendo de cuándo es. POST /whatsapp-templates/sync lo refresca.

category es lo que advierte el aviso de arriba. Las dos plantillas de este workspace tienen el mismo cuerpo aprobado y distinta categoría, y esa diferencia decide a quién se le puede escribir.

3. Envíala

curl -X POST https://api.vitrinadev.com/api/v1/conversations/$CONVERSATION/templates \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "template_id": "4a1e0001-0000-4000-8000-000000000001",
        "params": { "nombre": "Camila", "referencia": "SOL-4821" }
      }'
{
  "error": {
    "reasons": ["conversation_human_owned"],
    "hint": "Un miembro del equipo está atendiendo esta conversación. Si igual quieres enviarlo, reenvía con acknowledge: [\"conversation_human_owned\"].",
    "code": "OUTBOUND_WARNING",
    "message": "This message was not sent yet: the outbound policy warns about conversation_human_owned. Resend the same request with \"acknowledge\": [\"conversation_human_owned\"] to send it anyway; the acknowledgment is recorded.",
    "details": {
      "reasons": [
        {
          "code": "conversation_human_owned",
          "kind": "advertencia",
          "hint": "Un miembro del equipo está atendiendo esta conversación. Si igual quieres enviarlo, reenvía con acknowledge: [\"conversation_human_owned\"].",
          "detail": { "conversationId": "01a0ce1a-43b7-7c99-8e01-44ac541fa39e" }
        }
      ]
    },
    "requestId": "5394753c-dc89-4d4a-a70c-ea21390577e9"
  }
}

409, y kind: "advertencia". Nadie dijo que no. Esta conversación la está atendiendo una persona del equipo, y escribir encima de ella es un riesgo que quizá quieras correr igual. La política detiene el envío una vez para que lo decidas tú y no un reintento automático.

A diferencia del bloqueo del paso 1, una advertencia se puede reconocer y reenviar.

4. Reenvía con el reconocimiento

La misma llamada, con los códigos de error.reasons en el cuerpo:

curl -X POST https://api.vitrinadev.com/api/v1/conversations/$CONVERSATION/templates \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "template_id": "4a1e0001-0000-4000-8000-000000000001",
        "params": { "nombre": "Camila", "referencia": "SOL-4821" },
        "acknowledge": ["conversation_human_owned"]
      }'
{
  "data": {
    "created_at": "2026-09-23T12:14:02.304216+00:00",
    "sender_role": "assistant",
    "metadata": {
      "template": {
        "id": "4a1e0001-0000-4000-8000-000000000001",
        "name": "retomar_solicitud",
        "language": "es_CL"
      }
    },
    "type": "text",
    "media_urls": null,
    "content": "Hola Camila, quedamos en retomar tu solicitud SOL-4821. ¿La seguimos ahora?",
    "updated_at": "2026-09-23T12:14:02.304216",
    "tool_call_id": null,
    "tool_calls": null,
    "tokens": 0,
    "id": "42cb9cd4-00d8-4d3a-bdff-bf69cdd21a8d",
    "tenant_id": "82ee1231-7431-4f63-a716-224d61905e23",
    "sender_type": "api_key",
    "sender_id": "76647f68-da82-4d36-8cba-e97a70661e3d",
    "correlation_id": null,
    "conversation_id": "01a0ce1a-43b7-7c99-8e01-44ac541fa39e",
    "external_message_id": "sandbox-captured:8ace6cbc-6db8-4e26-9942-b5651e4b94e3",
    "delivery_status": "sent",
    "delivery_error": null,
    "delivered_at": null,
    "read_at": null,
    "media_sensitivity": null,
    "media_processing_state": null,
    "derived_context_expires_at": null,
    "media_expires_at": null,
    "next_attempt_at": null,
    "delivery_attempt": 0,
    "author": {
      "id": "76647f68-da82-4d36-8cba-e97a70661e3d",
      "kind": "api_key",
      "name": "mi-integracion"
    },
    "rfc822_message_id": null
  }
}

201, y la plantilla ya renderizada en content. La conversación muestra exactamente lo que le llegó al cliente. metadata.template deja anotado cuál plantilla fue, y author, que la escribió una credencial. El reconocimiento queda en el registro de auditoría junto al envío que justificó.

Tres detalles del reconocimiento que cuestan una llamada perdida cada uno:

  • Reconoce la lista completa. Si error.reasons trae dos códigos y tu reenvío nombra uno, la respuesta es otro 409 con el que falta.
  • Un código de bloqueo dentro de acknowledge se ignora. La respuesta vuelve a ser el mismo 422, palabra por palabra.
  • Ramifica por code, nunca por hint. Los textos están escritos para una persona y pueden cambiar de redacción; los códigos son el contrato.

5. Ensaya contra un número que no existe

El envío de arriba salió de un workspace sandbox. Ahí la política se evalúa igual, la conversación avanza igual y el mensaje se guarda igual. Lo único que no ocurre es la llamada a Meta: queda capturada. Eso significa el external_message_id que empieza con sandbox-captured:.

curl "https://api.vitrinadev.com/api/v1/sandbox/outbound?rail=whatsapp&limit=1" \
  -H "Authorization: Bearer $VITRINA_KEY"
{
  "data": [
    {
      "id": "8ace6cbc-6db8-4e26-9942-b5651e4b94e3",
      "tenant_id": "82ee1231-7431-4f63-a716-224d61905e23",
      "rail": "whatsapp",
      "provider": "whatsapp_cloud",
      "destination": "56952145670",
      "subject": null,
      "body": "[plantilla retomar_solicitud]",
      "payload": {
        "args": [
          "56952145670",
          {
            "name": "retomar_solicitud",
            "language": "es_CL",
            "components": [
              {
                "type": "body",
                "parameters": [
                  { "text": "Camila", "type": "text", "parameter_name": "nombre" },
                  { "text": "SOL-4821", "type": "text", "parameter_name": "referencia" }
                ]
              }
            ]
          }
        ],
        "method": "sendWhatsAppTemplate",
        "messaging_account_id": "0c822e50-8257-45b1-9501-ef5f2662ad2a"
      },
      "conversation_id": null,
      "origin": "provider.sendWhatsAppTemplate",
      "idempotency_key": null,
      "captured_at": "2026-09-23T12:14:02.299Z"
    }
  ],
  "meta": {
    "summary": [{ "rail": "whatsapp", "count": 6 }],
    "rails": [
      "whatsapp", "email", "instagram", "messenger", "webchat",
      "voice", "webhook", "tax_document", "portal", "other"
    ]
  }
}

payload.args es la llamada tal como habría salido a Meta, con cada parámetro mapeado a su slot. Es el lugar donde se ve un params mal armado antes de que lo vea un cliente. Cómo se crea un workspace de este tipo está en Sandbox.

Cuando falla

Los bloqueos que aparecen de verdad en este camino, y qué hacer con cada uno:

reasonsQué pasóQué hacer
window_closed, template_missingLa ventana de 24 horas está cerrada y el envío era texto libre.Manda una plantilla aprobada.
template_not_approvedLa plantilla está PENDING, REJECTED o PAUSED en Meta.Sincroniza con POST /whatsapp-templates/sync y elige otra.
template_params_incompleteFalta el valor de algún slot de param_spec.Completa todos los slots, incluido el de header_text.
template_unsupportedLa plantilla usa un header multimedia o variables en botones.Usa otra plantilla aprobada.
template_category_mismatchLa categoría no corresponde al tipo de mensaje.Elige una plantilla de la categoría correcta.
scope_blocked, marketing_consent_missingEl contacto se dio de baja de este tipo de mensajes, o nunca consintió.Respeta la preferencia: Respetar a quien pidió que no le escribas.
account_disconnectedLa cuenta de canal se desconectó.Vuelve a conectarla antes de reintentar.
no_channel_identityLa conversación no tiene dirección a la que enviar.Revisa que el contacto tenga teléfono y que la conversación tenga cuenta de canal.

Hay una respuesta más que no es ni bloqueo ni advertencia. Una aplicación conectada que ya escribió a tres contactos distintos en diez minutos no escribe al cuarto directamente. El mensaje queda como borrador en la cola de aprobaciones, y la respuesta es 202 con el id de la acción. El detalle completo está en Enviar mensajes.

En la aplicación: la misma plantilla se elige desde el compositor de la bandeja, que la ofrece automáticamente cuando la ventana está cerrada. Guía completa en Manual de plataforma → Reabrir una conversación fría.

La otra mitad de este problema es escribirle a alguien que nunca te escribió, y tiene sus propias reglas: Escribir primero por WhatsApp sin quemar el número. El catálogo completo de códigos, con los dos veredictos y la protección contra bucles, está en Enviar mensajes.

En esta página