VitrinaAPI

Write first on WhatsApp without burning your number

Consent, template category and the limits that protect your number

Somebody left their phone on a form and never wrote to you. That first message is the most expensive one you will send. If it annoys, the person blocks the number, and WhatsApp scores that block against your whole account.

Reopening a conversation recovers a relationship that exists. Opening one asks for permission. This recipe builds that first message with the three things that make it acceptable: a real address, a recorded consent and the right category.

Trap

The thread you open through the API has no customer address yet

POST /conversations records the thread, but it does not resolve where to send: the conversation comes back with external_id shaped like manual:<uuid>, on every channel. That identifier is not a phone number, and it is exactly what the API hands the provider on a send. Read external_id before sending anything. The thread that can be sent on is the one the customer opened by writing to you, or the one a follow-up they asked for opened.

Before you start

  • An API key with conversations:write, messages:send and contacts:write.
  • The contact's phone in E.164 format (+56944180332).
  • A Meta-approved template on the channel account that will send.
curl -X POST https://api.vitrinadev.com/api/v1/contacts/$CONTACT/outbound-preferences \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "channel": "whatsapp",
        "scope": "marketing",
        "status": "allowed",
        "legal_basis": "Marcó la casilla de novedades en el formulario del sitio, 23-09-2026"
      }'
{
  "data": {
    "id": "c70ac636-af84-4d05-bb6f-c8a27b793c14",
    "tenant_id": "82ee1231-7431-4f63-a716-224d61905e23",
    "contact_id": "01a0ce32-960a-7e88-aa0a-045eba7cfbdf",
    "channel": "whatsapp",
    "scope": "marketing",
    "status": "allowed",
    "source": "admin",
    "evidence_message_id": null,
    "legal_basis": "Marcó la casilla de novedades en el formulario del sitio, 23-09-2026",
    "recorded_at": "2026-09-23T12:17:45.114Z",
    "expires_at": null,
    "created_by": null,
    "created_at": "2026-09-23T12:17:45.114Z"
  }
}

A contact with no row here is not blocked. The platform treats them as allowed and the send goes out. That is not a permission from the customer.

Under Chile's Ley 21.719 what defends you is the row: its legal_basis written in words, and its recorded_at stamped by the server. That field is not accepted in the body. When the proof is a message from the customer, point at it with evidence_message_id.

2. Check that the window never opened

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 Ignacio, te escribo por la solicitud que dejaste en el sitio." }'
{
  "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": null }
        }
      ]
    },
    "requestId": "6e3b20da-cc94-468b-8c31-b8948643ac03"
  }
}

lastInboundAt: null is the difference with a cold conversation. There it holds an old date; here it holds none. The customer-care window does not open on what you send, only on what the customer writes. A first contact, then, always goes out as a template.

This call is not a required step. It is the cheap way to learn the state of the thread before spending a template.

3. Pick the category by what you are about to say

The category pins the legal basis of the message. Your code cannot declare a different one in the call body:

CategoryLegal basisConsent it requires
UTILITYserviceNone specific. It is an obligation towards someone who already asked.
MARKETINGmarketingThe contact's marketing scope. Without it, 422 marketing_consent_missing.
AUTHENTICATIONserviceNone. It is a code the customer asked for.

Sending a promotion with a UTILITY template is not the way out either. Meta classifies templates by their content at approval time, and one that invites a purchase comes back rejected or reclassified. The category is Meta's verdict on what the template says.

4. Send the first message

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-000000000002",
        "params": { "nombre": "Ignacio" }
      }'
{
  "data": {
    "created_at": "2026-09-23T12:17:45.980261+00:00",
    "sender_role": "assistant",
    "metadata": {
      "template": {
        "id": "4a1e0001-0000-4000-8000-000000000002",
        "name": "novedades_del_mes",
        "language": "es_CL"
      }
    },
    "type": "text",
    "media_urls": null,
    "content": "Hola Ignacio, este mes tenemos novedades para ti. ¿Te las cuento?",
    "updated_at": "2026-09-23T12:17:45.980261",
    "tool_call_id": null,
    "tool_calls": null,
    "tokens": 0,
    "id": "42ff6abc-f157-4875-b9f2-66e9ebd9f4e5",
    "tenant_id": "82ee1231-7431-4f63-a716-224d61905e23",
    "sender_type": "api_key",
    "sender_id": "76647f68-da82-4d36-8cba-e97a70661e3d",
    "correlation_id": null,
    "conversation_id": "01a0ce32-962e-720b-9bbc-d4ff5ec8a1fd",
    "external_message_id": "sandbox-captured:efe8a445-54a6-4012-93c4-ec5c53599a5c",
    "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
  }
}

The response carries the rendered body. That template ends with «Responde BAJA para dejar de recibir estos mensajes», a visible way out that reduces complaints. What happens when somebody uses it is in Keep a contact's opt-out.

5. Know the limit before you hit it

Writing to one person is easy. The damage shows up when a loop gets away and writes to four hundred. The policy keeps two counters per credential, on rolling windows. Here is the second one being crossed:

{
  "error": {
    "reasons": ["loop_burst"],
    "hint": "Esta credencial envió más de 60 mensajes en el último minuto. Si igual quieres enviarlo, reenvía con acknowledge: [\"loop_burst\"].",
    "code": "OUTBOUND_WARNING",
    "message": "This message was not sent yet: the outbound policy warns about loop_burst. Resend the same request with \"acknowledge\": [\"loop_burst\"] to send it anyway; the acknowledgment is recorded.",
    "details": {
      "reasons": [
        {
          "code": "loop_burst",
          "kind": "advertencia",
          "hint": "Esta credencial envió más de 60 mensajes en el último minuto. Si igual quieres enviarlo, reenvía con acknowledge: [\"loop_burst\"].",
          "detail": { "sendsLastMinute": 60, "limit": 60 }
        }
      ]
    },
    "requestId": "1a4c7a3d-e1eb-4d63-9b23-16e10183fa54"
  }
}
  • loop_burst counts sends per credential: more than sixty in a minute. detail carries the counter and the limit, so your code can wait instead of retrying.
  • loop_same_content counts recipients: the same content to more than twenty distinct contacts in ten minutes.

Both are warnings, so a resend with acknowledge goes through. They protect the number's quality rating when an integration gets stuck retrying.

What the API cannot protect for you

The policy does not refuse sends based on the quality rating WhatsApp assigns to your number.

What it does observe is the channel state. A disconnected account is a block (account_disconnected). An account flagged for review raises account_quality_degraded, a warning about discretionary traffic.

The number's quality depends on three decisions of yours, all made before the call: who you write to, with which category, and how often.

In the app: the same first message is sent from the inbox composer by picking a template. The consent is recorded on the contact's file.

If the conversation already existed and only went cold, the path is a different one: Send a template when the 24-hour window has closed. To create the template and follow its approval, see WhatsApp templates and Flows.

On this page