Keep a contact's opt-out without silencing what they asked for
Four consent scopes and a history that is never overwritten
A customer answered «BAJA». What they asked for is not silence: they asked for no more promotions. If your integration reads it as a total opt-out, it stops telling them about the very thing they asked to be told about. If it reads nothing, the advertising keeps arriving.
This recipe records that preference with the exact scope the customer gave. The same contact ends up refusing one message and receiving another in the same minute.
Trap
An opt-out blocks marketing, not service
When somebody answers an opt-out word, Vitrina replies: «Listo, no te enviaremos
más mensajes promocionales por este canal». The stored preference has scope
marketing, not all_proactive. A follow-up the customer asked for still goes
out after that opt-out.
Before you start
- An API key with
contacts:readto read the history andcontacts:writeto record. - The contact id.
- A written rule for which scope fits which situation. The table in step 2 is the starting point.
1. Record what the customer asked for
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"
}
}Three fields do the work. channel narrows the channel, or takes any for all of them. scope narrows the kind of message. status is allowed, blocked or unknown.
legal_basis is free text and is stored verbatim. It is what you will show the day somebody asks «since when, and how do you know?». When the proof is a message from the customer, evidence_message_id points at it, and the answer stops being a timestamp and becomes the message.
recorded_at is stamped by the server and is not accepted in the body.
2. Pick the scope by what the customer said
scope | What it covers | When it applies |
|---|---|---|
marketing | Promotions and reactivations. | The opt-out word, the unsubscribe link, «stop sending me offers». |
service | Operational notices about something already under way. | Almost never by customer request: whoever asks for this usually means all_proactive. |
promised_followup | The notices the customer asked to receive. | «Stop telling me about that», about one specific notice. |
all_proactive | Everything that goes out without them writing first. | «Do not contact me again», in any of its forms. |
The words Vitrina reads as an opt-out in an inbound message are baja, stop, alto, unsubscribe, no molestar and salir. They only count as the whole message.
«Cancelar» does not count as an opt-out, because it is usually meant to call off an appointment or an order.
3. Check that the block bites
The same contact, the same conversation, a MARKETING template:
{
"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 says which scope decided, and detail.source where the fact came from. It is a block, so no acknowledge lifts it. The preference was set by the customer, not by whoever is sending.
4. Check that service still gets through
Seconds later, the same contact, the same channel, a UTILITY template:
{
"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
}
}The template's category sets the legal basis of the send, and the legal basis picks the scope to check. A block on one scope does not affect the other three.
5. The total opt-out, and its precedence
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"
}'With that row in place, the same UTILITY template from step 4 stops going out:
{
"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"
}
}The precedence order, top down:
- A contact blocked or flagged as spam on their file blocks everything.
- A current
all_proactiveblock blocks everything. - The current row for the scope being asked about decides. A channel-specific row beats an
anyrow; among equals, the most recentrecorded_atwins. - With no row at all, the send goes out.
The absence of a preference is not a consent, but it does not stop a send either. Record the consent the day you receive it: that is your backing under Ley 21.719.
6. Revoking, and the history that stays
There is no PATCH and no DELETE here. Revoking is a POST with the opposite status, and the earlier row stays where it is:
{
"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 returns the whole history, newest fact first. A superseded row still reads the same as before. The proof that the customer asked to opt out has to outlive the day they lifted it.
source says who wrote each row. admin is a person or a credential. The others write themselves: when the customer answers an opt-out word, when they use the unsubscribe link, or when the provider reports a bounce.
When it fails
scope_blocked and marketing_consent_missing are blocks and are not retried. A retry sends the same message to the same contact who said no. What does help is branching on detail.scope and deciding whether there is another legitimate way to say the same thing. A service notice does not need to travel as a promotion.
When the block comes from the contact's file rather than from this table, the code changes: contact_blocked or contact_spam. Those two are lifted from the file.
In the app: the same preference is recorded on the contact's file, and the list of everyone who opted out lives in Campaigns → Do not contact.

What Chilean law requires about this data, and how to answer a data subject who claims it, is in Personal data. The consent recorded before the first message is in Write first on WhatsApp without burning your number.