Conversaciones
Los hilos con clientes y pacientes: leer la bandeja, responder, asignar, posponer y resolver — con el autor de cada mensaje.
Descarga la proyección completa de la API pública: openapi.json.
Responder hilos y resolver tickets explica este recurso en prosa, con ejemplos ejecutables.
| Método | Ruta | Qué hace |
|---|---|---|
GET | /conversations | List conversations |
POST | /conversations | Open a conversation from the platform side |
GET | /conversations/{id} | Get conversation |
GET | /conversations/{id}/ai-status | Why the AI is silent on this conversation |
POST | /conversations/{id}/assign | Assign a conversation to a teammate |
POST | /conversations/{id}/attach-to-ticket | Attach this conversation to an existing ticket |
POST | /conversations/{id}/attachments | Send a file to the customer (multipart/form-data) |
GET | /conversations/{id}/attributes | List the attribute values on a conversation |
PUT | /conversations/{id}/attributes | Upsert attribute values on a conversation |
DELETE | /conversations/{id}/attributes/{key} | Delete one attribute value from a conversation |
GET | /conversations/{id}/calls | List a conversation's call segments |
GET | /conversations/{id}/calls/{call_id}/recording-url | Mint a short-lived playback URL for a call recording |
POST | /conversations/{id}/claim | Claim a conversation for the caller |
POST | /conversations/{id}/close | Close a conversation (terminal) |
POST | /conversations/{id}/detach-from-ticket | Detach this conversation from its ticket |
GET | /conversations/{id}/export | Export the whole conversation (json | markdown) |
POST | /conversations/{id}/filter-sender | Denylist the sender and quarantine this conversation |
POST | /conversations/{id}/flow-media | Mint a media_id for a Flow header (multipart/form-data) |
POST | /conversations/{id}/flows | Send a published WhatsApp Flow |
POST | /conversations/{id}/handoff | Hand the conversation off to a human team |
GET | /conversations/{id}/linked-records | The ticket and open leads this conversation's work lives on |
POST | /conversations/{id}/location | Send a location pin to the customer |
GET | /conversations/{id}/messages | Paginated message playback |
POST | /conversations/{id}/messages | Send a message to the customer |
POST | /conversations/{id}/messages/{messageId}/transcribe | Transcribe an inbound audio message |
GET | /conversations/{id}/notes | List internal notes on a conversation |
POST | /conversations/{id}/notes | Add an internal note |
DELETE | /conversations/{id}/notes/{noteId} | Delete an internal note |
PATCH | /conversations/{id}/notes/{noteId} | Edit an internal note |
GET | /conversations/{id}/participants | Teammates who have been on this conversation |
POST | /conversations/{id}/pending | Mark a conversation pending (waiting, with no timer) |
POST | /conversations/{id}/reopen | Reopen a resolved, snoozed or closed conversation |
POST | /conversations/{id}/resolve | Resolve a conversation |
POST | /conversations/{id}/return-to-ai | Return handling to the AI |
GET | /conversations/{id}/scheduled-messages | List pending scheduled sends |
DELETE | /conversations/{id}/scheduled-messages/{scheduledId} | Cancel a scheduled send |
POST | /conversations/{id}/snooze | Snooze a conversation until a time or for N minutes |
GET | /conversations/{id}/tags | List the tags on a conversation |
POST | /conversations/{id}/tags | Attach a tag to a conversation |
DELETE | /conversations/{id}/tags/{tag_id} | Detach a tag from a conversation |
POST | /conversations/{id}/templates | Send an approved WhatsApp template |
POST | /conversations/{id}/tickets | Open a ticket on a conversation |
POST | /conversations/{id}/transcribe | Transcribe a recording for the composer (multipart/form-data) |
POST | /conversations/{id}/unfilter | Restore a quarantined conversation to the inbox |
POST | /conversations/{id}/voice | Send a voice note (multipart/form-data) |
GET | /conversations/counts | Inbox badge counts (my open / unread / mentions / unassigned) |
POST | /conversations/email | A person starts an email — «Escribir por correo» / «Nuevo correo» |
GET /conversations
List conversations
A page of the inbox, newest first. Each row is the conversation row with four joins the inbox draws from: contact, ticket, lastMessage and tags.
Two status axes, and they are NOT the same thing. convStatus filters the conversation's own lifecycle (open · pending · snoozed · resolved · closed); status filters the lifecycle of the TICKET it hangs off, so a thread with no ticket is invisible to it. handler is the third axis — who is answering (bot · human · external) — and is independent of both.
?include=counts adds a top-level counts sibling of data/meta carrying the EXACT payload of GET /conversations/counts — \{ myOpen, myUnread, myMentions, unassigned \} — so a client refreshes its badges and its list in ONE request. The counts are scoped to the calling principal and are independent of every filter here, so the two routes always agree for the same caller. The field is ABSENT (never zeroed) if the counts query fails, so a hiccup degrades the badges rather than blanking the list; an unrecognised include value is a 400 rather than a silently ignored parameter.
filtered=true is the only way to see quarantined threads, and only the literal string true opts in — every other value excludes them, which is the default.
Each row also carries marketplaceWindow — \{ open, closedAt, closedReason, lastEvent, lastEventReason, lastEventAt \} on a facebook_marketplace conversation, null on every other channel. open is whether a reply typed in Vitrina can be delivered right now.
Rows here do NOT carry ad_origin_nudge — that field is single-conversation-read only, to avoid a per-row integration lookup on every page of the inbox.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
page | query | integer | no | ≥ 1, por defecto 1 |
limit | query | integer | no | ≥ 1, ≤ 100, por defecto 20 |
channel | query | string | no | |
messagingAccountId | query | uuid | no | |
source | query | conversation \ | marketplace \ | manual \ |
status | query | open \ | pending \ | snoozed \ |
convStatus | query | open \ | pending \ | snoozed \ |
handler | query | bot \ | human \ | external |
assigneeUserId | query | uuid | no | |
teamId | query | uuid | no | |
search | query | string | no | |
ticketId | query | string | no | |
currentStageId | query | string | no | mín. 1 |
pipelineId | query | string | no | mín. 1 |
fromDate | query | string | no | |
toDate | query | string | no | |
unread_only | query | boolean | null | no | |
mentioned_to_me | query | boolean | null | no | |
unassigned | query | boolean | null | no | |
active | query | boolean | null | no | |
filtered | query | boolean | null | no | |
aiReplied | query | boolean | null | no | |
sort | query | created_at \ | updated_at \ | last_message_date |
order | query | asc \ | desc | no |
include | query | counts | no |
curl https://api.vitrinadev.com/api/v1/conversations \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"id": "bbbbbbbb-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"display_seq": 132,
"display_id": "C-132",
"channel": "web",
"external_id": "web:visitor-9f2c1a",
"messaging_account_id": "16161616-0000-4000-8000-000000000001",
"brand": null,
"contact_id": "22222222-0000-4000-8000-000000000001",
"ticket_id": "dddddddd-0000-4000-8000-000000000001",
"status": "open",
"handler": "human",
"assignee_user_id": "11111111-0000-4000-8000-000000000001",
"assignee_assigned_at": "2026-09-22T11:06:54.183Z",
"snoozed_until": null,
"resolved_at": null,
"resolved_by": null,
"closed_at": null,
"close_deferred_until": null,
"transfer_immediately": false,
"filtered_at": null,
"filtered_reason": null,
"summary": null,
"summarized_at": null,
"history_summary": null,
"history_summary_upto_message_id": null,
"first_billable_at": null,
"billable": null,
"billing_reason": null,
"assigned_unanswered_alerted_at": null,
"assigned_unanswered_realerted_at": null,
"bot_gate_override_at": null,
"bot_gate_override_by": null,
"source": null,
"awaiting_human_since": null,
"awaiting_payment_since": null,
"awaiting_payment_escalated_at": null,
"awaiting_obligation_id": null,
"ad_referral": null,
"ad_join": null,
"ad_welcome": null,
"metadata": null,
"ai_control_source": "legacy",
"ai_control_changed_at": null,
"ai_control_revision": 0,
"ai_control_window_start": null,
"ai_control_window_end": null,
"ai_control_account_revision": null,
"ai_blocked_until": null,
"ai_keep_with_human": false,
"external_id_provisional": false,
"originated_at": null,
"originated_by_kind": null,
"originated_by_ai_agent_id": null,
"originated_by_user_id": null,
"originated_outbound_action_id": null,
"last_message_date": "2026-09-22T11:08:10.882Z",
"created_at": "2026-09-22T11:06:33.744Z",
"updated_at": "2026-09-22T11:08:10.882Z",
"contact": {
"id": "22222222-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Rodrigo Pizarro",
"email": "[email protected]",
"phone": "+56977000021",
"external_id": "web:visitor-9f2c1a",
"avatar_url": null,
"city": null,
"country": null,
"language": "es",
"job_title": null,
"company_id": null,
"brand": null,
"social": {},
"social_stats": {},
"lifecycle_stage": "unknown",
"email_status": "subscribed",
"email_consent": false,
"outreach_consent_at": null,
"outreach_consent_source_url": null,
"outreach_consent_text_version": null,
"bot_replies_disabled_at": null,
"bot_replies_disabled_by": null,
"spam_at": null,
"blocked_at": null,
"archived_at": null,
"merged_at": null,
"merged_into_contact_id": null,
"search_text": "rodrigo pizarro [email protected] +56977000021",
"created_at": "2026-09-22T11:06:33.508Z",
"updated_at": "2026-09-22T11:06:33.508Z"
},
"ticket": {
"id": "dddddddd-0000-4000-8000-000000000001",
"display_id": "T-6",
"status": "open",
"handler": "human",
"assignee_user_id": "11111111-0000-4000-8000-000000000001",
"current_stage_id": "55555555-0000-4000-8000-000000000011",
"snoozed_until": null
},
"lastMessage": {
"id": "eeeeeeee-0000-4000-8000-000000000002",
"content": "Hola Rodrigo, te confirmo la visita del jueves a las 10:00.",
"sender_type": "api_key",
"created_at": "2026-09-22T11:08:10.882Z"
},
"marketplaceWindow": null,
"tags": [
{
"id": "12121212-0000-4000-8000-000000000001",
"name": "garantia",
"display_name": "Garantía"
}
],
"last_call": null
}
],
"meta": {
"pagination": {
"total": 18,
"limit": 20
},
"page": 1,
"totalPages": 1,
"hasNext": false,
"hasPrev": false
},
"counts": {
"myOpen": 3,
"myUnread": 1,
"unassigned": 2,
"myMentions": 0
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations
Open a conversation from the platform side
The outbound counterpart to an inbound provider message — everything else in the inbox is created BY the provider webhook when a customer writes. Because nothing arrived from a provider, the caller supplies the channel and the contact identity.
The contact is RESOLVED before it is created, in this order: contact.external_id, then contact.email, then contact.phone. The first one that finds somebody wins and that contact is reused; only a miss on all three creates a new one. So calling this twice for the same person no longer leaves two contacts, and a 201 may well carry a contact that already existed — read contact.id rather than assuming the response describes a new record.
⚠ A BLANK FIELD NEVER MATCHES. An email or phone you omit, send empty, or send in a shape we cannot parse is skipped entirely — it is not compared against contacts that have none, because fusing two unrelated people onto one record is worse than the duplicate it would avoid. Send only the identifiers you actually hold.
Matching is deliberately tolerant of spelling: email is compared case-insensitively, and a phone is normalised first, so +56 9 1234 5678, 56912345678 and 912345678 all resolve to the same person. An identity recorded on the contact’s channels counts as much as the one stored on the contact itself, so a number captured during an earlier WhatsApp thread is found here too.
Carry your own contact.external_id for anyone you may open a second thread with: it is the only key that cannot drift. An email address and a phone number can both be reassigned to a different person, which is exactly why they are consulted after it and not before.
⚠ The thread is addressable only if the channel can work out where to send. On web the address IS the visitor session, so a conversation opened here gets a synthetic manual:<uuid> external id and sending on it answers 422 no_channel_identity — open a web thread from the widget instead. On whatsapp and email the contact's phone or address is the address, and the thread is sendable as soon as messaging_account_id names a connected account.
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
channel | string | — | mín. 1, máx. 50, por defecto "web" |
brand | string | null | — | máx. 120 |
messaging_account_id | string | null | — | |
contact | object | — |
curl -X POST https://api.vitrinadev.com/api/v1/conversations \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"channel": "web",
"messaging_account_id": "16161616-0000-4000-8000-000000000001",
"contact": {
"name": "Rodrigo Pizarro",
"email": "[email protected]",
"phone": "+56977000021"
}
}'Ejemplo de respuesta (201)
{
"data": {
"id": "bbbbbbbb-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"display_seq": 134,
"display_id": "C-134",
"channel": "web",
"external_id": "manual:3970dd58-bdce-4498-b8f7-e0acd4440d20",
"messaging_account_id": "16161616-0000-4000-8000-000000000001",
"brand": null,
"contact_id": "22222222-0000-4000-8000-000000000001",
"ticket_id": null,
"status": "open",
"handler": "human",
"assignee_user_id": null,
"assignee_assigned_at": null,
"snoozed_until": null,
"resolved_at": null,
"resolved_by": null,
"closed_at": null,
"close_deferred_until": null,
"transfer_immediately": false,
"filtered_at": null,
"filtered_reason": null,
"summary": null,
"summarized_at": null,
"history_summary": null,
"history_summary_upto_message_id": null,
"first_billable_at": null,
"billable": null,
"billing_reason": null,
"assigned_unanswered_alerted_at": null,
"assigned_unanswered_realerted_at": null,
"bot_gate_override_at": null,
"bot_gate_override_by": null,
"source": null,
"awaiting_human_since": null,
"awaiting_payment_since": null,
"awaiting_payment_escalated_at": null,
"awaiting_obligation_id": null,
"ad_referral": null,
"ad_join": null,
"ad_welcome": null,
"metadata": null,
"ai_control_source": "legacy",
"ai_control_changed_at": null,
"ai_control_revision": 0,
"ai_control_window_start": null,
"ai_control_window_end": null,
"ai_control_account_revision": null,
"ai_blocked_until": null,
"ai_keep_with_human": false,
"external_id_provisional": false,
"originated_at": null,
"originated_by_kind": null,
"originated_by_ai_agent_id": null,
"originated_by_user_id": null,
"originated_outbound_action_id": null,
"last_message_date": "2026-09-22T11:07:43.585Z",
"created_at": "2026-09-22T11:07:43.585Z",
"updated_at": "2026-09-22T11:07:43.585Z"
}
}Responde: 201 · 400 · 401 · 403 · 404 · 409 · 429
GET /conversations/{id}
Get conversation
The conversation row with the full transcript embedded (message[], unbounded) — the historical, still-default shape every existing consumer keeps seeing unchanged.
Pass ?exclude=messages to opt into the SAME row without that embed. Any caller that re-reads this on a clock should: the embed has no limit, so a two-year-old thread returns two years of messages every time. GET /conversations/\{id\}/messages pages them properly.
Also carries ad_origin_nudge: 'connect_meta_ads' | null, non-null ONLY when the caller holds integrations:write, the conversation came from a Meta click-to-WhatsApp ad, and the workspace has no connected meta_ads integration. THIS ROUTE ONLY — deliberately absent from the list, where the predicate would be an N+1.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
exclude | query | messages | no |
curl https://api.vitrinadev.com/api/v1/conversations/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"id": "bbbbbbbb-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"display_seq": 132,
"display_id": "C-132",
"channel": "web",
"external_id": "web:visitor-9f2c1a",
"messaging_account_id": "16161616-0000-4000-8000-000000000001",
"brand": null,
"contact_id": "22222222-0000-4000-8000-000000000001",
"ticket_id": "dddddddd-0000-4000-8000-000000000001",
"status": "open",
"handler": "human",
"assignee_user_id": "11111111-0000-4000-8000-000000000001",
"assignee_assigned_at": "2026-09-22T11:06:54.183Z",
"snoozed_until": null,
"resolved_at": null,
"resolved_by": null,
"closed_at": null,
"close_deferred_until": null,
"transfer_immediately": false,
"filtered_at": null,
"filtered_reason": null,
"summary": null,
"summarized_at": null,
"history_summary": null,
"history_summary_upto_message_id": null,
"first_billable_at": null,
"billable": null,
"billing_reason": null,
"assigned_unanswered_alerted_at": null,
"assigned_unanswered_realerted_at": null,
"bot_gate_override_at": null,
"bot_gate_override_by": null,
"source": null,
"awaiting_human_since": null,
"awaiting_payment_since": null,
"awaiting_payment_escalated_at": null,
"awaiting_obligation_id": null,
"ad_referral": null,
"ad_join": null,
"ad_welcome": null,
"metadata": null,
"ai_control_source": "legacy",
"ai_control_changed_at": null,
"ai_control_revision": 0,
"ai_control_window_start": null,
"ai_control_window_end": null,
"ai_control_account_revision": null,
"ai_blocked_until": null,
"ai_keep_with_human": false,
"external_id_provisional": false,
"originated_at": null,
"originated_by_kind": null,
"originated_by_ai_agent_id": null,
"originated_by_user_id": null,
"originated_outbound_action_id": null,
"last_message_date": "2026-09-22T11:08:10.882Z",
"created_at": "2026-09-22T11:06:33.744Z",
"updated_at": "2026-09-22T11:08:10.882Z",
"contact": {
"id": "22222222-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Rodrigo Pizarro",
"email": "[email protected]",
"phone": "+56977000021",
"external_id": "web:visitor-9f2c1a",
"avatar_url": null,
"city": null,
"country": null,
"language": "es",
"job_title": null,
"company_id": null,
"brand": null,
"social": {},
"social_stats": {},
"lifecycle_stage": "unknown",
"email_status": "subscribed",
"email_consent": false,
"outreach_consent_at": null,
"outreach_consent_source_url": null,
"outreach_consent_text_version": null,
"bot_replies_disabled_at": null,
"bot_replies_disabled_by": null,
"spam_at": null,
"blocked_at": null,
"archived_at": null,
"merged_at": null,
"merged_into_contact_id": null,
"search_text": "rodrigo pizarro [email protected] +56977000021",
"created_at": "2026-09-22T11:06:33.508Z",
"updated_at": "2026-09-22T11:06:33.508Z"
},
"ticket": {
"id": "dddddddd-0000-4000-8000-000000000001",
"display_id": "T-6",
"status": "open",
"handler": "human",
"assignee_user_id": "11111111-0000-4000-8000-000000000001",
"current_stage_id": "55555555-0000-4000-8000-000000000011",
"snoozed_until": null
},
"lastMessage": {
"id": "eeeeeeee-0000-4000-8000-000000000002",
"content": "Hola Rodrigo, te confirmo la visita del jueves a las 10:00.",
"sender_type": "api_key",
"created_at": "2026-09-22T11:08:10.882Z"
},
"marketplaceWindow": null,
"tags": [
{
"id": "12121212-0000-4000-8000-000000000001",
"name": "garantia",
"display_name": "Garantía"
}
],
"last_call": null,
"ad_origin_nudge": null,
"message": [
{
"id": "eeeeeeee-0000-4000-8000-000000000001",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"content": "¿Me pueden confirmar la hora del jueves?",
"type": "text",
"sender_role": "user",
"sender_type": "contact",
"sender_id": null,
"created_at": "2026-09-22T11:07:02.824Z"
}
]
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /conversations/{id}/ai-status
Why the AI is silent on this conversation
A machine-readable silence_reason (plus a human explanation), or null when the AI would answer the next inbound message. Separate from GET /conversations/\{id\} on purpose: resolving the reason walks the agent precedence chain and can cost four queries, which the inbox's hottest path must not pay on every thread open — least of all to learn that there is nothing to report.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/ai-status \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"silence_reason": "human_took_over",
"explanation": "A teammate took control. The AI stays silent through the protected window, or until an explicit return when that window is unbounded.",
"bot_gate_override_at": null,
"awaiting_payment": null,
"gate_detail": null
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/assign
Assign a conversation to a teammate
Moves BOTH axes in one call: handler (who answers — defaults to human here) and assignee_user_id (which person). assignee_user_id: null hands the thread to the human queue unowned; omitting the key leaves the current assignee alone. Works with or without a ticket, and mirrors onto the ticket when there is one.
This is the key-friendly counterpart of /claim: an API key has no person behind it and cannot claim, but it can assign to a named member.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
assignee_user_id | string | null | — | |
handler | bot \ | human \ | external |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/assign \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"assignee_user_id": "11111111-0000-4000-8000-000000000001",
"handler": "human"
}'Ejemplo de respuesta (200)
{
"data": {
"id": "bbbbbbbb-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"display_seq": 132,
"display_id": "C-132",
"channel": "web",
"external_id": "web:visitor-9f2c1a",
"messaging_account_id": "16161616-0000-4000-8000-000000000001",
"brand": null,
"contact_id": "22222222-0000-4000-8000-000000000001",
"ticket_id": "dddddddd-0000-4000-8000-000000000001",
"status": "open",
"handler": "human",
"assignee_user_id": "11111111-0000-4000-8000-000000000001",
"assignee_assigned_at": "2026-09-22T11:06:54.183Z",
"snoozed_until": null,
"resolved_at": null,
"resolved_by": null,
"closed_at": null,
"close_deferred_until": null,
"transfer_immediately": false,
"filtered_at": null,
"filtered_reason": null,
"summary": null,
"summarized_at": null,
"history_summary": null,
"history_summary_upto_message_id": null,
"first_billable_at": null,
"billable": null,
"billing_reason": null,
"assigned_unanswered_alerted_at": null,
"assigned_unanswered_realerted_at": null,
"bot_gate_override_at": null,
"bot_gate_override_by": null,
"source": null,
"awaiting_human_since": null,
"awaiting_payment_since": null,
"awaiting_payment_escalated_at": null,
"awaiting_obligation_id": null,
"ad_referral": null,
"ad_join": null,
"ad_welcome": null,
"metadata": null,
"ai_control_source": "legacy",
"ai_control_changed_at": null,
"ai_control_revision": 0,
"ai_control_window_start": null,
"ai_control_window_end": null,
"ai_control_account_revision": null,
"ai_blocked_until": null,
"ai_keep_with_human": false,
"external_id_provisional": false,
"originated_at": null,
"originated_by_kind": null,
"originated_by_ai_agent_id": null,
"originated_by_user_id": null,
"originated_outbound_action_id": null,
"last_message_date": "2026-09-22T11:08:10.882Z",
"created_at": "2026-09-22T11:06:33.744Z",
"updated_at": "2026-09-22T11:08:10.882Z"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/attach-to-ticket
Attach this conversation to an existing ticket
Cross-channel follow-up: the customer who asked by WhatsApp writes again by email, and both threads belong to one case. Attaching puts this conversation on that ticket, so GET /tickets/\{id\}/conversations lists both and GET /tickets/\{id\}/messages interleaves them into one timeline. The response reports previous_ticket_id when the conversation was moved off another ticket.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
ticket_id | string | sí | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/attach-to-ticket \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"ticket_id": "dddddddd-0000-4000-8000-000000000001"
}'Ejemplo de respuesta (200)
{
"data": {
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000002",
"ticket_id": "dddddddd-0000-4000-8000-000000000001",
"previous_ticket_id": null
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/attachments
Send a file to the customer (multipart/form-data)
Stores the bytes, ships them through the channel provider, then persists the message. Provider send happens before persistence on purpose: a delivery failure surfaces as an error instead of leaving a "sent" row for a message the customer never got. Optional caption.
acknowledge is a COMMA-SEPARATED string in this multipart form, not a JSON array.
Requires messages:send in addition to conversations:write. Sent by an API key, a personal token or a connected app, it goes through the política de envíos first — 422 OUTBOUND_BLOCKED, 409 OUTBOUND_WARNING (resend with acknowledge) or, for a connected app past its fan-out limit, 202 pending_approval. A member in the UI is not checked.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/attachments \
-H "Authorization: Bearer $VITRINA_KEY" \
-F "file=@/path/to/file.png" \
-F "caption=Te adjunto el comprobante." \
-F "acknowledge=conversation_human_owned"Ejemplo de respuesta (201)
{
"data": {
"id": "eeeeeeee-0000-4000-8000-000000000002",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"content": "Te adjunto el comprobante.",
"type": "file",
"sender_role": "assistant",
"sender_type": "api_key",
"sender_id": "c1c1c1c1-0000-4000-8000-000000000001",
"correlation_id": null,
"external_message_id": null,
"media_urls": [
"https://media.vitrinadev.com/conversations/.../boleta.pdf"
],
"media_sensitivity": null,
"media_processing_state": null,
"media_expires_at": null,
"derived_context_expires_at": null,
"metadata": null,
"tokens": 0,
"tool_calls": null,
"tool_call_id": null,
"delivery_status": "sent",
"delivery_error": null,
"delivery_attempt": 1,
"next_attempt_at": null,
"delivered_at": null,
"read_at": null,
"author": {
"kind": "api_key",
"id": "c1c1c1c1-0000-4000-8000-000000000001",
"name": "CRM propio"
},
"created_at": "2026-09-22T11:08:10.882Z",
"updated_at": "2026-09-22T11:08:10.882Z"
}
}Responde: 201 · 202 · 400 · 401 · 403 · 404 · 409 · 422 · 429
GET /conversations/{id}/attributes
List the attribute values on a conversation
The values stored on this thread, each joined to its DEFINITION (custom_attribute_id, label, data_type, options, required) so a client can render the field without a second read. source says who wrote it — admin for a person or an integration, or the AI tool that filled it. A key with no definition still appears, with the definition fields null: the values join by TEXT key, not by a foreign key.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/attributes \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"id": "14141414-0000-4000-8000-000000000001",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"key": "numero_de_caso",
"value": "F-4821",
"source": "admin",
"source_tool": null,
"custom_attribute_id": "13131313-0000-4000-8000-000000000001",
"label": "Número de caso",
"data_type": "text",
"options": [],
"required": false,
"multiline": false,
"unique_values": false,
"pinned": false,
"updated_at": "2026-09-22T11:06:46.763Z"
}
]
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
PUT /conversations/{id}/attributes
Upsert attribute values on a conversation
Upsert, not replace: the keys in the body are written, everything else on the thread is left alone. 1 … 50 pairs per call; value is any JSON value. A key defined with unique_values collides with a 409 rather than overwriting the other record.
Deleting a value is DELETE /conversations/\{id\}/attributes/\{key\} — sending null stores a null, which is a different thing.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
attributes | object[] | sí |
curl -X PUT https://api.vitrinadev.com/api/v1/conversations/<id>/attributes \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"attributes": [
{
"key": "numero_de_caso",
"value": "F-4821"
}
]
}'Ejemplo de respuesta (200)
{
"data": [
{
"id": "14141414-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"key": "numero_de_caso",
"value": "F-4821",
"source": "admin",
"source_tool": null,
"is_unique": false,
"created_at": "2026-09-22T11:06:46.884Z",
"updated_at": "2026-09-22T11:06:46.763Z"
}
]
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /conversations/{id}/attributes/{key}
Delete one attribute value from a conversation
Removes the VALUE from this conversation. The definition survives, and so does the same key on every other thread.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
key | path | string | sí | mín. 1, máx. 120, patrón ^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$ |
curl -X DELETE https://api.vitrinadev.com/api/v1/conversations/<id>/attributes/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Responde: 204 · 400 · 401 · 403 · 404 · 409 · 429
GET /conversations/{id}/calls
List a conversation's call segments
A voice conversation is a thread like any other: the transcript is on /messages, and the CALLS — one row per connected segment, with its direction, duration and whether a recording exists — are here. Empty on every non-voice thread.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
limit | query | integer | no | ≥ 1, ≤ 500 |
offset | query | integer | null | no | ≥ 0 |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/calls \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"id": "17171717-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"direction": "outbound",
"status": "completed",
"started_at": "2026-09-22T11:12:00.000Z",
"ended_at": "2026-09-22T11:14:37.000Z",
"duration_seconds": 157,
"recording_available": true
}
]
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /conversations/{id}/calls/{call_id}/recording-url
Mint a short-lived playback URL for a call recording
A presigned URL that expires in minutes, so a recording is never served from a link that can be forwarded or logged. Mint a new one per playback rather than storing this. A call with no recording is a 404.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
call_id | path | uuid | sí |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/calls/<id>/recording-url \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"url": "https://media.vitrinadev.com/calls/17171717/recording.mp3?X-Amz-Expires=300&…",
"expires_at": "2026-09-22T11:20:00.000Z"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/claim
Claim a conversation for the caller
Requires a workspace member — a Supabase session or a personal token acting as one — since the caller BECOMES the assignee, and an sk_* API key has no member behind it to assign the conversation to; it gets 403. An API key assigns to a SPECIFIC member instead: POST /conversations/\{id\}/assign.
Claiming a thread somebody else already holds is not an error: the response reports the current assignee, so two people pressing at once get the same answer rather than a race.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/claim \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"id": "bbbbbbbb-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"display_seq": 132,
"display_id": "C-132",
"channel": "web",
"external_id": "web:visitor-9f2c1a",
"messaging_account_id": "16161616-0000-4000-8000-000000000001",
"brand": null,
"contact_id": "22222222-0000-4000-8000-000000000001",
"ticket_id": "dddddddd-0000-4000-8000-000000000001",
"status": "open",
"handler": "human",
"assignee_user_id": "11111111-0000-4000-8000-000000000001",
"assignee_assigned_at": "2026-09-22T11:06:54.183Z",
"snoozed_until": null,
"resolved_at": null,
"resolved_by": null,
"closed_at": null,
"close_deferred_until": null,
"transfer_immediately": false,
"filtered_at": null,
"filtered_reason": null,
"summary": null,
"summarized_at": null,
"history_summary": null,
"history_summary_upto_message_id": null,
"first_billable_at": null,
"billable": null,
"billing_reason": null,
"assigned_unanswered_alerted_at": null,
"assigned_unanswered_realerted_at": null,
"bot_gate_override_at": null,
"bot_gate_override_by": null,
"source": null,
"awaiting_human_since": null,
"awaiting_payment_since": null,
"awaiting_payment_escalated_at": null,
"awaiting_obligation_id": null,
"ad_referral": null,
"ad_join": null,
"ad_welcome": null,
"metadata": null,
"ai_control_source": "legacy",
"ai_control_changed_at": null,
"ai_control_revision": 0,
"ai_control_window_start": null,
"ai_control_window_end": null,
"ai_control_account_revision": null,
"ai_blocked_until": null,
"ai_keep_with_human": false,
"external_id_provisional": false,
"originated_at": null,
"originated_by_kind": null,
"originated_by_ai_agent_id": null,
"originated_by_user_id": null,
"originated_outbound_action_id": null,
"last_message_date": "2026-09-22T11:08:10.882Z",
"created_at": "2026-09-22T11:06:33.744Z",
"updated_at": "2026-09-22T11:08:10.882Z"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/close
Close a conversation (terminal)
The terminal state, reached manually here or by the auto-close worker a grace window after a resolve. Cascades to the ticket under the same no-active-siblings rule as /resolve. POST /conversations/\{id\}/reopen brings it back, and so does a new inbound message.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/close \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"status": "closed"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/detach-from-ticket
Detach this conversation from its ticket
Leaves the ticket standing; only this conversation's link to it is removed. Detaching the last conversation does not close the ticket.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/detach-from-ticket \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"previous_ticket_id": "dddddddd-0000-4000-8000-000000000001"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /conversations/{id}/export
Export the whole conversation (json | markdown)
One bundle with the conversation, its contact, every message, the internal notes and the attributes — what a person would attach to a complaint or hand to a lawyer. ?format=markdown returns the same bundle rendered as a readable transcript instead of JSON.
Unbounded by design: this is the export, not a page. For an ongoing read use GET /conversations/\{id\}/messages.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
format | query | json \ | markdown | no |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/export \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"conversation": {
"id": "bbbbbbbb-0000-4000-8000-000000000001",
"display_id": "C-132",
"channel": "web",
"status": "open",
"created_at": "2026-09-22T11:06:33.744Z"
},
"contact": {
"id": "22222222-0000-4000-8000-000000000001",
"name": "Rodrigo Pizarro",
"email": "[email protected]",
"phone": "+56977000021"
},
"messages": [
{
"id": "eeeeeeee-0000-4000-8000-000000000001",
"sender_type": "contact",
"content": "¿Me pueden confirmar la hora del jueves?",
"created_at": "2026-09-22T11:07:02.824Z"
},
{
"id": "eeeeeeee-0000-4000-8000-000000000002",
"sender_type": "api_key",
"content": "Hola Rodrigo, te confirmo la visita del jueves a las 10:00.",
"created_at": "2026-09-22T11:08:10.882Z"
}
],
"notes": [],
"attributes": [
{
"key": "numero_de_caso",
"value": "F-4821"
}
]
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/filter-sender
Denylist the sender and quarantine this conversation
Acts on the sender, not just this thread — future conversations from them are filtered too. They move to the «Filtrado» folder, which the list only shows for ?filtered=true. Nothing is deleted.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/filter-sender \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/flow-media
Mint a media_id for a Flow header (multipart/form-data)
Pre-uploads the multipart file to WhatsApp and answers the \{ media_id, header_type \} you then pass as the header of POST /conversations/\{id\}/flows.
A separate step rather than multipart on the send route so a failed upload never sends a half-formed message. Nothing is persisted on our side — the media_id lives on Meta and expires there, and no message leaves Vitrina, which is why this one does not take messages:send.
Interactive headers take image, video or document; audio is a 400, as is a non-WhatsApp conversation or a channel whose provider cannot pre-upload media.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/flow-media \
-H "Authorization: Bearer $VITRINA_KEY" \
-F "file=@/path/to/file.png"Ejemplo de respuesta (200)
{
"data": {
"media_id": "2038475610394856",
"header_type": "image"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/flows
Send a published WhatsApp Flow
Sends an interactive form (a Meta Flow) into the conversation, referenced by flow_id and dressed with body/cta and optional header/footer.
Unlike a template this does NOT re-open a closed 24h window — it is an ordinary interactive message, so it only lands while the window is open. WhatsApp conversations only; anything else is a 400.
header is a union: a string is a TEXT header, an object \{ type, media_id \} is a MEDIA header whose media_id comes from POST /conversations/\{id\}/flow-media.
The Flow itself lives on Meta; this endpoint only references it. The customer's completed form returns asynchronously as a flow_reply contact message, not in this response.
Requires messages:send in addition to conversations:write. Sent by an API key, a personal token or a connected app, it goes through the política de envíos first — 422 OUTBOUND_BLOCKED, 409 OUTBOUND_WARNING (resend with acknowledge) or, for a connected app past its fan-out limit, 202 pending_approval. A member in the UI is not checked.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
flow_id | string | sí | mín. 1, máx. 64 |
body | string | sí | mín. 1, máx. 1024 |
cta | string | sí | mín. 1, máx. 30 |
header | any | — | |
footer | string | — | mín. 1, máx. 60 |
acknowledge | string[] | — |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/flows \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"flow_id": "1234567890123456",
"body": "Completa estos datos y agendamos tu hora.",
"cta": "Completar"
}'Ejemplo de respuesta (201)
{
"data": {
"id": "eeeeeeee-0000-4000-8000-000000000002",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"content": "Hola Rodrigo, te confirmo la visita del jueves a las 10:00.",
"type": "text",
"sender_role": "assistant",
"sender_type": "api_key",
"sender_id": "c1c1c1c1-0000-4000-8000-000000000001",
"correlation_id": null,
"external_message_id": null,
"media_urls": null,
"media_sensitivity": null,
"media_processing_state": null,
"media_expires_at": null,
"derived_context_expires_at": null,
"metadata": null,
"tokens": 0,
"tool_calls": null,
"tool_call_id": null,
"delivery_status": "sent",
"delivery_error": null,
"delivery_attempt": 1,
"next_attempt_at": null,
"delivered_at": null,
"read_at": null,
"author": {
"kind": "api_key",
"id": "c1c1c1c1-0000-4000-8000-000000000001",
"name": "CRM propio"
},
"created_at": "2026-09-22T11:08:10.882Z",
"updated_at": "2026-09-22T11:08:10.882Z"
}
}Responde: 201 · 202 · 400 · 401 · 403 · 404 · 409 · 422 · 429
POST /conversations/{id}/handoff
Hand the conversation off to a human team
Flips the handling axis to human so the thread enters the agent inbox, opening or routing a ticket when the workspace has a team for it — the assignment rules decide who, which is why this is not the same call as /assign with a member id. Works with or without a ticket. An optional reason is recorded in the audit trail.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
reason | string | — | máx. 200 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/handoff \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "El cliente pide hablar con una persona"
}'Ejemplo de respuesta (200)
{
"data": {
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"handler": "human",
"ticket_id": "dddddddd-0000-4000-8000-000000000001",
"assignee_user_id": "11111111-0000-4000-8000-000000000001",
"team_id": "cccccccc-0000-4000-8000-000000000001"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /conversations/{id}/linked-records
The ticket and open leads this conversation's work lives on
The ticket the thread hangs off and every open lead it feeds, each annotated with its board and the columns the card may move to next (allowed_transitions_to) — so a caller can offer the legal moves without reading the pipeline separately. ticket is null on a thread that never needed one; leads is [] when nothing commercial came of it.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/linked-records \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"ticket": {
"id": "dddddddd-0000-4000-8000-000000000001",
"display_id": "T-6",
"status": "open",
"current_stage_id": "55555555-0000-4000-8000-000000000011",
"pipeline": {
"id": "44444444-0000-4000-8000-000000000001",
"name": "Soporte",
"stage": [
{
"id": "55555555-0000-4000-8000-000000000011",
"name": "Nuevo",
"slug": "new",
"position": 0,
"category": "open",
"allowed_transitions_to": [
"55555555-0000-4000-8000-000000000012"
]
}
]
}
},
"leads": []
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/location
Send a location pin to the customer
WhatsApp only. Exactly ONE source: location_id (a branch of the workspace), maps_url (a pasted Google Maps link — short maps.app.goo.gl links are expanded server-side), or latitude + longitude. Two sources, or a lone latitude, is a 400.
For a branch the coordinates come from its geo, falling back to expanding its arrival_info.maps_url (cached back onto geo on the first send); a branch with neither is a 400. name / address are what the customer reads on the card — sent verbatim when given, otherwise taken from the branch.
The persisted message is type: "location" with the pin on metadata.location; its content is the same text the card shows.
Requires messages:send in addition to conversations:write. Sent by an API key, a personal token or a connected app, it goes through the política de envíos first — 422 OUTBOUND_BLOCKED, 409 OUTBOUND_WARNING (resend with acknowledge) or, for a connected app past its fan-out limit, 202 pending_approval. A member in the UI is not checked.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
location_id | uuid | — | |
maps_url | string | — | mín. 1, máx. 2048 |
latitude | number | — | ≥ -90, ≤ 90 |
longitude | number | — | ≥ -180, ≤ 180 |
name | string | — | mín. 1, máx. 1024 |
address | string | — | mín. 1, máx. 1024 |
acknowledge | string[] | — |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/location \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"location_id": "b1b1b1b1-0000-4000-8000-000000000001"
}'Ejemplo de respuesta (201)
{
"data": {
"id": "eeeeeeee-0000-4000-8000-000000000002",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"content": "Sucursal Providencia — Av. Nueva Providencia 2214, Providencia",
"type": "location",
"sender_role": "assistant",
"sender_type": "api_key",
"sender_id": "c1c1c1c1-0000-4000-8000-000000000001",
"correlation_id": null,
"external_message_id": null,
"media_urls": null,
"media_sensitivity": null,
"media_processing_state": null,
"media_expires_at": null,
"derived_context_expires_at": null,
"metadata": {
"location": {
"latitude": -33.4262,
"longitude": -70.6162,
"name": "Sucursal Providencia",
"address": "Av. Nueva Providencia 2214, Providencia"
}
},
"tokens": 0,
"tool_calls": null,
"tool_call_id": null,
"delivery_status": "sent",
"delivery_error": null,
"delivery_attempt": 1,
"next_attempt_at": null,
"delivered_at": null,
"read_at": null,
"author": {
"kind": "api_key",
"id": "c1c1c1c1-0000-4000-8000-000000000001",
"name": "CRM propio"
},
"created_at": "2026-09-22T11:08:10.882Z",
"updated_at": "2026-09-22T11:08:10.882Z"
}
}Responde: 201 · 202 · 400 · 401 · 403 · 404 · 409 · 422 · 429
GET /conversations/{id}/messages
Paginated message playback
Rows are the message table verbatim, plus the author every message carries (ADR 0106 §3.1): the contact, a member, a member through a connected app or a personal token (author.via), the AI agent, an API key — whose name is the name it had WHEN IT WROTE, so renaming or revoking the key never rewrites history — or the system.
Outbound delivery is carried by four fields the inbox renders together: delivery_status (sent → delivered → read, failed, or retrying — the broker refused the send and another attempt is scheduled), next_attempt_at (when that attempt fires; set only while retrying), delivery_attempt (send attempts made — 1 is the original send) and delivery_error (the provider's reason on failed). A retrying message has NOT failed: it only becomes failed once the retry ladder (1 min, 5 min, 15 min, 1 h, 3 h) is exhausted. All four are null/0 on inbound rows.
Paged by cursor (meta.pagination.nextCursor), oldest first by default. A sender_type of system is a thread event — an assignment, a status change — not a message anyone sent.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
limit | query | integer | no | ≥ 1, ≤ 200, por defecto 50 |
order | query | asc \ | desc | no |
cursor | query | string | no |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/messages \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"id": "eeeeeeee-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"content": "¿Me pueden confirmar la hora del jueves?",
"type": "text",
"sender_role": "user",
"sender_type": "contact",
"sender_id": null,
"correlation_id": null,
"external_message_id": null,
"media_urls": null,
"media_sensitivity": null,
"media_processing_state": null,
"media_expires_at": null,
"derived_context_expires_at": null,
"metadata": null,
"tokens": null,
"tool_calls": null,
"tool_call_id": null,
"delivery_status": null,
"delivery_error": null,
"delivery_attempt": 0,
"next_attempt_at": null,
"delivered_at": null,
"read_at": null,
"author": {
"kind": "contact",
"id": null,
"name": null
},
"created_at": "2026-09-22T11:07:02.824Z",
"updated_at": "2026-09-22T11:07:02.824Z"
},
{
"id": "eeeeeeee-0000-4000-8000-000000000002",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"content": "Hola Rodrigo, te confirmo la visita del jueves a las 10:00.",
"type": "text",
"sender_role": "assistant",
"sender_type": "api_key",
"sender_id": "c1c1c1c1-0000-4000-8000-000000000001",
"correlation_id": null,
"external_message_id": null,
"media_urls": null,
"media_sensitivity": null,
"media_processing_state": null,
"media_expires_at": null,
"derived_context_expires_at": null,
"metadata": null,
"tokens": 0,
"tool_calls": null,
"tool_call_id": null,
"delivery_status": "sent",
"delivery_error": null,
"delivery_attempt": 1,
"next_attempt_at": null,
"delivered_at": null,
"read_at": null,
"author": {
"kind": "api_key",
"id": "c1c1c1c1-0000-4000-8000-000000000001",
"name": "CRM propio"
},
"created_at": "2026-09-22T11:08:10.882Z",
"updated_at": "2026-09-22T11:08:10.882Z"
},
{
"id": "eeeeeeee-0000-4000-8000-000000000002",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"content": "Hola Rodrigo, te confirmo la visita del jueves a las 10:00.",
"type": "text",
"sender_role": "assistant",
"sender_type": "human_user",
"sender_id": "11111111-0000-4000-8000-000000000001",
"correlation_id": null,
"external_message_id": null,
"media_urls": null,
"media_sensitivity": null,
"media_processing_state": null,
"media_expires_at": null,
"derived_context_expires_at": null,
"metadata": null,
"tokens": 0,
"tool_calls": null,
"tool_call_id": null,
"delivery_status": "sent",
"delivery_error": null,
"delivery_attempt": 1,
"next_attempt_at": null,
"delivered_at": null,
"read_at": null,
"author": {
"kind": "member",
"id": "11111111-0000-4000-8000-000000000001",
"name": "Camila Rojas",
"via": {
"kind": "connected_app",
"name": "Claude"
}
},
"created_at": "2026-09-22T11:08:10.882Z",
"updated_at": "2026-09-22T11:08:10.882Z"
}
],
"meta": {
"pagination": {
"limit": 50,
"nextCursor": "eyJ0cyI6IjIwMjYtMDktMjJUMTE6MDg6MTAuODgyWiIsImlkIjoiZWVlZWVlZWUtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAyIn0="
},
"order": "asc"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/messages
Send a message to the customer
The plain-text reply. The message is persisted with the caller as its author and shipped through whatever provider the conversation is on.
cc, bcc and html are EMAIL ONLY and a 400 anywhere else; html is sanitised server-side and sent as the text/html part, with content kept as the plain-text fallback. signature_id picks one of the sending member's saved signatures — omit it for their default, send null for none.
send_at (email only, ≥1 min ahead, ≤30 days) schedules the reply instead of sending it: the answer is \{ scheduled: true, id, send_at, status \}, not a message. The política de envíos judges it twice — now, and again when it fires, because a contact can opt out or a hold can land in between; a Bloqueo at fire time stops it and the scheduled row ends failed with the reason. Cancel with DELETE /conversations/\{id\}/scheduled-messages/\{scheduledId\}.
Requires messages:send in addition to conversations:write. Sent by an API key, a personal token or a connected app, it goes through the política de envíos first — 422 OUTBOUND_BLOCKED, 409 OUTBOUND_WARNING (resend with acknowledge) or, for a connected app past its fan-out limit, 202 pending_approval. A member in the UI is not checked.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
content | string | sí | mín. 1, máx. 8000 |
type | text | — | por defecto "text" |
cc | string[] | — | |
bcc | string[] | — | |
signature_id | string | null | — | |
html | string | — | máx. 200000 |
send_at | string | — | date-time |
acknowledge | string[] | — |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/messages \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"content": "Hola Rodrigo, te confirmo la visita del jueves a las 10:00.",
"acknowledge": [
"conversation_human_owned"
]
}'Ejemplo de respuesta (201)
{
"data": {
"id": "eeeeeeee-0000-4000-8000-000000000002",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"content": "Hola Rodrigo, te confirmo la visita del jueves a las 10:00.",
"type": "text",
"sender_role": "assistant",
"sender_type": "api_key",
"sender_id": "c1c1c1c1-0000-4000-8000-000000000001",
"correlation_id": null,
"external_message_id": null,
"media_urls": null,
"media_sensitivity": null,
"media_processing_state": null,
"media_expires_at": null,
"derived_context_expires_at": null,
"metadata": null,
"tokens": 0,
"tool_calls": null,
"tool_call_id": null,
"delivery_status": "sent",
"delivery_error": null,
"delivery_attempt": 1,
"next_attempt_at": null,
"delivered_at": null,
"read_at": null,
"author": {
"kind": "api_key",
"id": "c1c1c1c1-0000-4000-8000-000000000001",
"name": "CRM propio"
},
"created_at": "2026-09-22T11:08:10.882Z",
"updated_at": "2026-09-22T11:08:10.882Z"
}
}Responde: 201 · 202 · 400 · 401 · 403 · 404 · 409 · 422 · 429
POST /conversations/{id}/messages/{messageId}/transcribe
Transcribe an inbound audio message
Returns \{ text, cached \}. Bot-handled conversations are already transcribed by the worker, so those return the stored text with cached: true and no model call. Human-handled audio is transcribed on the first request — server-side, fetching the media through the provider so the browser never touches an expiring or CORS-blocked provider URL — and the result is stored, so later requests are cached too. A non-audio message is a 400.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
messageId | path | string | sí | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/messages/<id>/transcribe \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"text": "Hola, quería confirmar la hora del jueves.",
"cached": true
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /conversations/{id}/notes
List internal notes on a conversation
Internal only — a note is never delivered to the customer and never appears in the transcript the export renders as messages.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/notes \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"id": "ffffffff-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"ticket_id": "dddddddd-0000-4000-8000-000000000001",
"author_user_id": "11111111-0000-4000-8000-000000000001",
"author_name": "Camila Rojas",
"body": "El cliente ya envió la boleta por correo.",
"created_at": "2026-09-22T11:06:39.044Z",
"updated_at": "2026-09-22T11:06:39.044Z"
}
],
"meta": {
"total": 1
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/notes
Add an internal note
@mentions in the body are parsed into mention records (matched on the email local-part) and notify the named teammates. That parsing is best-effort: it never fails the note.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
body | string | sí | mín. 1, máx. 8000 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/notes \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"body": "El cliente ya envió la boleta por correo."
}'Ejemplo de respuesta (201)
{
"data": {
"id": "ffffffff-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"ticket_id": "dddddddd-0000-4000-8000-000000000001",
"author_user_id": "11111111-0000-4000-8000-000000000001",
"author_name": "Camila Rojas",
"body": "El cliente ya envió la boleta por correo.",
"created_at": "2026-09-22T11:06:39.044Z",
"updated_at": "2026-09-22T11:06:39.044Z"
}
}Responde: 201 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /conversations/{id}/notes/{noteId}
Delete an internal note
The note is deleted, not archived. There is no undo.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
noteId | path | uuid | sí |
curl -X DELETE https://api.vitrinadev.com/api/v1/conversations/<id>/notes/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Responde: 204 · 400 · 401 · 403 · 404 · 409 · 429
PATCH /conversations/{id}/notes/{noteId}
Edit an internal note
Replaces the body. Editing does NOT re-parse @mentions, so a name added in an edit notifies nobody — post a new note for that.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
noteId | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
body | string | sí | mín. 1, máx. 8000 |
curl -X PATCH https://api.vitrinadev.com/api/v1/conversations/<id>/notes/<id> \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"body": "El cliente ya envió la boleta por correo. @camila ¿la revisas?"
}'Ejemplo de respuesta (200)
{
"data": {
"id": "ffffffff-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"ticket_id": "dddddddd-0000-4000-8000-000000000001",
"author_user_id": "11111111-0000-4000-8000-000000000001",
"author_name": "Camila Rojas",
"body": "El cliente ya envió la boleta por correo. @camila ¿la revisas?",
"created_at": "2026-09-22T11:06:39.044Z",
"updated_at": "2026-09-22T11:10:12.500Z"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /conversations/{id}/participants
Teammates who have been on this conversation
The current assignee first, then every teammate who has written in the thread, each as \{ user_id, name, avatar_url, is_assignee \}.
The union of the two is deliberate: the writers alone miss an assignee who was handed the thread and has not replied yet, and the assignee alone misses the colleague who covered it last week. Only human_user messages count — an AI reply’s sender_id is an agent, and an API key's is the key, neither of which is a teammate.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/participants \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"user_id": "11111111-0000-4000-8000-000000000001",
"name": "Camila Rojas",
"avatar_url": null,
"is_assignee": true
}
]
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/pending
Mark a conversation pending (waiting, with no timer)
A snooze with nothing to wake it but the customer: the thread leaves the open queue and returns the moment they write again. Use it for "waiting on them"; use /snooze for "come back to me at 9 am".
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/pending \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"status": "pending"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/reopen
Reopen a resolved, snoozed or closed conversation
Back to open, clearing the snooze timer and the resolution stamps. Reopening a thread whose ticket was resolved reopens the ticket too.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/reopen \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"status": "open"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/resolve
Resolve a conversation
"Dealt with." Cascades to the ticket — but only when no OTHER conversation on that ticket is still active, so resolving the WhatsApp thread of a case that is also running by email leaves the ticket open and fires no ticket.resolved.
A new inbound message reopens the conversation. Resolve is reversible; /close is the terminal one.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/resolve \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"status": "resolved"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/return-to-ai
Return handling to the AI
The inverse of /handoff: handler goes back to bot and the assignee is cleared, so the AI answers the next inbound message. Media the customer sent while a person was handling the thread is backfilled for the agent, best-effort.
This moves the HANDLING axis. It is not the same as lifting the no-auto-reply gate on a big account, which is a separate, internal operation.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/return-to-ai \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"id": "bbbbbbbb-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"display_seq": 132,
"display_id": "C-132",
"channel": "web",
"external_id": "web:visitor-9f2c1a",
"messaging_account_id": "16161616-0000-4000-8000-000000000001",
"brand": null,
"contact_id": "22222222-0000-4000-8000-000000000001",
"ticket_id": "dddddddd-0000-4000-8000-000000000001",
"status": "open",
"handler": "bot",
"assignee_user_id": null,
"assignee_assigned_at": null,
"snoozed_until": null,
"resolved_at": null,
"resolved_by": null,
"closed_at": null,
"close_deferred_until": null,
"transfer_immediately": false,
"filtered_at": null,
"filtered_reason": null,
"summary": null,
"summarized_at": null,
"history_summary": null,
"history_summary_upto_message_id": null,
"first_billable_at": null,
"billable": null,
"billing_reason": null,
"assigned_unanswered_alerted_at": null,
"assigned_unanswered_realerted_at": null,
"bot_gate_override_at": null,
"bot_gate_override_by": null,
"source": null,
"awaiting_human_since": null,
"awaiting_payment_since": null,
"awaiting_payment_escalated_at": null,
"awaiting_obligation_id": null,
"ad_referral": null,
"ad_join": null,
"ad_welcome": null,
"metadata": null,
"ai_control_source": "legacy",
"ai_control_changed_at": null,
"ai_control_revision": 0,
"ai_control_window_start": null,
"ai_control_window_end": null,
"ai_control_account_revision": null,
"ai_blocked_until": null,
"ai_keep_with_human": false,
"external_id_provisional": false,
"originated_at": null,
"originated_by_kind": null,
"originated_by_ai_agent_id": null,
"originated_by_user_id": null,
"originated_outbound_action_id": null,
"last_message_date": "2026-09-22T11:08:10.882Z",
"created_at": "2026-09-22T11:06:33.744Z",
"updated_at": "2026-09-22T11:08:10.882Z"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /conversations/{id}/scheduled-messages
List pending scheduled sends
The replies waiting to fire on this conversation — PENDING ones only: a scheduled message that has gone out, been cancelled or been stopped by the política de envíos at fire time leaves this list. policy_acknowledged records the Advertencias the author went ahead over when they scheduled it; those are not re-asked at fire time, a Bloqueo still is.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/scheduled-messages \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"id": "15151515-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"sender_user_id": "11111111-0000-4000-8000-000000000001",
"content": "Hola Rodrigo, te recuerdo la visita de mañana a las 10:00.",
"html": null,
"cc": [],
"bcc": [],
"send_at": "2026-09-23T13:00:00+00:00",
"status": "scheduled",
"error": null,
"message_id": null,
"policy_acknowledged": [
"conversation_human_owned"
],
"created_at": "2026-09-22T11:09:04.112Z",
"updated_at": "2026-09-22T11:09:04.112Z"
}
]
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /conversations/{id}/scheduled-messages/{scheduledId}
Cancel a scheduled send
The reply never goes out and the row is gone — there is no un-cancel, rescheduling means composing it again. A message that already fired (or was already cancelled) is a 404.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
scheduledId | path | uuid | sí |
curl -X DELETE https://api.vitrinadev.com/api/v1/conversations/<id>/scheduled-messages/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Responde: 204 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/snooze
Snooze a conversation until a time or for N minutes
Exactly one of until (ISO 8601, must be in the future) or minutes (1 … 43 200). The thread leaves the open queue and comes back by itself; a new inbound message wakes it early. Mirrors onto the ticket when the conversation has one.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
until | string | — | date-time |
minutes | integer | — | ≥ 1, ≤ 43200 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/snooze \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"minutes": 120
}'Ejemplo de respuesta (200)
{
"data": {
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"status": "snoozed",
"snoozed_until": "2026-09-22T13:07:20.217Z"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /conversations/{id}/tags
List the tags on a conversation
Gated on tags:read, not conversations:read — a caller that can read the thread but not the taxonomy gets 403 here and still reads the conversation.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/tags \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"id": "12121212-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "garantia",
"display_name": "Garantía",
"usage_count": 1,
"created_at": "2026-09-22T11:06:38.372Z"
}
]
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/tags
Attach a tag to a conversation
Send either tag_id to attach an existing tag or name to create-and-attach in one call — the body is one or the other, not a mix. name is slugified, so attaching «Garantía» twice attaches the same tag twice over, never two. Gated on tags:write.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/tags \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Garantía"
}'Ejemplo de respuesta (201)
{
"data": {
"id": "12121212-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "garantia",
"display_name": "Garantía",
"usage_count": 1,
"created_at": "2026-09-22T11:06:38.372Z"
}
}Responde: 201 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /conversations/{id}/tags/{tag_id}
Detach a tag from a conversation
Removes the link only — the tag itself survives for every other conversation, and re-attaching the same pair restores it. Deleting the tag is DELETE /tags/\{id\}.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
tag_id | path | uuid | sí |
curl -X DELETE https://api.vitrinadev.com/api/v1/conversations/<id>/tags/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Responde: 204 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/templates
Send an approved WhatsApp template
The only way to reach a customer once WhatsApp’s 24-hour customer-care window has closed — free-form text is rejected by Meta outside it. params fills each \{\{name\}\} in the template body. Rejects with 400 on a non-WhatsApp conversation. The rendered result is persisted as an ordinary message, so the thread shows exactly what was delivered.
The template CATEGORY decides the legal basis, not the caller: a MARKETING template is judged as marketing whatever it is called, which is why a contact who never consented to marketing is a 422 here and not on a UTILITY template.
Requires messages:send in addition to conversations:write. Sent by an API key, a personal token or a connected app, it goes through the política de envíos first — 422 OUTBOUND_BLOCKED, 409 OUTBOUND_WARNING (resend with acknowledge) or, for a connected app past its fan-out limit, 202 pending_approval. A member in the UI is not checked.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
template_id | uuid | sí | |
params | object | — | |
acknowledge | string[] | — |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/templates \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"template_id": "c3c3c3c3-0000-4000-8000-000000000001",
"params": {
"nombre": "Rodrigo",
"hora": "10:00"
}
}'Ejemplo de respuesta (201)
{
"data": {
"id": "eeeeeeee-0000-4000-8000-000000000002",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"content": "Hola Rodrigo, te confirmo la visita del jueves a las 10:00.",
"type": "text",
"sender_role": "assistant",
"sender_type": "api_key",
"sender_id": "c1c1c1c1-0000-4000-8000-000000000001",
"correlation_id": null,
"external_message_id": null,
"media_urls": null,
"media_sensitivity": null,
"media_processing_state": null,
"media_expires_at": null,
"derived_context_expires_at": null,
"metadata": null,
"tokens": 0,
"tool_calls": null,
"tool_call_id": null,
"delivery_status": "sent",
"delivery_error": null,
"delivery_attempt": 1,
"next_attempt_at": null,
"delivered_at": null,
"read_at": null,
"author": {
"kind": "api_key",
"id": "c1c1c1c1-0000-4000-8000-000000000001",
"name": "CRM propio"
},
"created_at": "2026-09-22T11:08:10.882Z",
"updated_at": "2026-09-22T11:08:10.882Z"
}
}Responde: 201 · 202 · 400 · 401 · 403 · 404 · 409 · 422 · 429
POST /conversations/{id}/tickets
Open a ticket on a conversation
The "long-running case" path — the service handles default-team routing, auto-assignment and the ticket.opened Evento. Gated on tickets:write, not conversations:write. stage_id places the ticket on a column of a ticket board; omit it and it lands on the workspace's fallback ticket board. A conversation that already has a ticket answers with that one rather than opening a second.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
pipeline_id | string | null | — | |
stage_id | string | null | — | |
reason | string | null | — | máx. 500 |
brand | string | null | — | máx. 120 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/tickets \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"stage_id": "55555555-0000-4000-8000-000000000011",
"reason": "El cliente pide reagendar su visita"
}'Ejemplo de respuesta (201)
{
"data": {
"id": "dddddddd-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"display_seq": 6,
"display_id": "T-6",
"status": "open",
"handler": "bot",
"assignee_user_id": null,
"assignee_ai_agent_graph_id": null,
"team_id": null,
"contact_id": "22222222-0000-4000-8000-000000000001",
"lead_id": null,
"origin_conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"current_stage_id": "55555555-0000-4000-8000-000000000011",
"snoozed_until": null,
"reason": "El cliente pide reagendar su visita",
"opened_by": "human",
"opened_via": "admin_ui",
"resolved_by": null,
"resolved_at": null,
"brand": null,
"created_at": "2026-09-22T11:06:47.738Z",
"updated_at": "2026-09-22T11:06:54.183Z"
}
}Responde: 201 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/transcribe
Transcribe a recording for the composer (multipart/form-data)
Speech-to-text for the operator's own recording: returns the text for them to edit before sending. Nothing is sent and no provider is involved — contrast the per-message route below, which transcribes what the customer sent.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/transcribe \
-H "Authorization: Bearer $VITRINA_KEY" \
-F "file=@/path/to/file.png"Ejemplo de respuesta (200)
{
"data": {
"text": "Hola, quería confirmar la hora del jueves."
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/unfilter
Restore a quarantined conversation to the inbox
Clears the quarantine and allowlists the sender, so the thread cannot be re-filtered later. The inverse of /filter-sender.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/unfilter \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/voice
Send a voice note (multipart/form-data)
Transcodes the upload to OGG/Opus — the format WhatsApp renders as a push-to-talk bubble rather than a file — and sends it as audio. Only channels whose capabilities include audio accept this (WhatsApp today); anywhere else is a 400. acknowledge is a COMMA-SEPARATED string in this multipart form.
Requires messages:send in addition to conversations:write. Sent by an API key, a personal token or a connected app, it goes through the política de envíos first — 422 OUTBOUND_BLOCKED, 409 OUTBOUND_WARNING (resend with acknowledge) or, for a connected app past its fan-out limit, 202 pending_approval. A member in the UI is not checked.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/voice \
-H "Authorization: Bearer $VITRINA_KEY" \
-F "file=@/path/to/file.png" \
-F "acknowledge=conversation_human_owned"Ejemplo de respuesta (201)
{
"data": {
"id": "eeeeeeee-0000-4000-8000-000000000002",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"content": "Hola Rodrigo, te confirmo la visita del jueves a las 10:00.",
"type": "text",
"sender_role": "assistant",
"sender_type": "api_key",
"sender_id": "c1c1c1c1-0000-4000-8000-000000000001",
"correlation_id": null,
"external_message_id": null,
"media_urls": null,
"media_sensitivity": null,
"media_processing_state": null,
"media_expires_at": null,
"derived_context_expires_at": null,
"metadata": null,
"tokens": 0,
"tool_calls": null,
"tool_call_id": null,
"delivery_status": "sent",
"delivery_error": null,
"delivery_attempt": 1,
"next_attempt_at": null,
"delivered_at": null,
"read_at": null,
"author": {
"kind": "api_key",
"id": "c1c1c1c1-0000-4000-8000-000000000001",
"name": "CRM propio"
},
"created_at": "2026-09-22T11:08:10.882Z",
"updated_at": "2026-09-22T11:08:10.882Z"
}
}Responde: 201 · 202 · 400 · 401 · 403 · 404 · 409 · 422 · 429
GET /conversations/counts
Inbox badge counts (my open / unread / mentions / unassigned)
The four badge counts on their own, scoped to the calling principal: myOpen and myUnread are the conversations assigned to the member behind the credential, myMentions the ones that @-mention them, unassigned the whole workspace’s unowned queue. An API key has no member behind it, so its three "my" counts read 0 and only unassigned is meaningful — use a personal token to get a person’s badges.
GET /conversations?include=counts returns the same payload beside a page of conversations; this route exists for the caller that wants the badges without one.
curl https://api.vitrinadev.com/api/v1/conversations/counts \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"myOpen": 3,
"myUnread": 1,
"unassigned": 2,
"myMentions": 0
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/email
A person starts an email — «Escribir por correo» / «Nuevo correo»
Creates AND sends a new email thread in one operation (ADR 0108 §Decision 10): a Contact id (must already carry an email) or a bare address (an unknown one becomes a Contact — the same de-duplication an inbound sender goes through), subject, body, optional cc, and the signature the reply composer already resolves. It originates through the tenant's connected mailbox (messaging-accounts/email-sender), keys the new conversation on the provider's thread, and the conversation is born under HUMAN control, assigned to the caller — never the AI. Unlike every automated rail, this send never consumes or waits on the mailbox budget and carries no unsubscribe footer: it is correspondence, not a rail. Refused 422 EMAIL_SENDER_UNRESOLVED when no sending mailbox resolves — none enabled, several with none designated, or the one that would send being disconnected.
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
contact_id | uuid | — | |
to | string | — | email, máx. 255 |
subject | string | sí | mín. 1, máx. 255 |
content | string | sí | mín. 1, máx. 8000 |
cc | string[] | — | |
html | string | — | máx. 200000 |
signature_id | string | null | — |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/email \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"contact_id": "22222222-0000-4000-8000-000000000001",
"subject": "Resumen de lo que conversamos hoy",
"content": "Hola María, te dejo por escrito el resumen de lo que revisamos hoy.",
"cc": [
"[email protected]"
]
}'Ejemplo de respuesta (201)
{
"data": {
"conversation": {
"id": "bbbbbbbb-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"display_seq": 132,
"display_id": "C-132",
"channel": "email",
"external_id": "web:visitor-9f2c1a",
"messaging_account_id": "16161616-0000-4000-8000-000000000001",
"brand": null,
"contact_id": "22222222-0000-4000-8000-000000000001",
"ticket_id": "dddddddd-0000-4000-8000-000000000001",
"status": "open",
"handler": "human",
"assignee_user_id": "11111111-0000-4000-8000-000000000001",
"assignee_assigned_at": "2026-09-22T11:06:54.183Z",
"snoozed_until": null,
"resolved_at": null,
"resolved_by": null,
"closed_at": null,
"close_deferred_until": null,
"transfer_immediately": false,
"filtered_at": null,
"filtered_reason": null,
"summary": null,
"summarized_at": null,
"history_summary": null,
"history_summary_upto_message_id": null,
"first_billable_at": null,
"billable": null,
"billing_reason": null,
"assigned_unanswered_alerted_at": null,
"assigned_unanswered_realerted_at": null,
"bot_gate_override_at": null,
"bot_gate_override_by": null,
"source": null,
"awaiting_human_since": null,
"awaiting_payment_since": null,
"awaiting_payment_escalated_at": null,
"awaiting_obligation_id": null,
"ad_referral": null,
"ad_join": null,
"ad_welcome": null,
"metadata": null,
"ai_control_source": "legacy",
"ai_control_changed_at": null,
"ai_control_revision": 0,
"ai_control_window_start": null,
"ai_control_window_end": null,
"ai_control_account_revision": null,
"ai_blocked_until": null,
"ai_keep_with_human": false,
"external_id_provisional": false,
"originated_at": null,
"originated_by_kind": null,
"originated_by_ai_agent_id": null,
"originated_by_user_id": null,
"originated_outbound_action_id": null,
"last_message_date": "2026-09-22T11:08:10.882Z",
"created_at": "2026-09-22T11:06:33.744Z",
"updated_at": "2026-09-22T11:08:10.882Z"
},
"message": {
"id": "eeeeeeee-0000-4000-8000-000000000002",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"content": "Hola Rodrigo, te confirmo la visita del jueves a las 10:00.",
"type": "text",
"sender_role": "assistant",
"sender_type": "api_key",
"sender_id": "c1c1c1c1-0000-4000-8000-000000000001",
"correlation_id": null,
"external_message_id": null,
"media_urls": null,
"media_sensitivity": null,
"media_processing_state": null,
"media_expires_at": null,
"derived_context_expires_at": null,
"metadata": null,
"tokens": 0,
"tool_calls": null,
"tool_call_id": null,
"delivery_status": "sent",
"delivery_error": null,
"delivery_attempt": 1,
"next_attempt_at": null,
"delivered_at": null,
"read_at": null,
"author": {
"kind": "api_key",
"id": "c1c1c1c1-0000-4000-8000-000000000001",
"name": "CRM propio"
},
"created_at": "2026-09-22T11:08:10.882Z",
"updated_at": "2026-09-22T11:08:10.882Z"
},
"contact": {
"id": "22222222-0000-4000-8000-000000000001",
"created": false,
"email": "[email protected]"
}
}
}Responde: 201 · 400 · 401 · 403 · 404 · 409 · 429