VitrinaAPI

Respetar a quien pidió que no le escribas

Cuatro ámbitos de consentimiento, un historial que no se borra

Un cliente respondió «BAJA». Lo que pidió no es silencio: pidió que no le manden más promociones. Si tu integración lo lee como una baja total, deja de avisarle de lo que él mismo pidió que le avisaran. Si no lo lee, le sigue llegando publicidad.

Esta receta anota esa preferencia con el ámbito exacto que el cliente dio. El mismo contacto termina rechazando un mensaje y recibiendo otro en el mismo minuto.

Trampa

Una baja bloquea el marketing, no el servicio

Cuando alguien responde una palabra de baja, Vitrina le contesta: «Listo, no te enviaremos más mensajes promocionales por este canal». La preferencia que se guarda tiene ámbito marketing, no all_proactive. Un Seguimiento que el propio cliente pidió sigue saliendo después de esa baja.

Antes de empezar

  • Una API key con contacts:read para leer el historial y contacts:write para anotar.
  • El id del contacto.
  • Un criterio escrito de qué ámbito corresponde a cada situación. La tabla del paso 2 es el punto de partida.

1. Anota lo que el cliente pidió

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": "blocked",
        "legal_basis": "Respondió BAJA por WhatsApp el 23-09-2026"
      }'
{
  "data": {
    "id": "df71f63d-f49f-4d6d-8c90-e3a506b9817e",
    "tenant_id": "82ee1231-7431-4f63-a716-224d61905e23",
    "contact_id": "01a0ce37-bf26-7e2c-bcc8-ced619c07df6",
    "channel": "whatsapp",
    "scope": "marketing",
    "status": "blocked",
    "source": "admin",
    "evidence_message_id": null,
    "legal_basis": "Respondió BAJA por WhatsApp el 23-09-2026",
    "recorded_at": "2026-09-23T12:22:35.107Z",
    "expires_at": null,
    "created_by": null,
    "created_at": "2026-09-23T12:22:35.107Z"
  }
}

Tres campos hacen el trabajo. channel acota el canal, o vale any para todos. scope acota el tipo de mensaje. status es allowed, blocked o unknown.

legal_basis es texto libre y se guarda literal. Es lo que vas a mostrar el día que alguien pregunte «¿desde cuándo, y cómo lo saben?». Cuando la prueba es un mensaje del propio cliente, evidence_message_id apunta a él, y la respuesta deja de ser una fecha para pasar a ser el mensaje.

recorded_at lo pone el servidor y no se acepta en el cuerpo.

2. Elige el ámbito por lo que el cliente dijo

scopeQué cubreCuándo se usa
marketingPromociones y reactivaciones.La palabra de baja, el enlace de desuscripción, «no me manden más ofertas».
serviceAvisos operativos de algo que el cliente ya tiene en curso.Casi nunca por decisión del cliente: quien pide esto suele estar pidiendo all_proactive.
promised_followupLos avisos que el propio cliente pidió recibir.«Ya no me avises de eso», sobre un aviso puntual.
all_proactiveTodo lo que salga sin que él haya escrito primero.«No me contacten más», en cualquiera de sus formas.

Las palabras que Vitrina reconoce como baja en un mensaje entrante son baja, stop, alto, unsubscribe, no molestar y salir. Solo cuentan cuando son el mensaje completo.

«Cancelar» no cuenta como baja, porque suele usarse para anular una cita o un pedido.

3. Comprueba que el bloqueo muerde

El mismo contacto, la misma conversación, una plantilla MARKETING:

{
  "error": {
    "reasons": ["scope_blocked"],
    "hint": "El contacto se dio de baja de este tipo de mensajes. Respeta su preferencia.",
    "code": "OUTBOUND_BLOCKED",
    "message": "This message was not sent: the outbound policy blocks it (scope_blocked). A block cannot be acknowledged — see error.hint for what to do instead.",
    "details": {
      "reasons": [
        {
          "code": "scope_blocked",
          "kind": "bloqueo",
          "hint": "El contacto se dio de baja de este tipo de mensajes. Respeta su preferencia.",
          "detail": { "scope": "marketing", "source": "contact_outbound_preference" }
        }
      ]
    },
    "requestId": "e7128069-c571-47df-8405-f6fcd2286538"
  }
}

detail.scope dice qué ámbito decidió, y detail.source de dónde salió el dato. Es un bloqueo, así que ningún acknowledge lo levanta. La preferencia la puso el cliente, no quien envía.

4. Comprueba que el servicio sigue pasando

Segundos después, el mismo contacto, el mismo canal, una plantilla UTILITY:

{
  "data": {
    "created_at": "2026-09-23T12:22:35.169854+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 Valentina, quedamos en retomar tu solicitud SOL-4821. ¿La seguimos ahora?",
    "updated_at": "2026-09-23T12:22:35.169854",
    "tool_call_id": null,
    "tool_calls": null,
    "tokens": 0,
    "id": "e53b0168-b7cb-46d2-97fa-2c3691ac544a",
    "tenant_id": "82ee1231-7431-4f63-a716-224d61905e23",
    "sender_type": "api_key",
    "sender_id": "76647f68-da82-4d36-8cba-e97a70661e3d",
    "correlation_id": null,
    "conversation_id": "01a0ce37-bf2f-7430-a199-59c85ba55d35",
    "external_message_id": "sandbox-captured:dcbe70b8-d912-4e19-ab57-530183e84b3e",
    "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
  }
}

