VitrinaAPI

Conversations

The threads with customers and patients: read the inbox, reply, assign, snooze and resolve — with the author of every message.

Beta
This may change at any time, with a changelog entry and a notice to recent callers — see versioning.

Download the full API pública projection: openapi.json.

Reply and resolve tickets explains this resource in prose, with runnable examples.

MethodPathWhat it does
GET/conversationsList conversations
POST/conversationsOpen a conversation from the platform side
GET/conversations/{id}Get conversation
GET/conversations/{id}/agent-turnInspect the exact state the AI agent would receive
PATCH/conversations/{id}/ai-controlKeep a conversation with people across AI availability windows
GET/conversations/{id}/ai-statusWhy the AI is silent on this conversation
POST/conversations/{id}/assignAssign a conversation to a teammate
POST/conversations/{id}/attach-to-ticketAttach this conversation to an existing ticket
POST/conversations/{id}/attachmentsSend a file to the customer (multipart/form-data)
GET/conversations/{id}/attributesList the attribute values on a conversation
PUT/conversations/{id}/attributesUpsert attribute values on a conversation
DELETE/conversations/{id}/attributes/{key}Delete one attribute value from a conversation
POST/conversations/{id}/bot-gate-overrideLet the AI answer this conversation despite the no-auto-reply gate
GET/conversations/{id}/callsList a conversation's call segments
GET/conversations/{id}/calls/{call_id}/recording-urlMint a short-lived playback URL for a call recording
POST/conversations/{id}/claimClaim a conversation for the caller
POST/conversations/{id}/closeClose a conversation (terminal)
POST/conversations/{id}/continue-on-whatsappContinue a conversation on WhatsApp
POST/conversations/{id}/detach-from-ticketDetach this conversation from its ticket
GET/conversations/{id}/exportExport the whole conversation (json | markdown)
POST/conversations/{id}/filter-senderDenylist the sender and quarantine this conversation
POST/conversations/{id}/flow-mediaMint a media_id for a Flow header (multipart/form-data)
POST/conversations/{id}/flowsSend a published WhatsApp Flow
POST/conversations/{id}/handoffHand the conversation off to a human team
GET/conversations/{id}/linked-conversationsList a conversation's linked conversations
POST/conversations/{id}/linked-conversationsLink a conversation to its continuation on another channel
GET/conversations/{id}/linked-recordsThe ticket and open leads this conversation's work lives on
POST/conversations/{id}/locationSend a location pin to the customer
POST/conversations/{id}/media-assets/{assetId}/sendSend a library file to the customer
POST/conversations/{id}/media/signSign conversation media urls for a short-lived read
GET/conversations/{id}/messagesPaginated message playback
POST/conversations/{id}/messagesSend a message to the customer
POST/conversations/{id}/messages/{messageId}/transcribeTranscribe an inbound audio message
DELETE/conversations/{id}/no-reply-neededUndo «No requiere respuesta»
POST/conversations/{id}/no-reply-neededMark the current wait as needing no reply («No requiere respuesta»)
GET/conversations/{id}/notesList internal notes on a conversation
POST/conversations/{id}/notesAdd an internal note
DELETE/conversations/{id}/notes/{noteId}Delete an internal note
PATCH/conversations/{id}/notes/{noteId}Edit an internal note
GET/conversations/{id}/participantsTeammates who have been on this conversation
POST/conversations/{id}/pendingMark a conversation pending (waiting, with no timer)
POST/conversations/{id}/reopenReopen a resolved, snoozed or closed conversation
POST/conversations/{id}/resolveResolve a conversation
POST/conversations/{id}/return-to-aiReturn handling to the AI
GET/conversations/{id}/scheduled-messagesList pending scheduled sends
DELETE/conversations/{id}/scheduled-messages/{scheduledId}Cancel a scheduled send
POST/conversations/{id}/snoozeSnooze a conversation until a time or for N minutes
GET/conversations/{id}/tagsList the tags on a conversation
POST/conversations/{id}/tagsAttach a tag to a conversation
DELETE/conversations/{id}/tags/{tag_id}Detach a tag from a conversation
POST/conversations/{id}/templatesSend an approved WhatsApp template
POST/conversations/{id}/ticketsOpen a ticket on a conversation
POST/conversations/{id}/transcribeTranscribe a recording for the composer (multipart/form-data)
POST/conversations/{id}/unfilterRestore a quarantined conversation to the inbox
POST/conversations/{id}/voiceSend a voice note (multipart/form-data)
POST/conversations/{id}/voice-clips/{clipId}/sendSend a library voice clip to the customer
POST/conversations/{id}/voice-noteSend a typed line as a voice note in the agent’s voice
GET/conversations/{id}/whatsapp-continuationsList a conversation's Continuaciones por WhatsApp
GET/conversations/countsInbox badge counts (my open / unread / mentions / unassigned / waiting)
POST/conversations/emailA person starts an email — «Escribir por correo» / «Nuevo correo»
POST/conversations/whatsappA 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.

