Reply and resolve tickets
The thread with a customer, the case that outlives it, and who answers.
If the question is "what did we tell this person?", the answer is in a conversation. If it's "where has this case got to?", it's in a ticket.
A conversation is one thread with one person on one channel, whether that's a WhatsApp chat, an email, a site chat or a call. The same person writing by WhatsApp and by email is two conversations.
A ticket is the case. It can group several conversations, it lives on a column of a ticket board, and it has an owner and an SLA. It survives the customer switching channels. Not every conversation needs one: a question answered in two messages never opens a ticket.
Reading needs conversations:read; writing, conversations:write. Tickets have their own: tickets:read / tickets:write, plus tickets:claim for the one operation where the caller keeps the case. Sending a message also needs messages:send and goes through the outbound policy.
The inbox
curl "https://api.vitrinadev.com/api/v1/conversations?limit=20&include=counts" \
-H "Authorization: Bearer $VITRINA_KEY"Each row is the conversation with four joins the inbox draws from: contact, ticket, lastMessage and tags.
{
"id": "bbbbbbbb-0000-4000-8000-000000000001",
"display_id": "C-132",
"channel": "web",
"status": "open",
"handler": "human",
"assignee_user_id": "11111111-0000-4000-8000-000000000001",
"contact_id": "22222222-0000-4000-8000-000000000001",
"ticket_id": "dddddddd-0000-4000-8000-000000000001",
"last_message_date": "2026-09-22T11:08:10.882Z"
}The three axes
The filters on this list are easy to confuse.
convStatus filters the conversation's own lifecycle: open, pending, snoozed, resolved, closed.
status filters the lifecycle of the ticket it hangs off. A conversation with no ticket is invisible to this filter.
handler filters who is answering: bot, human or external. It is independent of the other two: a conversation can be open and answered by the AI, or snoozed and assigned to a person.
The rest are direct: channel, assigneeUserId, teamId, search, fromDate / toDate, unread_only, mentioned_to_me, unassigned.
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.
?include=counts adds a counts sibling of data with the same four numbers GET /conversations/counts returns. A client refreshes its badges and its list in one round trip.
{ "data": { "myOpen": 3, "myUnread": 1, "unassigned": 2, "myMentions": 0 } }They're scoped to the caller: myOpen, myUnread and myMentions belong to the person behind the credential. An API key reads 0 for all three, because there's no person behind it. For somebody's badges, use a personal token.
The identifier
id is the uuid and it's the identity: it's what any reference stores. display_id (C-132) is the label people quote. A path accepts it as a shortcut (GET /conversations/C-132 works), but it never appears inside another resource or an event. Same rule for the ticket (T-6).
Reading the thread
GET /conversations/{id} returns the conversation with the whole transcript embedded, unbounded. If you re-read a conversation on a clock, pass ?exclude=messages and fetch the messages separately. A two-year-old thread returns two years of messages every time.
curl "https://api.vitrinadev.com/api/v1/conversations/bbbbbbbb-0000-4000-8000-000000000001/messages?limit=50" \
-H "Authorization: Bearer $VITRINA_KEY"Every message carries its author:
{
"id": "eeeeeeee-0000-4000-8000-000000000002",
"conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"content": "Hola Rodrigo, te confirmo la visita del jueves a las 10:00.",
"type": "text",
"sender_type": "api_key",
"delivery_status": "sent",
"author": {
"kind": "api_key",
"id": "c1c1c1c1-0000-4000-8000-000000000001",
"name": "CRM propio"
}
}The author rule is the same everywhere in Vitrina. Someone acting through a connected app or a personal token is still the author. The credential is recorded beside them, in via: "Camila vía Claude", never "Claude". An API key signs as itself, under the name it had at that moment. Renaming or revoking it doesn't rewrite history.
A sender_type of system is not a message anybody sent: those are the thread's own activity lines ("Camila reasignó la conversación").
Delivery
Outbound messages carry four fields that are read together:
| Field | What it says |
|---|---|
delivery_status | sent → delivered → read, or failed, or retrying |
next_attempt_at | When the next attempt fires (only while retrying) |
delivery_attempt | Attempts made; 1 is the original send |
delivery_error | The provider's reason, once it is failed |
retrying is not a failure. The provider refused the send and another attempt is scheduled. It only turns failed once the retry ladder runs out: 1 min, 5 min, 15 min, 1 h, 3 h. All four are null/0 on inbound messages.
Replying
curl -X POST https://api.vitrinadev.com/api/v1/conversations/bbbbbbbb-0000-4000-8000-000000000001/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."}'It needs messages:send in addition to conversations:write, and it goes through the outbound policy. Send messages gives it a whole chapter. In two lines: a block answers 422 OUTBOUND_BLOCKED and cannot be overridden. A warning answers 409 OUTBOUND_WARNING, and you resend the same request with acknowledge naming every code.
The other ways to put something in front of the customer ask for the same and answer the same:
| Operation | What it is for |
|---|---|
POST /conversations/{id}/templates | An approved WhatsApp template: the only thing that lands once the 24-hour window has closed |
POST /conversations/{id}/flows | An interactive Meta form. It does not reopen the window |
POST /conversations/{id}/location | A pin: a branch, a Maps link or coordinates |
POST /conversations/{id}/attachments | A file (multipart/form-data) |
POST /conversations/{id}/voice | A voice note (multipart/form-data) |
On the two multipart/form-data routes, acknowledge goes as a comma-separated list, never as a JSON array.
Scheduling a send
On email conversations, send_at holds the reply until that time (at least a minute ahead, at most 30 days). The answer is not a message:
{ "scheduled": true, "id": "15151515-0000-4000-8000-000000000001", "send_at": "2026-09-23T13:00:00+00:00", "status": "scheduled" }The outbound policy judges it twice: when you schedule it, and again when it fires. Between Monday and Thursday the contact may have asked to stop receiving messages, or landed under a hold. If there's a block at fire time the message doesn't go out. The row ends failed with the reason, and the refusal is recorded in the audit log.
The warnings you acknowledged when you scheduled it aren't asked again. They're recorded on the outbound ledger.
GET /conversations/{id}/scheduled-messages lists the pending ones; DELETE /conversations/{id}/scheduled-messages/{scheduledId} cancels one. Cancelling has no undo: rescheduling means composing it again.
Who is answering
Two axes again, and all four operations move them together:
curl -X POST https://api.vitrinadev.com/api/v1/conversations/bbbbbbbb-0000-4000-8000-000000000001/assign \
-H "Authorization: Bearer $VITRINA_KEY" \
-H 'Content-Type: application/json' \
-d '{"assignee_user_id":"11111111-0000-4000-8000-000000000001","handler":"human"}'assign leaves the thread with the person you name. assignee_user_id: null hands it to the human queue unowned; omitting the key leaves the current owner alone.
claim gives it to the caller, so it needs a person behind the credential: a session or a personal token. An sk_* key gets 403, because there's nobody to assign it to; that's what assign with an id is for. Claiming a thread somebody already holds isn't an error. The response says who holds it, so two people pressing at once get the same answer rather than a race.
handoff passes it to people and lets the assignment rules decide who, which is what distinguishes it from assign.
return-to-ai gives it back to the AI: handler returns to bot and the assignee is cleared.
The lifecycle
| Operation | What it means |
|---|---|
POST /conversations/{id}/snooze | "Come back at this time." Exactly one of until (ISO 8601, in the future) or minutes |
POST /conversations/{id}/pending | "Waiting on them", with no clock: it returns when the person writes |
POST /conversations/{id}/resolve | "Dealt with." A new message reopens it |
POST /conversations/{id}/close | Terminal. The auto-close arrives on its own a while after a resolve |
POST /conversations/{id}/reopen | Back to open, clearing the stamps. A closed thread does not reopen: closing is terminal, and the response hands it back as it is, closed |
Resolving and closing cascade to the ticket, but only when no other conversation on that ticket is still active. Resolving the WhatsApp thread of a case that's also running by email leaves the ticket open and fires no ticket.resolved. The case hasn't ended.
Tickets
You open a ticket on a conversation when the case is going to last:
curl -X POST https://api.vitrinadev.com/api/v1/conversations/bbbbbbbb-0000-4000-8000-000000000001/tickets \
-H "Authorization: Bearer $VITRINA_KEY" \
-H 'Content-Type: application/json' \
-d '{"reason":"El cliente pide reagendar su visita"}'{
"data": [
{
"id": "dddddddd-0000-4000-8000-000000000001",
"display_id": "T-6",
"status": "open",
"handler": "human",
"assignee_user_id": "11111111-0000-4000-8000-000000000001",
"contact_id": "22222222-0000-4000-8000-000000000001",
"origin_conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
"current_stage_id": "55555555-0000-4000-8000-000000000011",
"reason": "El cliente pide reagendar su visita",
"opened_by": "human",
"opened_via": "admin_ui",
"resolved_by": null,
"resolved_at": null
}
]
}It needs tickets:write. With neither stage_id nor pipeline_id it lands on the workspace's fallback ticket board. With pipeline_id it lands on that board's first column, which has to be a ticket board. A conversation that already has a ticket returns that same one and never opens a second.
One case, several channels
The customer asked by WhatsApp and then sent an email. Two conversations, one case:
curl -X POST https://api.vitrinadev.com/api/v1/conversations/bbbbbbbb-0000-4000-8000-000000000002/attach-to-ticket \
-H "Authorization: Bearer $VITRINA_KEY" \
-H 'Content-Type: application/json' \
-d '{"ticket_id":"dddddddd-0000-4000-8000-000000000001"}'After that, GET /tickets/{id}/conversations lists both. GET /tickets/{id}/messages interleaves them into one timeline, each message stamped with the channel it arrived on. The system lines for assignments and status changes ride along too. It's a view for reading; the delivery fields and the author of each message are on GET /conversations/{id}/messages.
detach-from-ticket undoes the link and leaves the ticket standing.
The board
GET /tickets/stage/{stageId} returns the cards in a column, paged: that is how a board is read, one call per column, with the columns coming from GET /pipelines/{id}.
PUT /tickets/{id}/stage moves the card. The move is refused with a 400 when the current column's allowed_transitions_to does not name the target, or when the target belongs to another board.
GET /tickets/status-counts and /today-counts give the numbers per state.
{
"data": {
"total": 5,
"open": 1,
"pending": 0,
"snoozed": 0,
"resolved": 0,
"closed": 4,
"closing": 0,
"human": 1,
"marketing": 0
}
}closing is not a state a ticket ever holds: it counts resolved tickets whose auto-close timer is running.
Two things that surprise people
PUT /tickets/{id}/close resolves, it does not close. It sets status to resolved, flips the handler to human and stamps resolved_by / resolved_at. The terminal state arrives with the auto-close a while later, or with PUT /tickets/{id}/status sending closed. It's also the operation that fires ticket.resolved. PUT /tickets/{id}/status with resolved sets the column without stamping the attribution, and doesn't emit the event.
Every ticket write answers a one-element array, { "data": [ticket] }, like the one above. The read (GET /tickets/{id}) answers the object.
Labelling and annotating
Tags are the loose vocabulary: anyone with tags:write creates one, and attaching by name creates it on first use. The name is slugified, so "Garantía", "garantia" and "Garantía " are the same tag.
curl -X POST https://api.vitrinadev.com/api/v1/conversations/bbbbbbbb-0000-4000-8000-000000000001/tags \
-H "Authorization: Bearer $VITRINA_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"Garantía"}'They take tags:read / tags:write, never the conversation scopes. A credential that reads the thread but not the taxonomy gets 403 here and keeps reading the conversation. Detaching a tag removes the link, never the tag.
Custom attributes hold the data your operation needs and Vitrina does not ship. The definition first, once:
curl -X POST https://api.vitrinadev.com/api/v1/custom-attributes \
-H "Authorization: Bearer $VITRINA_KEY" \
-H 'Content-Type: application/json' \
-d '{"entity_type":"conversation","key":"numero_de_caso","label":"Número de caso","data_type":"text"}'Then the values, with PUT /conversations/{id}/attributes, which upserts rather than replaces: the keys you send are written and everything else is left alone. Deleting a value is DELETE /conversations/{id}/attributes/{key}. Sending null stores a null, which is a different thing.
A definition's key and entity_type can't be edited. Deleting a definition does not delete the values unless you pass ?purge_values=true.
Notes (POST /conversations/{id}/notes) are internal and never reach the customer. @mentions in the body notify the people you name; editing a note does not re-read them.
The events
Five, on top of the four ticket events:
| Event | When |
|---|---|
conversation.created | A thread was opened: the customer wrote in, or the workspace opened it |
conversation.assigned | It changed hands: assignee, handler, or both |
conversation.resolved | It stopped being open work |
message.received | The customer sent a message |
message.sent | The workspace sent them one |
A historical import fires nothing, so syncing months of past conversations generates no events.
conversation.assigned is one event for two changes, because one never happens without the other. Handing a thread to a person also moves the handling to human, and giving it back to the AI clears the assignee. The envelope carries them together:
{
"type": "conversation.assigned",
"version": 1,
"resource": {
"type": "conversation",
"id": "bbbbbbbb-0000-4000-8000-000000000001",
"url": "https://api.vitrinadev.com/api/v1/conversations/bbbbbbbb-0000-4000-8000-000000000001"
},
"changes": {
"assignee": { "from": null, "to": "11111111-0000-4000-8000-000000000001" },
"handler": { "from": "bot", "to": "human" }
},
"author": {
"kind": "member",
"id": "11111111-0000-4000-8000-000000000001",
"name": "Camila Rojas",
"via": { "kind": "connected_app", "name": "Claude" }
},
"data_omitted": "not_requested"
}changes is keyed by what changed (assignee, handler, status) and never by the column that holds it.
conversation.resolved follows the same rule in the other direction: it fires on a resolve, and on a close only when the thread was not already resolved. The auto-close of something already resolved fires nothing: that's the grace window elapsing.
message.sent fires when the message is persisted. On most channels that's the moment Vitrina commits to the send, rather than the moment the provider confirms it. delivery_status carries what was known then. A later failure or delivery receipt shows on the message row, never as a second event.
Like every event, these travel as the notice and without the data unless the subscription asks for it and its owner may read it. The webhooks chapter explains the envelope and the signature.
What is not published
The inbox's composed panel, the typing indicator and AI-agent control over a thread aren't part of the public API. Every section of the panel has its own published endpoint.
Ticket labels (/labels) aren't published yet either.