Conversations
The threads with customers and patients: read the inbox, reply, assign, snooze and resolve — with the author of every message.
Download the full API pública projection: openapi.json.
Reply and resolve tickets explains this resource in prose, with runnable examples.
| Method | Path | What it does |
|---|---|---|
GET | /conversations | List conversations |
POST | /conversations | Open a conversation from the platform side |
GET | /conversations/{id} | Get conversation |
GET | /conversations/{id}/agent-turn | Inspect the exact state the AI agent would receive |
PATCH | /conversations/{id}/ai-control | Keep a conversation with people across AI availability windows |
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 |
POST | /conversations/{id}/bot-gate-override | Let the AI answer this conversation despite the no-auto-reply gate |
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}/continue-on-whatsapp | Continue a conversation on WhatsApp |
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-conversations | List a conversation's linked conversations |
POST | /conversations/{id}/linked-conversations | Link a conversation to its continuation on another channel |
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 |
POST | /conversations/{id}/media-assets/{assetId}/send | Send a library file to the customer |
POST | /conversations/{id}/media/sign | Sign conversation media urls for a short-lived read |
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 |
DELETE | /conversations/{id}/no-reply-needed | Undo «No requiere respuesta» |
POST | /conversations/{id}/no-reply-needed | Mark the current wait as needing no reply («No requiere respuesta») |
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) |
POST | /conversations/{id}/voice-clips/{clipId}/send | Send a library voice clip to the customer |
POST | /conversations/{id}/voice-note | Send a typed line as a voice note in the agent’s voice |
GET | /conversations/{id}/whatsapp-continuations | List a conversation's Continuaciones por WhatsApp |
GET | /conversations/counts | Inbox badge counts (my open / unread / mentions / unassigned / waiting) |
POST | /conversations/email | A person starts an email — «Escribir por correo» / «Nuevo correo» |
POST | /conversations/whatsapp | A person opens a WhatsApp thread — «Escribir» → WhatsApp |
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.
Each row also carries ad_origin (the AdOrigin schema) — the Meta ad that opened the thread, in ONE shape whatever the channel (click-to-WhatsApp or an Instagram DM ad), or null for an organic thread. platform is the thread channel; ad_external_id is Meta's ad id (null for a boosted post); ad_name and campaign_name are null when the Ads read does not know them; title is the ADVERTISER'S copy, never the customer's words; thumbnail_url is Vitrina's durable copy of the creative when media_archived is true, else Meta's own link, which expires — render it with a fallback; captured_at is when the thread opened. Meta's own object stays on ad_referral, verbatim and write-once, in the channel's spelling (source_id/ctwa_clid on WhatsApp; source: 'ADS', ad_id, ads_context_data on Instagram).
Each row also carries campaign_origin — the newest campaign send stored in the thread (null when none): its campaign, when it left, whether the send created the thread, and whether it is still the newest message (awaiting_reply). fromCampaign=true lists every such thread, closed ones included.
Each row also carries waiting_since — when its customer started waiting on a human, null when nobody waits (the «Esperando respuesta» rule; see the waiting parameter). waiting=true lists only waiting conversations, team-wide; sort=wait orders urgent first, then the longest wait. waiting_state says why: owed (waiting), closed_by_customer (the customer only acknowledged, thanked or closed after we answered), nothing_owed (they declined, or the message is not for us), paid_or_in_store (a transfer receipt right after we sent our bank details, or «llegué» / «voy subiendo»: a suggestion for a person to confirm — nothing moves a lead or records a payment) or promised_later (we promised to write on a later day: a dated «Recordatorio acordado» follow-up, due at waiting_due_at, after which the thread is waiting again) or visit_passed (the customer announced a store visit — «mañana paso», «el martes voy» — for a day that is now over, with nothing newer from them: a «Recordatorio acordado» to check whether they came falls due at waiting_due_at, the morning after; the thread does not come back to the queue when it does) or customer_later (the customer deferred — «la otra semana», «por la quincena me acerco», «el lunes te aviso» — and an open «Recordatorio acordado» covers it, due at waiting_due_at: out of the queue until the customer writes again or it falls due) — the ones left out of the queue by a confident verdict on the newest unanswered customer message, voice notes read as their transcript — and null when nobody waits at all. waiting=closed lists those left-out threads (still live: resolving stays a person’s click).
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.
fields=summary answers the SAME page with only the identifying and funnel scalars per row — id, display_id, channel, source, status, handler, assignee_user_id, contact_id, ticket_id, awaiting_human_since, first_ai_message_at, last_message_date, created_at, ad_origin — plus contact_name, ticket_status and current_stage_id off the embeds, and assignee_name / unread when the list computed them (an api_key has no read markers, so no unread). No contact, ticket, lastMessage, tags, marketplaceWindow or last_call. meta and counts are unchanged. A 20-row page is ~147k characters at full (the default, byte-identical to omitting the parameter) and a fraction of that at summary; page with summary to choose, then GET /conversations/\{id\} for one thread whole.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
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 | |
fromAd | query | boolean | null | no | |
fromCampaign | query | boolean | null | no | |
waiting | query | any | no | |
attribute | query | array | null | no | |
tagIds | query | array | null | no | |
viewId | query | uuid | no | |
sort | query | created_at \ | updated_at \ | last_message_date \ |
order | query | asc \ | desc | no |
include | query | counts | no | |
fields | query | summary \ | full | no |
curl https://api.vitrinadev.com/api/v1/conversations \
-H "Authorization: Bearer $VITRINA_KEY"Example response (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,
"ad_origin": null,
"tags": [
{
"id": "12121212-0000-4000-8000-000000000001",
"name": "garantia",
"display_name": "Garantía"
}
],
"last_call": null,
"handoff_urgent": false,
"waiting_since": null,
"waiting_state": null,
"waiting_due_at": null,
"waiting_subject_id": null
},
{
"id": "bbbbbbbb-0000-4000-8000-000000000002",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"display_seq": 133,
"display_id": "C-133",
"channel": "instagram",
"external_id": "<instagram-scoped-id>",
"messaging_account_id": "16161616-0000-4000-8000-000000000001",
"brand": null,
"contact_id": "22222222-0000-4000-8000-000000000001",
"ticket_id": null,
"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": {
"ref": null,
"source": "ADS",
"type": "OPEN_THREAD",
"ad_id": "120236000000000001",
"ads_context_data": {
"ad_title": "Evaluación sin costo este mes",
"photo_url": null,
"video_url": "https://lookaside.fbsbx.com/ig_messaging_cdn/?asset_id=<asset>",
"post_id": "18000000000000001",
"product_id": null
}
},
"ad_join": null,
"ad_welcome": null,
"metadata": {
"ad_creative": {
"thumbnail_url": "https://media.vitrinadev.com/ad-creatives/a1a1a1a1-0000-4000-8000-000000000001/<digest>.jpg",
"source": "share_media",
"permalink": "https://www.instagram.com/p/<shortcode>/",
"archived_at": "2026-09-22T10:42:15.120Z"
}
},
"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-22T10:41:12.310Z",
"created_at": "2026-09-22T10:41:12.004Z",
"updated_at": "2026-09-22T10:41:12.310Z",
"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": null,
"lastMessage": {
"id": "eeeeeeee-0000-4000-8000-000000000001",
"content": "Hola, quiero más información",
"sender_type": "contact",
"created_at": "2026-09-22T10:41:12.310Z"
},
"marketplaceWindow": null,
"ad_origin": {
"platform": "instagram",
"kind": "ad",
"ad_external_id": "120236000000000001",
"ad_name": "Evaluación facial — septiembre",
"campaign_name": "Mensajes Instagram · Evaluación",
"title": "Evaluación sin costo este mes",
"thumbnail_url": "https://media.vitrinadev.com/ad-creatives/a1a1a1a1-0000-4000-8000-000000000001/<digest>.jpg",
"media_archived": true,
"video_url": "https://lookaside.fbsbx.com/ig_messaging_cdn/?asset_id=<asset>",
"source_url": "https://www.instagram.com/p/<shortcode>/",
"captured_at": "2026-09-22T10:41:12.004Z"
},
"tags": [],
"last_call": null,
"handoff_urgent": false,
"waiting_since": null,
"waiting_state": null,
"waiting_due_at": null,
"waiting_subject_id": null
}
],
"meta": {
"pagination": {
"total": 18,
"limit": 20
},
"page": 1,
"totalPages": 1,
"hasNext": false,
"hasPrev": false
},
"counts": {
"myOpen": 3,
"myUnread": 1,
"unassigned": 2,
"myMentions": 0
}
}Answers: 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. A WhatsApp thread is keyed on the number (its wa_id), so when that number already has an open thread, that thread is returned instead of a second one.
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
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"
}
}'Example response (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"
}
}Answers: 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 ad (click-to-WhatsApp or an Instagram DM 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.
And handoff_resolved_suggested: boolean (#2968) — true only on an open/pending AI-handoff thread whose customer is still waiting on us AND Jev already read their own pending words as saying the matter is resolved (checked once per customer message, at the next handoff re-notify tick). The inbox reads this to offer a one-click close. THIS ROUTE ONLY, same N+1 reasoning as ad_origin_nudge — unlike handoff_urgent below, this one was never worth a column since only the open thread needs it.
handoff_urgent: boolean (#2969) is a plain conversation column, not a per-route annotation: true while an open/pending AI-handoff thread's customer is still waiting on us and Jev read their newest pending message as urgent (a clinic post-treatment complication, or an explicit same-day deadline) — kept in sync by the same classification that also tightens the re-notify cadence, and present on every conversation read, list included, because the inbox shows the flag on the conversation ROW too, not only its header.
Carries ad_origin exactly as GET /conversations rows do.
reply_via (THIS ROUTE ONLY): non-null on a conversation that cannot carry a reply (a website form or a Yapo lead) — the WhatsApp thread to answer in, or conversation_id: null when the contact has none open yet.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
exclude | query | messages | no |
curl https://api.vitrinadev.com/api/v1/conversations/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Example response (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,
"ad_origin": null,
"tags": [
{
"id": "12121212-0000-4000-8000-000000000001",
"name": "garantia",
"display_name": "Garantía"
}
],
"last_call": null,
"handoff_urgent": false,
"waiting_since": null,
"waiting_state": null,
"waiting_due_at": null,
"waiting_subject_id": null,
"ad_origin_nudge": null,
"handoff_resolved_suggested": false,
"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"
}
]
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /conversations/{id}/agent-turn
Inspect the exact state the AI agent would receive
Rebuilds, through the same assembler the worker uses, everything the agent sees on this conversation: the full system prompt, the gated tool catalogue, message history, temperature and step budget. The model and reasoning effort are platform-managed and not included. Read-only — no enrichment, no tool side effects. Answers a JSON file download (Content-Disposition: attachment), not the \{ data \} envelope the other reads use. On a clinic, a connected app reads the patient in the prompt, history and tool results as a pseudonym unless the clinic allows patient names, like every other read.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/agent-turn \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"inspected_at": "2026-10-09T16:40:00.000Z",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"display_id": "C-132",
"channel": "web",
"agent_version": null,
"ticket": {
"id": "dddddddd-0000-4000-8000-000000000001",
"display_id": "T-6",
"status": "open",
"current_stage_id": "55555555-0000-4000-8000-000000000011"
},
"agent": {
"id": "7b7b7b7b-0000-4000-8000-000000000001",
"name": "Asistente de ventas"
},
"temperature": 0.3,
"max_steps": 8,
"system_prompt": "Eres el asistente de ventas de Automotora Ejemplo. Responde en español, con tono cercano…",
"messages": [
{
"role": "user",
"content": "Hola, ¿siguen disponibles los repuestos que vi en la web?"
},
{
"role": "assistant",
"content": "Hola María, sí. ¿Qué repuesto te interesa?"
}
],
"history_summary": null,
"tools": [
{
"type": "function",
"function": {
"name": "search_products",
"description": "Busca productos del catálogo por texto, marca o precio.",
"parameters": {
"type": "object",
"properties": {
"q": {
"type": "string"
}
}
}
}
}
],
"knowledge_base": [
{
"name": "Preguntas frecuentes.pdf"
}
],
"skills": [],
"workspace": {
"language": "es",
"glossary": [],
"timezone": "America/Santiago",
"withinBusinessHours": true,
"businessHoursReason": null,
"currency": "CLP",
"dateFormat": "latam"
},
"branding": {
"displayName": "Automotora Ejemplo",
"senderName": "Automotora Ejemplo",
"website": "https://ejemplo.cl"
},
"vertical": "automotive",
"connected_channels": [
{
"name": "WhatsApp Ventas",
"channels": [
"whatsapp"
]
}
],
"branches": [],
"pipelines": [],
"sales_pipelines": [],
"open_leads": [],
"previous_conversations": {
"items": [],
"olderCount": 0
},
"business_hours": null,
"attributes": [],
"contact_attributes": []
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
PATCH /conversations/{id}/ai-control
Keep a conversation with people across AI availability windows
keep_with_human: true pins the conversation to its human handler: the AI stays silent on it even after an AI availability window opens, until you send false. If the conversation is not yet handled by a person it is first switched to the human handler (keeping the current assignee, if any). false releases the pin and lets the normal availability rules apply again. Scoped to this one conversation. Needs the conversations:write scope; 404 when the conversation does not exist in the workspace. Answers the resulting control state: source, revision, changed_at and blocked_until.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
keep_with_human | boolean | yes |
curl -X PATCH https://api.vitrinadev.com/api/v1/conversations/<id>/ai-control \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"keep_with_human": true
}'Example response (200)
{
"data": {
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"keep_with_human": true,
"source": "persistent_human",
"changed_at": "2026-10-09T16:41:00.000Z",
"revision": 3,
"blocked_until": null
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/ai-status \
-H "Authorization: Bearer $VITRINA_KEY"Example response (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
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
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"
}'Example response (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"
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
ticket_id | string | yes | 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"
}'Example response (200)
{
"data": {
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000002",
"ticket_id": "dddddddd-0000-4000-8000-000000000001",
"previous_ticket_id": null
}
}Answers: 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.
Or a JSON body (application/json) — what an MCP connector sends through call_operation, which cannot build multipart: \{ filename, content, content_encoding?, content_type? \} plus the operation's own form fields. content is UTF-8 text by default, or base64 when content_encoding is "base64" (images, PDF, audio, video). The same byte cap applies to the decoded bytes (413 above it); invalid base64 is a 400; the content type defaults from the filename extension. In the JSON body acknowledge is the same comma-separated string.
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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
filename | string | yes | mín. 1, máx. 255 |
content | string | yes | mín. 1, máx. 35000000 |
content_encoding | utf8 \ | base64 | — |
content_type | string | — | máx. 200 |
caption | string | — | |
acknowledge | string | — |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/attachments \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"file": "@comprobante.pdf",
"caption": "Te adjunto el comprobante.",
"acknowledge": "conversation_human_owned"
}'Example response (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,
"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"
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/attributes \
-H "Authorization: Bearer $VITRINA_KEY"Example response (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"
}
]
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
attributes | object[] | yes |
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"
}
]
}'Example response (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"
}
]
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
key | path | string | yes | 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"Answers: 204 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/bot-gate-override
Let the AI answer this conversation despite the no-auto-reply gate
enabled: true stamps a per-conversation override so the AI may answer even though the contact is over the workspace follower threshold (or is marked "sin bot"); false re-arms the gate. Scoped to this conversation only — it changes nothing for the contact or for any other thread. Read the current state from GET /conversations/\{id\}/ai-status (bot_gate_override_at).
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
enabled | boolean | yes |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/bot-gate-override \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"enabled": true
}'Example response (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": "2026-10-09T16:42:10.000Z",
"bot_gate_override_by": "11111111-0000-4000-8000-000000000001",
"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",
"name": "María González",
"email": "[email protected]",
"phone": "+56 9 1234 5678"
},
"ticket": {
"id": "dddddddd-0000-4000-8000-000000000001",
"display_id": "T-6",
"status": "open",
"current_stage_id": "55555555-0000-4000-8000-000000000011"
}
}
}Answers: 200 · 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | 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"Example response (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
}
]
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
call_id | path | uuid | yes |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/calls/<id>/recording-url \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"url": "https://media.vitrinadev.com/calls/17171717/recording.mp3?X-Amz-Expires=300&…",
"expires_at": "2026-09-22T11:20:00.000Z"
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/claim \
-H "Authorization: Bearer $VITRINA_KEY"Example response (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"
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/close \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"status": "closed"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/continue-on-whatsapp
Continue a conversation on WhatsApp
Continuación por WhatsApp: the dealer writes to the customer on WhatsApp to continue THIS conversation (Instagram, Messenger, web chat, voice or Marketplace — never a WhatsApp conversation), links the two conversations and hands over what was agreed. phone is optional: omitted, the number captured in the conversation (else the contact's) is used. The send is scheduled, never synchronous. Idempotent per (conversation, phone): asking again returns the existing Continuación (outcome: already, or linked when the customer already continues on that number from another conversation) and never sends twice; a refused or failed one is re-armed. state is pending (scheduled), deferred (waiting for an approved WhatsApp template, sent as soon as it is approved and never after send_deadline_at), sent, failed or skipped. A refusal is a 422 whose details.reason says why (no_phone, no_whatsapp, do_not_contact, dealer_number, buyer_wrote_first, unsupported_channel, billing_paused, no_name, ...). A conversation the caller cannot see is a 404, like one that does not exist.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
phone | string | null | — | mín. 5, máx. 40 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/continue-on-whatsapp \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "+56 9 8765 4321"
}'Example response (200)
{
"data": {
"outcome": "scheduled",
"reason": null,
"continuation_id": "0c4f7a92-1e6b-4d38-8a5f-63b2e9d1c470",
"state": "pending",
"status": "pending",
"phone": "+56987654321",
"source_conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"continuation_conversation_id": "5a8e2c41-7b3d-4f19-9c06-2d7e4b1a8f53",
"link_id": "0c4f7a92-1e6b-4d38-8a5f-63b2e9d1c470",
"merged_into_contact_id": null
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/detach-from-ticket \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"previous_ticket_id": "dddddddd-0000-4000-8000-000000000001"
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
format | query | json \ | markdown | no |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/export \
-H "Authorization: Bearer $VITRINA_KEY"Example response (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"
}
]
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/filter-sender \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001"
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | 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"Example response (200)
{
"data": {
"media_id": "2038475610394856",
"header_type": "image"
}
}Answers: 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; with the window closed the send is refused with 409 window_closed (send a template instead). 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
flow_id | string | yes | mín. 1, máx. 64 |
body | string | yes | mín. 1, máx. 1024 |
cta | string | yes | mín. 1, máx. 30 |
header | any | — | |
footer | string | — | mín. 1, máx. 60 |
context | object | — | |
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"
}'Example response (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,
"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"
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
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"
}'Example response (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"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /conversations/{id}/linked-conversations
List a conversation's linked conversations
Conversaciones enlazadas: the same customer in ONE exchange over two channels — a source conversation (e.g. Facebook Marketplace) and its continuation (e.g. WhatsApp). Each conversation keeps its own control. Every link is returned from THIS conversation's side: role says whether it is the source or the continuation, and linked_conversation is the other one. context_card is what the continuation carries from the source (the car, what was agreed, what is pending); it is never a message. Newest link first; empty when the conversation has no links. A member who sees only their own conversations does not get a link whose other conversation they cannot see.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/linked-conversations \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": [
{
"id": "0c4f7a92-1e6b-4d38-8a5f-63b2e9d1c470",
"role": "source",
"origin": "manual",
"source_conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"continuation_conversation_id": "5a8e2c41-7b3d-4f19-9c06-2d7e4b1a8f53",
"context_card": {
"summary": "Pregunta por la Hilux 2021 publicada en Marketplace; quiere verla el sábado.",
"vehicle": {
"vehicle_id": "8d1b6e3a-4c27-4f90-b5a8-1e7c3d9f2a64",
"label": "Toyota Hilux 2.8 SRX 4x4 2021"
},
"agreements": [
"Visita el sábado a las 11:00 en la sucursal Las Condes"
],
"pending_asks": [
"Fotos del interior y del tablero"
],
"pending_promises": [
"Enviar la ficha técnica por WhatsApp"
],
"trade_in": "Entrega en parte de pago un Suzuki Swift 2017, 98.000 km"
},
"created_by_user_id": null,
"created_at": "2026-10-15T14:02:11.000Z",
"linked_conversation": {
"id": "5a8e2c41-7b3d-4f19-9c06-2d7e4b1a8f53",
"display_id": "C-3061",
"channel": "whatsapp",
"status": "open",
"last_message_date": "2026-10-15T14:05:40.000Z"
}
}
],
"meta": {
"total": 1
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/linked-conversations
Link a conversation to its continuation on another channel
\{id\} is the SOURCE conversation; continuation_conversation_id is where the exchange continues. Both must belong to the same contact (merge the contacts first) and run on different channels. Idempotent per pair, in either direction: 201 when the link is created, 200 with the existing link when the two were already linked — a context_card in the body then replaces the stored one. Nothing is sent to the customer. Both conversations must be visible to the caller: one they cannot see is a 404, like one that does not exist.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
continuation_conversation_id | uuid | yes | |
context_card | object | — |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/linked-conversations \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"continuation_conversation_id": "5a8e2c41-7b3d-4f19-9c06-2d7e4b1a8f53",
"context_card": {
"summary": "Pregunta por la Hilux 2021 publicada en Marketplace; quiere verla el sábado.",
"vehicle": {
"vehicle_id": "8d1b6e3a-4c27-4f90-b5a8-1e7c3d9f2a64",
"label": "Toyota Hilux 2.8 SRX 4x4 2021"
},
"agreements": [
"Visita el sábado a las 11:00 en la sucursal Las Condes"
],
"pending_asks": [
"Fotos del interior y del tablero"
],
"pending_promises": [
"Enviar la ficha técnica por WhatsApp"
],
"trade_in": "Entrega en parte de pago un Suzuki Swift 2017, 98.000 km"
}
}'Example response (200)
{
"data": {
"id": "0c4f7a92-1e6b-4d38-8a5f-63b2e9d1c470",
"role": "source",
"origin": "manual",
"source_conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"continuation_conversation_id": "5a8e2c41-7b3d-4f19-9c06-2d7e4b1a8f53",
"context_card": {
"summary": "Pregunta por la Hilux 2021 publicada en Marketplace; quiere verla el sábado.",
"vehicle": {
"vehicle_id": "8d1b6e3a-4c27-4f90-b5a8-1e7c3d9f2a64",
"label": "Toyota Hilux 2.8 SRX 4x4 2021"
},
"agreements": [
"Visita el sábado a las 11:00 en la sucursal Las Condes"
],
"pending_asks": [
"Fotos del interior y del tablero"
],
"pending_promises": [
"Enviar la ficha técnica por WhatsApp"
],
"trade_in": "Entrega en parte de pago un Suzuki Swift 2017, 98.000 km"
},
"created_by_user_id": null,
"created_at": "2026-10-15T14:02:11.000Z",
"linked_conversation": {
"id": "5a8e2c41-7b3d-4f19-9c06-2d7e4b1a8f53",
"display_id": "C-3061",
"channel": "whatsapp",
"status": "open",
"last_message_date": "2026-10-15T14:05:40.000Z"
}
}
}Answers: 200 · 201 · 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/linked-records \
-H "Authorization: Bearer $VITRINA_KEY"Example response (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": []
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
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"
}'Example response (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"
}
},
"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"
}
}Answers: 201 · 202 · 400 · 401 · 403 · 404 · 409 · 422 · 429
POST /conversations/{id}/media-assets/{assetId}/send
Send a library file to the customer
Sends one file of the workspace library (GET /media-assets) into the conversation as its own message, as the caller. Images, videos and PDFs are copied into the conversation (the thread keeps its copy if the library file is later replaced or deleted) and go out with caption (omitted = the file’s own caption; null = none). Audio goes out as a voice note with its transcript, exactly like POST /conversations/\{id\}/voice. The message carries metadata.media_asset_id, and the send is counted on the file (send_count). A file already sent in this conversation may be sent again.
Refused with details.code: 409 MEDIA_ASSET_NOT_SENDABLE (details.reason: expired, or consent_required — it shows a person whose consent is not confirmed), 409 MEDIA_CHANNEL_UNSUPPORTED (details.channel, details.kind — e.g. a PDF on Instagram), 409 VOICE_NOTE_UNSUPPORTED (audio on a channel without voice notes; details.text holds its words so the caller can send them as text), 413 MEDIA_TOO_LARGE (over the channel’s per-file cap — email takes 3 MB). Nothing is sent on any refusal. Another workspace’s file is 404.
Needs macros:read (the library) as well as conversations:write and messages:send.
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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
assetId | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
caption | string | null | — | máx. 1024 |
acknowledge | string[] | — |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/media-assets/<id>/send \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"caption": "Resultado real tras 3 sesiones"
}'Example response (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,
"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"
}
}Answers: 201 · 202 · 400 · 401 · 403 · 404 · 409 · 422 · 429
POST /conversations/{id}/media/sign
Sign conversation media urls for a short-lived read
Stored media urls (message.media_urls, media_playback) are references, not links: the public media proxy answers 403 to an unsigned url and 410 to an expired one (vitrina-app#5263). Trade them here for urls that work for 15 minutes (expires_at), then sign again — a client re-signs before expiry, or on a 410.
Only urls whose object belongs to THIS conversation are signed; one from another thread comes back with error: "forbidden". A url that is not conversation media (a channel CDN link) is echoed back in signed_url with expires_at: null. A healthcare workspace's urls are served Cache-Control: no-store. Never log or persist a signed url: it is a bearer credential until it expires.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
urls | string[] | yes |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/media/sign \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"urls": [
"https://api.vitrina.example/api/v1/public/conversation-media/bbbbbbbb-0000-4000-8000-000000000001/0b7f2c51-voice.ogg"
]
}'Example response (200)
[
{
"url": "https://api.vitrina.example/api/v1/public/conversation-media/bbbbbbbb-0000-4000-8000-000000000001/0b7f2c51-voice.ogg",
"signed_url": "https://api.vitrina.example/api/v1/public/conversation-media/bbbbbbbb-0000-4000-8000-000000000001/0b7f2c51-voice.ogg?exp=1793000000&g=s&ns=1&sig=REDACTED",
"expires_at": "2026-10-23T12:15:00.000Z"
}
]Answers: 200 · 400 · 401 · 403 · 404 · 409 · 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.
attachments has one entry per media_urls entry, in the same order (index, url, mime, playback_url, playback_mime). On audio, playback_url is a file every iOS player handles — the original when it already is mp4/m4a/mp3/aac, else its AAC rendition (audio/mp4); null until that rendition exists, in which case GET /messages/\{id\}/audio-playback?attachment=\{index\} generates it and redirects.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | 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"Example response (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,
"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",
"attachments": []
},
{
"id": "eeeeeeee-0000-4000-8000-000000000009",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"content": "",
"type": "audio",
"sender_role": "user",
"sender_type": "contact",
"sender_id": null,
"correlation_id": null,
"external_message_id": null,
"media_urls": [
"https://api.vitrina.example/api/v1/public/conversation-media/bbbbbbbb-0000-4000-8000-000000000001/0b7f2c51-voice.ogg"
],
"media_sensitivity": null,
"media_processing_state": null,
"media_expires_at": null,
"derived_context_expires_at": null,
"metadata": {
"mime": "audio/ogg; codecs=opus",
"size": 18342
},
"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:40.112Z",
"updated_at": "2026-09-22T11:07:40.112Z",
"attachments": [
{
"index": 0,
"url": "https://api.vitrina.example/api/v1/public/conversation-media/bbbbbbbb-0000-4000-8000-000000000001/0b7f2c51-voice.ogg",
"mime": "audio/ogg; codecs=opus",
"playback_url": "https://api.vitrina.example/api/v1/public/conversation-media/bbbbbbbb-0000-4000-8000-000000000001/0b7f2c51-voice.ogg.m4a",
"playback_mime": "audio/mp4"
}
]
},
{
"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,
"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",
"attachments": []
},
{
"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,
"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",
"attachments": []
}
],
"meta": {
"pagination": {
"limit": 50,
"nextCursor": "eyJ0cyI6IjIwMjYtMDktMjJUMTE6MDg6MTAuODgyWiIsImlkIjoiZWVlZWVlZWUtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAyIn0="
},
"order": "asc"
}
}Answers: 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\}.
Instagram and Messenger reply window. A reply from a signed-in member may go out up to 7 days after the customer's last message: past the standard 24 hours it is sent with Meta's HUMAN_AGENT tag. Past 7 days — or when the customer never wrote — it is refused with 422 MESSAGING_WINDOW_CLOSED and nothing is stored. Every other caller (API key, personal token, connected app) keeps the 24-hour window.
Read-only channels. A website form (website) or Yapo lead conversation has no reply rail: the send is refused with 409 CHANNEL_READ_ONLY (details.channel) and nothing is stored or queued. The same holds for templates, attachments, location, Flows and voice notes. Answer on WhatsApp — see reply_via on GET /conversations/\{id\}.
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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
content | string | yes | 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"
]
}'Example response (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,
"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"
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
messageId | path | string | yes | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/messages/<id>/transcribe \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"text": "Hola, quería confirmar la hora del jueves.",
"cached": true
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /conversations/{id}/no-reply-needed
Undo «No requiere respuesta»
Removes the click POST /conversations/\{id\}/no-reply-needed recorded on the current wait (and the verdict row it created), so the conversation waits again as before. subject_id (optional) names the wait the undo is for: when the conversation now waits on another message, nothing changes. Nothing to undo → changed: false.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
subject_id | query | uuid | no |
curl -X DELETE https://api.vitrinadev.com/api/v1/conversations/<id>/no-reply-needed \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"subject_id": "01a17d4e-c0de-7a11-9d0e-5e1a7e5b0c11",
"subject_kind": "message",
"waiting_state": "nothing_owed",
"changed": true
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/{id}/no-reply-needed
Mark the current wait as needing no reply («No requiere respuesta»)
Takes the conversation out of «Esperando respuesta» for the wait the person saw: subject_id is the row’s waiting_subject_id (the newest customer message nobody on the team answered; with none pending, the open handoff’s event). If the customer wrote again since, the call answers 409 WAITING_SUBJECT_CHANGED (details.subject_id = the current one) and records nothing. The customer’s next message starts a new wait. Recorded as a correction of the reply_owed verdict on that subject (label nothing_owed); a click outranks any verdict on the same subject, and this workspace’s later verdicts read recent clicks as examples — never another workspace’s. Idempotent (one click per verdict, concurrent clicks included): already out of the queue → changed: false. Other 409s carry details.reason: not_waiting, or one the queue never hides — urgent, payment_obligation (a payment held open), promised_human_contact (the AI told the customer a person would contact them). subject_kind is message or handoff; waiting_state is the thread’s state after the call.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
subject_id | uuid | yes |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/no-reply-needed \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"subject_id": "01a17d4e-c0de-7a11-9d0e-5e1a7e5b0c11"
}'Example response (200)
{
"data": {
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"subject_id": "01a17d4e-c0de-7a11-9d0e-5e1a7e5b0c11",
"subject_kind": "message",
"waiting_state": "nothing_owed",
"changed": true
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/notes \
-H "Authorization: Bearer $VITRINA_KEY"Example response (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
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
body | string | yes | 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."
}'Example response (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"
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
noteId | path | uuid | yes |
curl -X DELETE https://api.vitrinadev.com/api/v1/conversations/<id>/notes/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
noteId | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
body | string | yes | 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?"
}'Example response (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"
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/participants \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": [
{
"user_id": "11111111-0000-4000-8000-000000000001",
"name": "Camila Rojas",
"avatar_url": null,
"is_assignee": true
}
]
}Answers: 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".
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/pending \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"status": "pending"
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/reopen \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"status": "open"
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/resolve \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"status": "resolved"
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/return-to-ai \
-H "Authorization: Bearer $VITRINA_KEY"Example response (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"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /conversations/{id}/scheduled-messages
List pending scheduled sends
The replies waiting to fire on this conversation, plus the ones that FAILED in the last 7 days (status: failed), one per send. A failed row carries failure: \{ code, label \}: code is one of copy_refused, policy_blocked, conversation_missing, delivery_failed or not_sent, and label is a Spanish sentence to show as is; the raw error is never exposed. failure is null on a pending row. One that has gone out, was cancelled or was dismissed 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/scheduled-messages \
-H "Authorization: Bearer $VITRINA_KEY"Example response (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",
"failure": null,
"created_at": "2026-09-22T11:09:04.112Z"
},
{
"id": "44444444-0000-4000-8000-00000000b002",
"content": "Adjunto la cotización que me pediste.",
"send_at": "2026-09-22T15:00:00+00:00",
"status": "failed",
"failure": {
"code": "copy_refused",
"label": "Una dirección en copia ya no puede recibir correos (se dio de baja, rebotó o marcó spam). Quítala de cc/bcc y vuelve a enviarlo."
},
"created_at": "2026-09-22T10:02:11.004Z"
}
]
}Answers: 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. On a FAILED row it dismisses it: the row leaves the list. A message that already went out (or was already cancelled or dismissed) is a 404.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
scheduledId | path | uuid | yes |
curl -X DELETE https://api.vitrinadev.com/api/v1/conversations/<id>/scheduled-messages/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
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
}'Example response (200)
{
"data": {
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"status": "snoozed",
"snoozed_until": "2026-09-22T13:07:20.217Z"
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/tags \
-H "Authorization: Bearer $VITRINA_KEY"Example response (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"
}
]
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | 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"
}'Example response (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"
}
}Answers: 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\}.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
tag_id | path | uuid | yes |
curl -X DELETE https://api.vitrinadev.com/api/v1/conversations/<id>/tags/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
template_id | uuid | yes | |
params | object | — | |
acknowledge | string[] | — | |
keep_handler | boolean | — |
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"
}
}'Example response (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,
"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"
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
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"
}'Example response (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"
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | 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"Example response (200)
{
"data": {
"text": "Hola, quería confirmar la hora del jueves."
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/unfilter \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001"
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | 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"Example response (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,
"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"
}
}Answers: 201 · 202 · 400 · 401 · 403 · 404 · 409 · 422 · 429
POST /conversations/{id}/voice-clips/{clipId}/send
Send a library voice clip to the customer
Deprecated — use POST /conversations/\{id\}/media-assets/\{assetId\}/send (a voice clip is an audio file of the library, same id). Kept working unchanged until it is removed.
Sends one clip of the workspace «Clip de voz» library (GET /voice-clips) into the conversation as a voice note, as the caller: OGG/Opus with the push-to-talk flag on WhatsApp, AAC by public link on Instagram. The clip's transcript is stored on the message row. Everything POST /conversations/\{id\}/voice checks applies — the reply rail, the política de envíos, the Instagram/Messenger 7-day HUMAN_AGENT window and the provider's own refusal.
Refused with 409 CONFLICT and details.code: VOICE_CLIP_EXPIRED (the clip is past expires_at; nothing is sent) or VOICE_NOTE_UNSUPPORTED (the channel cannot carry audio — email, web chat, Messenger, Marketplace; details.text holds the clip's words, or null, so the caller can offer them as a text reply; nothing is sent). Another workspace's clip is 404.
Needs macros:read (the library) as well as conversations:write and messages:send.
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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
clipId | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
acknowledge | string[] | — |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/voice-clips/<id>/send \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{}'Example response (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,
"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"
}
}Answers: 201 · 202 · 400 · 401 · 403 · 404 · 409 · 422 · 429
POST /conversations/{id}/voice-note
Send a typed line as a voice note in the agent’s voice
The teammate types; Vitrina speaks it in the AI agent’s configured voice (behavior_policies.voice_note, published setting) so the conversation keeps one voice whoever answers. Same pipeline as the agent’s own notes: numbers, prices and times are spoken as words, the Whisper check must hear every number and model, and the characters count against the workspace’s daily cap (GET /ai-agents/\{id\}/voice-note/usage). Sent as a WhatsApp voice note (OGG/Opus) or an Instagram audio (M4A), stored as an audio message authored by the caller, with the typed text as its transcript (metadata.attachment_context; no speech-to-text).
Whose voice: ai_agent_id, else the agent that answers this conversation (origin agent, then the channel’s agent, then the workspace default).
A refusal is 200 with ok: false and nothing is sent: code is one of not_configured (voice notes off or no voice chosen), invalid_text (reason: empty | too_long | phone_or_rut), cap_reached, check_failed (with check; characters were spent), provider_error, channel_unsupported (only WhatsApp and Instagram carry voice notes), agent_not_found, transcode_failed, too_large, no_public_url. Send the text instead. A channel rejection is the provider error, as on POST /conversations/\{id\}/voice.
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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
text | string | yes | máx. 4000 |
ai_agent_id | uuid | — | |
acknowledge | string[] | — |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/<id>/voice-note \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "Te lo dejo en $830.000, lo retiras hoy hasta las 19:00 hrs."
}'Example response (200)
{
"data": {
"ok": false,
"code": "not_configured",
"message": "Voice notes are off for this agent.",
"ai_agent_id": "6f1c2a9e-3b7d-4c55-9a51-2d8e0b7c4f10",
"usage": {
"day": "2026-10-28",
"characters_today": 412,
"daily_cap": 10000,
"remaining": 9588
}
}
}Answers: 200 · 201 · 202 · 400 · 401 · 403 · 404 · 409 · 422 · 429
GET /conversations/{id}/whatsapp-continuations
List a conversation's Continuaciones por WhatsApp
The Continuaciones started from this conversation, newest first (one per phone number). Read state to draw the inbox button: pending, deferred (waiting for an approved template, until send_deadline_at), sent, failed or skipped (reason says why). Empty when none was started. meta.whatsapp_line is true when the workspace has an enabled WhatsApp line a Continuación could use (the same lookup the send runs), so a caller without messaging_accounts:read can still decide whether to offer the button. A conversation the caller cannot see is a 404.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl https://api.vitrinadev.com/api/v1/conversations/<id>/whatsapp-continuations \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": [
{
"id": "0c4f7a92-1e6b-4d38-8a5f-63b2e9d1c470",
"state": "deferred",
"status": "pending",
"reason": "template_not_approved",
"phone": "+56987654321",
"trigger": "user",
"source_conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"continuation_conversation_id": "5a8e2c41-7b3d-4f19-9c06-2d7e4b1a8f53",
"captured_at": "2026-10-15T14:02:11.000Z",
"send_deadline_at": "2026-10-16T14:02:11.000Z",
"sent_at": null,
"created_at": "2026-10-15T14:02:11.000Z"
}
],
"meta": {
"total": 1,
"whatsapp_line": true
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /conversations/counts
Inbox badge counts (my open / unread / mentions / unassigned / waiting)
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.
«Esperando respuesta» (the waiting queue): waiting is how many conversations have a customer waiting on a human, team-wide (the list GET /conversations?waiting=true returns exactly these); waitingUrgent how many of them are flagged urgent; waitingMine how many are assigned to the caller (0 for an API key); waitingOldestSince when the longest wait started (ISO, null when nobody waits) — render it as «el más antiguo 2 h»; waitingClosed how many live threads were left out because the customer closed the chat, nothing is owed, they paid or are in the store, or a follow-up we promised is not due yet (the list waiting=closed); waitingExcluded how many waiting conversations a workspace view with «Sacar de Esperando respuesta» (exclude_from_waiting) took out of waiting — they are not in the queue, the banner or the urgent / mine / oldest figures, and they wait in their view (an urgent handoff_urgent thread is never taken out) (GET /inbox-views gives each view its waiting_count).
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"Example response (200)
{
"data": {
"myOpen": 3,
"myUnread": 1,
"unassigned": 2,
"myMentions": 0,
"waiting": 3,
"waitingUrgent": 1,
"waitingMine": 1,
"waitingOldestSince": "2026-10-31T13:05:00.000Z",
"waitingClosed": 4,
"waitingExcluded": 0
}
}Answers: 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.
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
contact_id | uuid | — | |
to | string | — | email, máx. 255 |
subject | string | yes | mín. 1, máx. 255 |
content | string | yes | mín. 1, máx. 8000 |
cc | string[] | — | |
html | string | — | máx. 200000 |
signature_id | string | null | — | |
from_account_id | uuid | — |
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]"
]
}'Example response (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,
"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]"
}
}
}Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429
POST /conversations/whatsapp
A person opens a WhatsApp thread — «Escribir» → WhatsApp
Opens the WhatsApp conversation of a Contact that carries a phone, without sending anything: 201 when a new thread was created, 200 when an active one already existed. The thread is keyed on the phone number so the provider can deliver, and is born under HUMAN control, assigned to the caller. The composer then applies the 24-hour window and template rules. 422 when the contact has no phone or the workspace has no WhatsApp channel connected.
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
contact_id | uuid | yes |
curl -X POST https://api.vitrinadev.com/api/v1/conversations/whatsapp \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"contact_id": "22222222-0000-4000-8000-000000000001"
}'Example response (201)
{
"data": {
"conversation": {
"id": "bbbbbbbb-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"display_seq": 132,
"display_id": "C-132",
"channel": "whatsapp",
"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"
},
"created": true
}
}Answers: 200 · 201 · 400 · 401 · 403 · 404 · 409 · 429
Contacts
The people a workspace talks to: search, create, correct, merge, tag, and the channels and attributes hanging off each one.
The workspace's email voice
What an assistant should read before writing an email on the workspace's behalf: language, style guide, the AI agent's persona and the team's recent real emails.