Send a template when the 24-hour window has closed
The 24-hour window, the template that reopens it, and the two verdicts
A customer wrote four days ago and nobody got round to answering. Your integration finally sends the missing message, and the API answers 422. Vitrina did not decide that: WhatsApp closed the customer-care window, and only an approved template reopens it.
By the end of this recipe you can read the refusal, pick the right template, and land the message in the conversation with a record of who authorised the send.
Trap
The template's category decides the legal basis, not your intention
A MARKETING template is judged as marketing even when your code uses it to
confirm something. It drags the contact's marketing consent along with it, and
a contact who never gave it gets a 422. No acknowledge lifts that block.
To pick up a service conversation, choose a UTILITY template.
Before you start
- An API key with
conversations:writeandmessages:send. They are two different permissions: the first operates the inbox, the second puts a message in front of a customer (Sending messages). - A Meta-approved template on the same channel account as the conversation.
- The conversation id.
GET /contacts/{id}/conversationslists a contact's threads.
1. Read why the API refuses
Free-form text on a cold conversation does not leave:
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"
}
}Two codes for one situation, and each carries the fact that explains it. window_closed comes with lastInboundAt: the customer's last message, which is where the 24 hours start counting. What you sent last does not count, and a conversation the customer never wrote in has a window that never opened. template_missing says what is missing to get through anyway.
kind: "bloqueo" is the part that changes your code. A bloqueo is a «no» somebody else already gave: the customer, the law, or the channel provider. It cannot be overridden, and hint says what to do instead. The other half of the policy, the advertencia, shows up in step 3.
2. Pick a template that can be sent
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 is the field that decides whether your code can send it:
slotsnames every value you have to supply, with the exact keyparamsexpects. A slot with no value answers422 template_params_incomplete, and that includes theheader_textslot when the template has one.supported: falsemeans Vitrina cannot send that template, because it uses a mediaheaderor variables inside a button. The policy stops it before sending.statusmay have changed at Meta with nobody watching. What you see here is the local mirror, andsynced_atsays how old it is.POST /whatsapp-templates/syncrefreshes it.
category is what the trap above warns about. Both templates in this workspace have an approved body and a different category, and that difference decides who can be written to.
3. Send it
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, and kind: "advertencia". Nobody said no. A teammate is handling this conversation, and writing over them is a risk you may want to take anyway. The policy stops the send once, so the decision is yours and not an automatic retry's.
Unlike the bloqueo in step 1, an advertencia can be acknowledged and resent.
4. Resend with the acknowledgment
The same call, with the codes from error.reasons in the body:
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, with the template already rendered into content. The conversation shows exactly what reached the customer. metadata.template records which template it was, and author records that a credential wrote it. The acknowledgment lands in the audit log beside the send it justified.
Three details about acknowledging, each worth a wasted call:
- Acknowledge the whole list. If
error.reasonscarries two codes and your retry names one, the answer is another409with the missing one. - A bloqueo code inside
acknowledgeis ignored. The answer is the same422, word for word. - Branch on
code, never onhint. The texts are written for a person and their wording can change; the codes are the contract.
5. Rehearse against a number that does not exist
The send above came out of a sandbox workspace. There the policy is evaluated the same, the conversation advances the same and the message is stored the same. The only thing that does not happen is the call to Meta: it gets captured instead. That is what an external_message_id starting with sandbox-captured: means.
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 is the call as it would have gone to Meta, with every parameter mapped to its slot. It is where a badly built params shows up before a customer sees it. How to create a workspace like this one is in Sandbox.
When it fails
The blocks that really show up on this path, and what to do about each:
reasons | What happened | What to do |
|---|---|---|
window_closed, template_missing | The 24-hour window is closed and the send was free text. | Send an approved template. |
template_not_approved | The template is PENDING, REJECTED or PAUSED at Meta. | Sync with POST /whatsapp-templates/sync and pick another. |
template_params_incomplete | A param_spec slot has no value. | Fill every slot, header_text included. |
template_unsupported | The template uses a media header or button variables. | Use another approved template. |
template_category_mismatch | The category does not match the message type. | Pick a template of the right category. |
scope_blocked, marketing_consent_missing | The contact opted out of this kind of message, or never consented. | Honour the preference: Keep a contact's opt-out. |
account_disconnected | The channel account disconnected. | Reconnect it before retrying. |
no_channel_identity | The conversation has no address to send to. | Check the contact has a phone and the conversation a channel account. |
There is one more answer that is neither a block nor a warning. A connected app that already wrote to three distinct contacts in ten minutes does not write to a fourth one directly. The message is parked as a draft in the approval queue, and the answer is 202 with the action id. The full detail is in Sending messages.
In the app: the same template is picked from the inbox composer, which offers it automatically when the window is closed.
The other half of this problem is writing to someone who never wrote to you, and it has its own rules: Write first on WhatsApp without burning your number. The full code catalogue, with both verdicts and the loop protection, is in Sending messages.