VitrinaAPI

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:write and messages: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}/conversations lists 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:

  • slots names every value you have to supply, with the exact key params expects. A slot with no value answers 422 template_params_incomplete, and that includes the header_text slot when the template has one.
  • supported: false means Vitrina cannot send that template, because it uses a media header or variables inside a button. The policy stops it before sending.
  • status may have changed at Meta with nobody watching. What you see here is the local mirror, and synced_at says how old it is. POST /whatsapp-templates/sync refreshes 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.reasons carries two codes and your retry names one, the answer is another 409 with the missing one.
  • A bloqueo code inside acknowledge is ignored. The answer is the same 422, word for word.
  • Branch on code, never on hint. 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:

reasonsWhat happenedWhat to do
window_closed, template_missingThe 24-hour window is closed and the send was free text.Send an approved template.
template_not_approvedThe template is PENDING, REJECTED or PAUSED at Meta.Sync with POST /whatsapp-templates/sync and pick another.
template_params_incompleteA param_spec slot has no value.Fill every slot, header_text included.
template_unsupportedThe template uses a media header or button variables.Use another approved template.
template_category_mismatchThe category does not match the message type.Pick a template of the right category.
scope_blocked, marketing_consent_missingThe contact opted out of this kind of message, or never consented.Honour the preference: Keep a contact's opt-out.
account_disconnectedThe channel account disconnected.Reconnect it before retrying.
no_channel_identityThe 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.

On this page