ParameterInTypeRequiredConstraints
pagequeryintegerno≥ 1, por defecto 1
limitqueryintegerno≥ 1, ≤ 100, por defecto 20
channelquerystringno
messagingAccountIdqueryuuidno
sourcequeryconversation \marketplace \manual \
statusqueryopen \pending \snoozed \
convStatusqueryopen \pending \snoozed \
handlerquerybot \human \external
assigneeUserIdqueryuuidno
teamIdqueryuuidno
searchquerystringno
ticketIdquerystringno
currentStageIdquerystringnomín. 1
pipelineIdquerystringnomín. 1
fromDatequerystringno
toDatequerystringno
unread_onlyqueryboolean | nullno
mentioned_to_mequeryboolean | nullno
unassignedqueryboolean | nullno
activequeryboolean | nullno
filteredqueryboolean | nullno
aiRepliedqueryboolean | nullno
fromAdqueryboolean | nullno
fromCampaignqueryboolean | nullno
waitingqueryanyno
attributequeryarray | nullno
tagIdsqueryarray | nullno
viewIdqueryuuidno
sortquerycreated_at \updated_at \last_message_date \
orderqueryasc \descno
includequerycountsno
fieldsquerysummary \fullno
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:&lt;uuid&gt; 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

FieldTypeRequiredConstraints
channelstring—mín. 1, máx. 50, por defecto "web"
brandstring | null—máx. 120
messaging_account_idstring | null—
contactobject—
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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1
excludequerymessagesno
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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
keep_with_humanbooleanyes
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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
assignee_user_idstring | null—
handlerbot \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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
ticket_idstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
filenamestringyesmín. 1, máx. 255
contentstringyesmín. 1, máx. 35000000
content_encodingutf8 \base64—
content_typestring—máx. 200
captionstring—
acknowledgestring—
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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
attributesobject[]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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1
keypathstringyesmí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).

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
enabledbooleanyes
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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1
limitqueryintegerno≥ 1, ≤ 500
offsetqueryinteger | nullno≥ 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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1
call_idpathuuidyes
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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
phonestring | 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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1
formatqueryjson \markdownno
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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
flow_idstringyesmín. 1, máx. 64
bodystringyesmín. 1, máx. 1024
ctastringyesmín. 1, máx. 30
headerany—
footerstring—mín. 1, máx. 60
contextobject—
acknowledgestring[]—
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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
reasonstring—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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
continuation_conversation_iduuidyes
context_cardobject—
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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
location_iduuid—
maps_urlstring—mín. 1, máx. 2048
latitudenumber—≥ -90, ≤ 90
longitudenumber—≥ -180, ≤ 180
namestring—mín. 1, máx. 1024
addressstring—mín. 1, máx. 1024
acknowledgestring[]—
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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1
assetIdpathuuidyes

Body

