Send messages
The permission a send needs, and the outbound policy's two verdicts.
A message that leaves Vitrina reaches a person. So every send made with a credential goes through the outbound policy first. That covers an API key, a personal token and a connected app, and it's the same policy the automatic rails obey. A member typing in the inbox doesn't go through it: they're looking at the conversation already.
The policy stops sends that could damage a number or a customer relationship:
- a contact who asked not to be messaged;
- a closed WhatsApp window;
- a script stuck in a loop;
- an app that misread an instruction.
The permission
Sending needs messages:send, in addition to conversations:write.
Two permissions, because they're two different things. conversations:write is running the inbox: assign, resolve, snooze, tag. messages:send is putting a message in front of a customer. An integration that tidies the inbox does not need the second.
A key with conversations:write and without messages:send:
{
"error": {
"code": "FORBIDDEN",
"message": "Missing required scope: messages:send",
"requestId": "b4bab5e3-56aa-43d2-acdd-90d558a25656"
}
}…and the same key assigns and resolves the conversation without trouble.
The operations that need it:
| Operation | What it sends |
|---|---|
POST /conversations/{id}/messages | Text, and email with cc, bcc and a rich body. |
POST /conversations/{id}/templates | An approved WhatsApp template. |
POST /conversations/{id}/flows | A WhatsApp Flow (an interactive form). |
POST /conversations/{id}/location | A location pin. |
POST /conversations/{id}/attachments | A file or an image, with an optional caption. |
POST /conversations/{id}/voice | A voice note. |
Vitrina's roles carry messages:send. A new key carries it only if you ask for it; see Authentication.
The two verdicts
The policy answers one of two things, and each one asks something different of you.
422 OUTBOUND_BLOCKED: the block
Somebody else has already said no: the contact, the law, or the channel provider. It cannot be overridden. error.hint says what to do instead.
Free-form text into a WhatsApp conversation whose last customer reply was three days ago:
{
"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"
}
}kind and hint come back in Spanish, like every other domain-facing string this API returns. That's the response's wording, not this page's.
Among the things that block:
- a contact who is blocked, marked as spam or unsubscribed from this channel;
- an active outbound hold;
- a closed 24-hour window with no template;
- a template that isn't approved, isn't complete or isn't of the right category for the message;
- a conversation with no address to send to;
- a disconnected channel account;
- a number whose WhatsApp quality rating is red.
409 OUTBOUND_WARNING: the warning
A courtesy, or a risk the sender can knowingly take. It stops the send once.
{
"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"
}
}What warns:
- the workspace's quiet hours;
- a conversation a teammate is handling;
- a closed conversation;
- an archived or merged contact;
- a number with a degraded quality rating;
- a fact that couldn't be verified;
- loop protection.
Resending with acknowledge
The same call again, with the codes from error.reasons in the body:
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"]
}'It answers 201 with the persisted message. The acknowledgment is recorded in the audit log and on the workspace's outbound ledger, next to the send it justified.
Trap
Acknowledging one code of two answers 409 again, with both
Resend with the full list from error.reasons, not with the first one.
A block code inside acknowledge is ignored: the response is the same 422,
word for word.
On a multipart send (a file or a voice note), acknowledge is one more form field, with the codes separated by commas:
curl -X POST https://api.vitrinadev.com/api/v1/conversations/$CONVERSATION/attachments \
-H "Authorization: Bearer $VITRINA_KEY" \
-F "[email protected]" \
-F "caption=Here is the detail in writing." \
-F "acknowledge=quiet_hours"Nothing is stored until the send clears the policy: a 422 or a 409 leaves no half-uploaded file behind.
Loop protection
Two per-credential counters, on sliding windows. Sending the same content to more than twenty distinct contacts within ten minutes raises loop_same_content. Going past sixty sends in a minute raises loop_burst. Both are warnings: a resend with acknowledge goes through.
Connected apps
A connected app acts with the permission of the member who authorised it, and writes to a customer with that person's name behind it. That's why one of its messages is worth a look before it multiplies.
A connected app writes directly to at most three distinct contacts within ten minutes. From the fourth new contact on, its sends land in the approval queue as drafts marked "vía <app>", and wait for a member of the team to approve them.
So the fourth contact answers 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 is Spanish and written for an assistant to relay to the person it's working for. A send to a contact it already wrote to inside that window goes out directly: the limit counts distinct contacts, not messages.
The draft shows up in the queue with the app that wrote it:
{
"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
}
}And a member approves it with POST /outbound/approvals/{id}/approve:
{
"data": {
"outcome": "sent",
"actionId": "2386f55f-438e-4804-9f4b-2a1a3952d579",
"reason": null,
"runId": null
}
}Approving re-evaluates the send against the facts of that moment, rather than the ones the draft was written against. A block that appeared while it waited stops it here. Its warnings don't: the person approving is the acknowledgment. If the customer wrote after the app composed the message, the approval answers stale. The draft returns to the queue so somebody reads the conversation before deciding again.
A draft is approved or rejected exactly as the app wrote it. It isn't editable from the queue.
Trap
The permission set Vitrina proposes when an app is authorised is read-only
It carries neither conversations:write nor messages:send. An app that needs
to write to customers asks for them explicitly, and only then does the
three-contact rule apply to it.
Details worth knowing
A scheduled email is judged when you schedule it. POST /conversations/{id}/messages with send_at holds the fully composed email and fires it later. The policy judges it at the moment you schedule it, on the facts of that moment.
The template's category outranks the request body. A MARKETING template is marketing even if your integration uses it to confirm something, and it brings the marketing consent requirement with it. The request body can't state otherwise: it's read off the template.
Branch on the codes in reasons. message and hint are written for a person and their wording can change. Branch on code, as everywhere else in this API; see Errors.