La categoría de la plantilla fija la base legal del envío, y la base legal elige el ámbito que se consulta. El bloqueo de un ámbito no afecta a los otros tres.

5. La baja total, y su precedencia

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": "any",
        "scope": "all_proactive",
        "status": "blocked",
        "legal_basis": "Pidió por teléfono no recibir ningún mensaje que no haya pedido"
      }'

Con esa fila puesta, la misma plantilla UTILITY del paso 4 deja de salir:

{
  "error": {
    "reasons": ["scope_blocked"],
    "hint": "El contacto se dio de baja de este tipo de mensajes. Respeta su preferencia.",
    "code": "OUTBOUND_BLOCKED",
    "message": "This message was not sent: the outbound policy blocks it (scope_blocked). A block cannot be acknowledged — see error.hint for what to do instead.",
    "details": {
      "reasons": [
        {
          "code": "scope_blocked",
          "kind": "bloqueo",
          "hint": "El contacto se dio de baja de este tipo de mensajes. Respeta su preferencia.",
          "detail": { "scope": "all_proactive", "source": "contact_outbound_preference" }
        }
      ]
    },
    "requestId": "89987b46-09e9-4f8e-b3fd-08bd96b1fbae"
  }
}

El orden de precedencia, de arriba hacia abajo:

  1. El contacto bloqueado o marcado como spam en su ficha bloquea todo.
  2. Un all_proactive vigente bloquea todo.
  3. La fila vigente del ámbito consultado manda. Una fila de canal específico gana a una de any; entre iguales, gana la más reciente.
  4. Sin ninguna fila, el envío sale.

La ausencia de una preferencia no es un consentimiento, pero tampoco detiene el envío. Anota el consentimiento el día que lo recibes: es tu respaldo ante la Ley 21.719.

6. Revocar, y el historial que queda

No hay PATCH ni DELETE aquí. Revocar es un POST con el estado contrario, y la fila anterior se queda donde está:

{
  "data": [
    {
      "id": "8461b248-323c-46b1-8277-22c639277373",
      "tenant_id": "82ee1231-7431-4f63-a716-224d61905e23",
      "contact_id": "01a0ce37-bf26-7e2c-bcc8-ced619c07df6",
      "channel": "any",
      "scope": "all_proactive",
      "status": "allowed",
      "source": "admin",
      "evidence_message_id": null,
      "legal_basis": "Volvió a pedir el aviso de su solicitud el 23-09-2026",
      "recorded_at": "2026-09-23T12:22:35.238Z",
      "expires_at": null,
      "created_by": null,
      "created_at": "2026-09-23T12:22:35.238Z"
    },
    {
      "id": "16029957-30a2-4c00-81fb-3f004aa1d05c",
      "tenant_id": "82ee1231-7431-4f63-a716-224d61905e23",
      "contact_id": "01a0ce37-bf26-7e2c-bcc8-ced619c07df6",
      "channel": "any",
      "scope": "all_proactive",
      "status": "blocked",
      "source": "admin",
      "evidence_message_id": null,
      "legal_basis": "Pidió por teléfono no recibir ningún mensaje que no haya pedido",
      "recorded_at": "2026-09-23T12:22:35.191Z",
      "expires_at": null,
      "created_by": null,
      "created_at": "2026-09-23T12:22:35.191Z"
    },
    {
      "id": "df71f63d-f49f-4d6d-8c90-e3a506b9817e",
      "tenant_id": "82ee1231-7431-4f63-a716-224d61905e23",
      "contact_id": "01a0ce37-bf26-7e2c-bcc8-ced619c07df6",
      "channel": "whatsapp",
      "scope": "marketing",
      "status": "blocked",
      "source": "admin",
      "evidence_message_id": null,
      "legal_basis": "Respondió BAJA por WhatsApp el 23-09-2026",
      "recorded_at": "2026-09-23T12:22:35.107Z",
      "expires_at": null,
      "created_by": null,
      "created_at": "2026-09-23T12:22:35.107Z"
    }
  ],
  "meta": { "total": 3 }
}

GET /contacts/{id}/outbound-preferences devuelve el historial completo, del hecho más nuevo al más viejo. Una fila superada se sigue leyendo igual que antes. La prueba de que el cliente pidió la baja tiene que sobrevivir al día en que la levantó.

source dice quién escribió cada fila. admin es una persona o una credencial. Las otras se escriben solas: cuando el cliente responde una palabra de baja, cuando usa el enlace de desuscripción, o cuando el proveedor informa un rebote.

Cuando falla

scope_blocked y marketing_consent_missing son bloqueos y no se reintentan. El reintento manda el mismo mensaje al mismo contacto que dijo que no. Lo que sí sirve es ramificar por detail.scope y decidir si hay otra forma legítima de decir lo mismo. Un aviso de servicio no necesita ir como promoción.

Cuando el bloqueo viene de la ficha del contacto y no de esta tabla, el código cambia: contact_blocked o contact_spam. Esos dos se levantan desde la ficha.

En la aplicación: la misma preferencia se anota en la ficha del contacto, y la lista de quienes pidieron no recibir está en Campañas → No contactar. Guía completa en Manual de plataforma → Respetar a quien pidió no escribirle.

La lista No contactar de Vitrina: cada supresión con su motivo, su canal y su fecha

Qué exige la ley chilena sobre estos datos, y cómo se responde a un titular que los reclama, está en Datos personales. El consentimiento que se anota antes del primer mensaje está en Escribir primero por WhatsApp sin quemar el número.

En esta página