FieldTypeRequiredConstraints
captionstring | null—máx. 1024
acknowledgestring[]—
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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
urlsstring[]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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1
limitqueryintegerno≥ 1, ≤ 200, por defecto 50
orderqueryasc \descno
cursorquerystringno
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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
contentstringyesmín. 1, máx. 8000
typetext—por defecto "text"
ccstring[]—
bccstring[]—
signature_idstring | null—
htmlstring—máx. 200000
send_atstring—date-time
acknowledgestring[]—
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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1
messageIdpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1
subject_idqueryuuidno
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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
subject_iduuidyes
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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
bodystringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1
noteIdpathuuidyes
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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1
noteIdpathuuidyes

Body

FieldTypeRequiredConstraints
bodystringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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".

ParameterInTypeRequiredConstraints
idpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1
scheduledIdpathuuidyes
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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
untilstring—date-time
minutesinteger—≥ 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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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\}.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1
tag_idpathuuidyes
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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
template_iduuidyes
paramsobject—
acknowledgestring[]—
keep_handlerboolean—
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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
pipeline_idstring | null—
stage_idstring | null—
reasonstring | null—máx. 500
brandstring | 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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1
clipIdpathuuidyes

Body

FieldTypeRequiredConstraints
acknowledgestring[]—
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.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
textstringyesmáx. 4000
ai_agent_iduuid—
acknowledgestring[]—
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.

ParameterInTypeRequiredConstraints
idpathstringyesmí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

FieldTypeRequiredConstraints
contact_iduuid—
tostring—email, máx. 255
subjectstringyesmín. 1, máx. 255
contentstringyesmín. 1, máx. 8000
ccstring[]—
htmlstring—máx. 200000
signature_idstring | null—
from_account_iduuid—
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

FieldTypeRequiredConstraints
contact_iduuidyes
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

On this page

GET /conversationsPOST /conversationsGET /conversations/{id}GET /conversations/{id}/agent-turnPATCH /conversations/{id}/ai-controlGET /conversations/{id}/ai-statusPOST /conversations/{id}/assignPOST /conversations/{id}/attach-to-ticketPOST /conversations/{id}/attachmentsGET /conversations/{id}/attributesPUT /conversations/{id}/attributesDELETE /conversations/{id}/attributes/{key}POST /conversations/{id}/bot-gate-overrideGET /conversations/{id}/callsGET /conversations/{id}/calls/{call_id}/recording-urlPOST /conversations/{id}/claimPOST /conversations/{id}/closePOST /conversations/{id}/continue-on-whatsappPOST /conversations/{id}/detach-from-ticketGET /conversations/{id}/exportPOST /conversations/{id}/filter-senderPOST /conversations/{id}/flow-mediaPOST /conversations/{id}/flowsPOST /conversations/{id}/handoffGET /conversations/{id}/linked-conversationsPOST /conversations/{id}/linked-conversationsGET /conversations/{id}/linked-recordsPOST /conversations/{id}/locationPOST /conversations/{id}/media-assets/{assetId}/sendPOST /conversations/{id}/media/signGET /conversations/{id}/messagesPOST /conversations/{id}/messagesPOST /conversations/{id}/messages/{messageId}/transcribeDELETE /conversations/{id}/no-reply-neededPOST /conversations/{id}/no-reply-neededGET /conversations/{id}/notesPOST /conversations/{id}/notesDELETE /conversations/{id}/notes/{noteId}PATCH /conversations/{id}/notes/{noteId}GET /conversations/{id}/participantsPOST /conversations/{id}/pendingPOST /conversations/{id}/reopenPOST /conversations/{id}/resolvePOST /conversations/{id}/return-to-aiGET /conversations/{id}/scheduled-messagesDELETE /conversations/{id}/scheduled-messages/{scheduledId}POST /conversations/{id}/snoozeGET /conversations/{id}/tagsPOST /conversations/{id}/tagsDELETE /conversations/{id}/tags/{tag_id}POST /conversations/{id}/templatesPOST /conversations/{id}/ticketsPOST /conversations/{id}/transcribePOST /conversations/{id}/unfilterPOST /conversations/{id}/voicePOST /conversations/{id}/voice-clips/{clipId}/sendPOST /conversations/{id}/voice-noteGET /conversations/{id}/whatsapp-continuationsGET /conversations/countsPOST /conversations/emailPOST /conversations/whatsapp