Enviar mensajes
Qué permiso pide un envío y los dos veredictos de la política de envíos.
Un mensaje que sale de Vitrina llega a una persona. Por eso todo envío hecho con una credencial pasa antes por la política de envíos. Vale para una API key, un token personal y una aplicación conectada, y es la misma política que rige los envíos automáticos. Un miembro escribiendo en la bandeja no pasa por ella: ya está mirando la conversación.
La política frena los envíos que pueden dañar un número o la relación con un cliente:
- un contacto que pidió no recibir mensajes;
- una ventana de WhatsApp cerrada;
- un script en bucle;
- una aplicación que entendió mal una instrucción.
El permiso
Enviar pide messages:send, además de conversations:write.
Son dos permisos porque son dos cosas distintas. conversations:write es operar la bandeja: asignar, resolver, posponer, etiquetar. messages:send es poner un mensaje delante de un cliente. Una integración que ordena la bandeja no necesita lo segundo.
Una key con conversations:write y sin messages:send:
{
"error": {
"code": "FORBIDDEN",
"message": "Missing required scope: messages:send",
"requestId": "b4bab5e3-56aa-43d2-acdd-90d558a25656"
}
}…y la misma key asigna y resuelve la conversación sin problema.
Las operaciones que lo piden:
| Operación | Qué envía |
|---|---|
POST /conversations/{id}/messages | Texto, y correo con cc, bcc y cuerpo enriquecido. |
POST /conversations/{id}/templates | Una plantilla aprobada de WhatsApp. |
POST /conversations/{id}/flows | Un formulario (Flow) de WhatsApp. |
POST /conversations/{id}/location | Una ubicación. |
POST /conversations/{id}/attachments | Un archivo o una imagen, con caption opcional. |
POST /conversations/{id}/voice | Una nota de voz. |
Los roles de Vitrina traen messages:send. Una key nueva lo lleva solo si lo pides; está en Autenticación.
Los dos veredictos
La política responde una de dos cosas, y cada una te pide algo distinto.
422 OUTBOUND_BLOCKED: el bloqueo de envío
Alguien más ya dijo que no: el contacto, la ley o el proveedor del canal. No se puede pasar por encima. error.hint dice qué hacer en su lugar.
Un texto libre a una conversación de WhatsApp cuya última respuesta del cliente fue hace tres días:
{
"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-19T03:19:38.824255+00:00" }
}
]
},
"requestId": "03c42c0e-0769-4659-b500-6464a85c3a73"
}
}Bloquean, entre otros:
- un contacto bloqueado, marcado como spam o dado de baja de este canal;
- una retención de envíos activa;
- la ventana de 24 horas cerrada sin plantilla;
- una plantilla que no está aprobada, no está completa o no corresponde a la categoría del mensaje;
- una conversación sin dirección a la que enviar;
- una cuenta de canal desconectada;
- un número con calidad en rojo en WhatsApp.
409 OUTBOUND_WARNING: la advertencia de envío
Una cortesía o un riesgo que quien envía puede asumir a sabiendas. Detiene el envío una vez.
{
"error": {
"reasons": ["quiet_hours", "conversation_human_owned"],
"hint": "Es horario de silencio del espacio de trabajo. Si igual quieres enviarlo, reenvía con acknowledge: [\"quiet_hours\"].",
"code": "OUTBOUND_WARNING",
"message": "This message was not sent yet: the outbound policy warns about quiet_hours, conversation_human_owned. Resend the same request with \"acknowledge\": [\"quiet_hours\",\"conversation_human_owned\"] to send it anyway; the acknowledgment is recorded.",
"details": {
"reasons": [
{
"code": "quiet_hours",
"kind": "advertencia",
"hint": "Es horario de silencio del espacio de trabajo. Si igual quieres enviarlo, reenvía con acknowledge: [\"quiet_hours\"].",
"detail": {
"timezone": "UTC",
"basis": "service",
"basisAssumed": false,
"localHour": 3,
"windowStartHour": 9,
"windowEndHour": 20,
"retryAfter": "2026-09-22T09:00:00.000Z"
}
},
{
"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": "2476d000-0000-4000-8000-000000000002" }
}
]
},
"requestId": "c29e8967-a22f-4b83-8c62-a9c5002ea77a"
}
}Advierten:
- el horario de silencio del espacio de trabajo;
- una conversación que está atendiendo un miembro del equipo;
- una conversación cerrada;
- un contacto archivado o fusionado;
- un número con calidad degradada;
- un dato que no se pudo verificar;
- la protección contra bucles.
Reenviar con acknowledge
La misma llamada otra vez, con los códigos de error.reasons en el cuerpo:
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, te confirmo tu cita para el jueves a las 10.",
"acknowledge": ["quiet_hours", "conversation_human_owned"]
}'Responde 201 con el mensaje persistido. El reconocimiento queda anotado en los logs de auditoría y en el registro de envíos del espacio de trabajo, junto al envío que justificó.
Trampa
Reconocer un código de dos vuelve a responder 409 con los dos
Reenvía con la lista completa de error.reasons, no con el primero.
Un código de bloqueo dentro de acknowledge se ignora: la respuesta es el mismo
422, palabra por palabra.
En un envío multipart (un archivo o una nota de voz), acknowledge va como un campo más del formulario, con los códigos separados por coma:
curl -X POST https://api.vitrinadev.com/api/v1/conversations/$CONVERSATION/attachments \
-H "Authorization: Bearer $VITRINA_KEY" \
-F "[email protected]" \
-F "caption=Te dejo el detalle por escrito." \
-F "acknowledge=quiet_hours"Nada se guarda mientras el envío no pase la política: un 422 o un 409 no deja el archivo subido a medias.
Protección contra bucles
Dos contadores por credencial, en ventanas móviles. Enviar el mismo contenido a más de veinte contactos distintos en diez minutos levanta loop_same_content. Pasar de sesenta envíos en un minuto levanta loop_burst. Los dos son advertencias: un reenvío con acknowledge sigue adelante.
Aplicaciones conectadas
Una aplicación conectada actúa con el permiso del miembro que la autorizó, y escribe a un cliente con su nombre detrás. Por eso conviene mirar uno de sus mensajes antes de que se multiplique.
Una aplicación conectada escribe directamente a hasta tres contactos distintos en diez minutos. A partir del cuarto contacto nuevo, sus envíos entran a la cola de aprobaciones como borradores «vía <aplicación>», y esperan a que un miembro del equipo los apruebe.
El cuarto contacto, entonces, responde 202:
{
"data": {
"status": "pending_approval",
"action_id": "2386f55f-438e-4804-9f4b-2a1a3952d579",
"message": "El mensaje quedó pendiente de aprobación: Asistente de ventas ya escribió a 3 contactos distintos en los últimos 10 minutos, así que un miembro del equipo debe aprobarlo en Vitrina (Seguimientos › Aprobaciones) antes de que se envíe."
}
}data.message viene en español y está escrito para que un asistente se lo repita a la persona con la que está trabajando. Un envío a un contacto al que ya le escribió en esa misma ventana sale directo: el límite cuenta contactos distintos, no mensajes.
El borrador aparece en la cola con la aplicación que lo escribió:
{
"data": {
"items": [
{
"actionId": "2386f55f-438e-4804-9f4b-2a1a3952d579",
"rail": "api",
"purpose": "conversation_reply",
"obligationClass": "transactional_service",
"contactId": "2476c000-0000-4000-8000-000000000006",
"conversationId": "2476d000-0000-4000-8000-000000000006",
"message": "Hola, te escribo para coordinar tu próxima cita.",
"reason": "Escrito vía Asistente de ventas. Quedó en espera porque esta aplicación ya escribió a 3 contactos distintos en los últimos 10 minutos.",
"channel": "web",
"via": { "kind": "connected_app", "name": "Asistente de ventas" },
"decisionVersion": 0,
"expired": false
}
],
"total": 1
}
}Y un miembro lo aprueba con POST /outbound/approvals/{id}/approve:
{
"data": {
"outcome": "sent",
"actionId": "2386f55f-438e-4804-9f4b-2a1a3952d579",
"reason": null,
"runId": null
}
}Aprobar vuelve a evaluar el envío con los hechos del momento, no con los de cuando se escribió el borrador. Un bloqueo que apareció mientras esperaba lo detiene aquí. Sus advertencias no: la persona que aprueba es el reconocimiento. Si el cliente escribió después de que la aplicación redactó el mensaje, la aprobación responde stale. El borrador vuelve a la cola para que alguien lea la conversación antes de decidir de nuevo.
El borrador se aprueba o se rechaza tal como la aplicación lo escribió. No se edita desde la cola.
Trampa
El conjunto de permisos que Vitrina propone al autorizar una aplicación es de solo lectura
No incluye conversations:write ni messages:send. Una aplicación que necesite
escribir a clientes los pide explícitamente, y solo entonces queda sujeta a la
regla de los tres contactos.
Detalles que conviene saber
Un correo programado se evalúa al programarlo. POST /conversations/{id}/messages con send_at retiene el correo ya compuesto y lo dispara después. La política lo juzga en el momento en que lo programas, con los hechos de ese momento.
La categoría de la plantilla manda sobre el cuerpo de la llamada. Una plantilla MARKETING es marketing aunque tu integración la use para confirmar algo, y arrastra el consentimiento de marketing con ella. Eso no se puede escribir en el cuerpo de la llamada: se lee de la plantilla.
Ramifica por los códigos de reasons. message y hint están escritos para una persona y pueden cambiar de redacción. Ramifica por code, como en el resto de la API; está en Errores.