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:writeymessages: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}/conversationslista 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:
slotsnombra cada valor que tienes que entregar, con la clave exacta que esperaparams. Un slot sin valor responde422 template_params_incomplete, y eso incluye el slot deheader_textcuando la plantilla tiene uno.supported: falsesignifica que Vitrina no puede enviar esa plantilla, porque usa unheadermultimedia o variables dentro de un botón. La política la detiene antes de enviarla.statuspuede haber cambiado en Meta sin que nadie lo mire. Lo que ves aquí es el espejo local, consynced_atdiciendo de cuándo es.POST /whatsapp-templates/synclo 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.reasonstrae dos códigos y tu reenvío nombra uno, la respuesta es otro409con el que falta. - Un código de bloqueo dentro de
acknowledgese ignora. La respuesta vuelve a ser el mismo422, palabra por palabra. - Ramifica por
code, nunca porhint. 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:
reasons | Qué pasó | Qué hacer |
|---|---|---|
window_closed, template_missing | La ventana de 24 horas está cerrada y el envío era texto libre. | Manda una plantilla aprobada. |
template_not_approved | La plantilla está PENDING, REJECTED o PAUSED en Meta. | Sincroniza con POST /whatsapp-templates/sync y elige otra. |
template_params_incomplete | Falta el valor de algún slot de param_spec. | Completa todos los slots, incluido el de header_text. |
template_unsupported | La plantilla usa un header multimedia o variables en botones. | Usa otra plantilla aprobada. |
template_category_mismatch | La categoría no corresponde al tipo de mensaje. | Elige una plantilla de la categoría correcta. |
scope_blocked, marketing_consent_missing | El 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_disconnected | La cuenta de canal se desconectó. | Vuelve a conectarla antes de reintentar. |
no_channel_identity | La 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.