API Docs

Conversations, tickets, and messages

AdminUpdated Sep 19, 2026

Conversations, tickets, and messages

The thread model — how conversations differ from tickets, when each is the right tool, and how messages compose into both.

Three nested objects make up the core of Chatly's chat surface: conversation, ticket, and message. Knowing which one to reach for — and when to use both together — is one of the bigger productivity wins for support teams migrating from a less structured tool.

Info — Conversation vs ticket, in one sentence

A conversation is a live thread you have. A ticket is a piece of work you track. A conversation may produce zero, one, or many tickets — and a ticket may bundle several conversations on the same underlying issue.

Message

A message is a single utterance inside a conversation.

Message kinds (the message_kind enum — these five, exactly)

Name

Type

Description

text

visible

Normal user-visible message. The default.

rich

visible

Structured payload — buttons, cards, provider templates. Rendering depends on what the channel adapter declares in its capabilities.

note

agent-only

Internal note in the conversation, seen only by agents (gated on messages:internal_note).

system_event

metadata

"Assigned to Mia", "Closed by automation". Auto-generated by the platform.

attachment_only

visible

Attachments with no accompanying body text.

Warning — There is no separate attachment, card, template or voice_segment kind

Attachments ride on the attachments array of any message — attachment_only is the case where there is no body text with them. Rich and templated payloads are both rich. Voice call markers are system_event. Sending an unrecognised kind is a 400.

Every message has:

  • id, conversationId, createdAt — bare UUIDs, unprefixed

  • authorTypeagent | contact | bot | system

  • authorId — when applicable

  • body (string, max 10,000 chars), attachments (array, max 10)

  • kind (above)

  • metadata — channel-specific data (Twilio SID, WhatsApp message ID, etc.)

  • deliveredAt, readAt, editedAt, replyToMessageId

Info — No AI-extracted entities field

Messages carry no entities column. AI-derived facts about a person live on contact memories instead (GET /v1/contacts/{id}/memories), not on the message row.

Conversation

A conversation is the ongoing thread between a contact and your team on a particular channel.

Conversation fields

Name

Type

Description

id

uuid

A bare UUID. No conv_ prefix — nothing in the API is prefixed.

contactId

fk

Who's talking. Required.

channelType

enum

Which kind of channel it arrived on. Required.

channelId

fk?

The specific channel row, when one still exists.

brandId

fk

Required, not optional — every workspace gets a default brand at creation, so every conversation has one.

assigneeUserId

fk?

Which agent owns it.

teamId

fk?

Which team owns it.

status

enum

open | pending | snoozed | closed | merged. Defaults to open.

priority

enum

low | normal | high | urgent. Defaults to normal.

subject

string?

Thread subject, where the channel has one (email).

tags

string[]

Free-form tags from the workspace taxonomy.

lastMessageAt, lastMessagePreview

mixed

Denormalised for inbox sorting and the list row.

unreadAgentCount, unreadContactCount

integer

Per-side unread counters.

firstResponseAt

timestamp?

When a human agent first replied visibly. A bot reply and an internal note both leave it NULL — that's what makes it usable as the unattended predicate.

closedAt

timestamp?

When it was closed. There is no separate resolvedAt.

snoozeUntil

timestamp?

If status='snoozed', when to resurface.

Warning — No custom attributes on conversations

Conversations have no attributes / custom-fields column. Custom workspace-defined fields exist on contacts (attributes) and on tickets (customFields), not on the conversation row. Use tags, or hang the data off the contact.

An AI summary is available as autoSummary (with autoSummaryGeneratedAt), but it is not part of the conversation DTO returned by the list and detail endpoints.

Lifecycle

        ┌─────────┐
        │  open   │ ◄──────────┐   (the default on creation)
        └──┬───┬──┘            │
           │   │ snooze        │ resurface (snoozeUntil passes,
           │   ▼               │            or the customer replies)
           │  ┌──────────┐     │
           │  │ snoozed  │ ────┘
           │  └──────────┘
           │
           │ waiting on the customer
           ▼
        ┌─────────┐
        │ pending │ ──── customer replies ──► open
        └────┬────┘
             │ close
             ▼
        ┌─────────┐
        │ closed  │ ──── reopen (customer replies) ──► open
        └─────────┘

        ┌─────────┐
        │ merged  │  terminal — set by POST /v1/conversations/{winner}/merge/{loser}
        └─────────┘  on the LOSING thread; mergedIntoId points at the survivor

Warning — There is no new status and no archived status

A conversation is created directly as open. And archiving is a label on a bulk action, not a state: the inbox's "Archive" button resolves to status = 'closed', because conversation_status has no archived value. Nothing auto-archives on an idle timer either — the only status timer in the platform is the snooze waker.

Transitions are recorded in the audit log.

Ticket

A ticket is a wrapper around one or more conversations for long- lived work.

Tip — Use a ticket when the work outlives the chat

A refund request resolved within 30 minutes? Just a conversation. An engineering bug report that takes engineering 2 weeks to fix? That's a ticket — keeps the work visible to the team even when the customer has moved on with their day.

Ticket fields

Name

Type

Description

number

integer

Human-friendly increment. #1247.

title, description

string

Short label + longer body.

status

enum

new | open | pending | on_hold | resolved | closed

priority

enum

low | normal | high | urgent

type

string

Ticket type; defaults to question.

assigneeUserId, teamId, queueId

fk?

Who owns the work.

parentTicketId

fk?

For sub-tickets.

dueAt

timestamp?

Hard deadline for SLA reporting.

slaPolicyId

fk?

Which SLA policy governs; drives firstResponseDueAt / resolutionDueAt / slaBreachedAt.

tags

string[]

Free-form tags.

customFields

object

Workspace-defined custom fields. This is where per-object custom data lives — conversations have no equivalent.

Conversations are linked to a ticket through a join table, not through a field on the ticket — there is no linkedConversationIds array to write:

POST /v1/tickets/{id}/conversations/{conversationId} — Bearer token

GET /v1/tickets/{id}/conversations — Bearer token

DELETE /v1/tickets/{id}/conversations/{conversationId} — Bearer token

Warning — No externalRefs field on tickets

There is no column for a Linear issue ID, Jira key, or GitHub issue URL on the ticket row, and no two-way sync driven from one. Track the outbound link in customFields if you need it.

Tickets are visible only to agents, never to customers. Customers hear about a ticket only as text in a conversation reply ("We've filed ticket #1247 about this — we'll update you when it's resolved").

Conversation vs ticket — pick guide

Common scenarios

Name

Type

Description

"Where is my order?"

Conversation only

Reply, close. No ticket.

Recurring billing bug affecting 12 customers

One ticket, many conversations

File one ticket, link each conversation to it; mass-update when resolved.

Customer asks for a feature

Conversation + ticket

Reply, file a feature-request ticket linked to your roadmap tool. Close conversation; ticket stays open in eng tracker.

Customer chats casually before buying

Conversation only

Sales context. No ticket.

Refund pending finance approval

Conversation + ticket

Reply with status; ticket tracks the approval workflow.

Long-term account management thread

Conversation with sidebar attributes

No ticket — use conversation custom attributes to track ongoing state.

Routing on creation

When a conversation is created, routing rules fire. The actions available to them are a fixed set of primitives:

  • Assign to a specific agent, or to a team (which then applies the inbox's strategy — round_robin, load_balanced, skills_based, sticky, or manual)

  • Transfer / unassign

  • Set status (open | pending | snoozed | closed), close, or snooze

  • Tag / untag

  • Set priority

  • Add an internal note ("VIP — see CRM for context")

Warning — There is no set-a-custom-attribute action

Conversations have no attributes column, so no routing or workflow action can write one. If you need per-thread state beyond tags and priority, put it on the contact's attributes or on a linked ticket's customFields.

Each of these is permission-gated at the primitive (assignment needs conversations:assign, closing needs conversations:close, and so on), so a workflow cannot do on your behalf what you could not do yourself. Routing decisions write to the audit log.

Multi-channel threading

A contact can have multiple open conversations on different channels simultaneously — web chat + WhatsApp + email — all in the dashboard's unified inbox view. Agents see them side-by-side and can reply on whichever channel makes sense.

SLAs

Per workspace you can define SLA targets:

sla_policies — every target is in MINUTES, not seconds

Name

Type

Description

name

string

Policy label. Snapshotted onto each conversation's SLA row so a deleted policy still names itself in a breach report.

firstResponseMinutes

integer

How quickly must a human agent first respond. Required.

nextResponseMinutes

integer?

Target for every subsequent agent reply. Without it, "answered once inside the window then went quiet for two days" satisfies the SLA.

resolveMinutes

integer

How quickly must the conversation be closed. Required.

businessHoursOnly

boolean

When true the clock only runs during the workspace's configured business hours. Defaults to false.

priorityOverrides

object

Per-priority overrides of firstResponseMinutes / resolveMinutes.

Each conversation gets a conversation_slas row carrying the resolved firstResponseDueAt / resolutionDueAt and the …WarnedAt / …BreachedAt stamps the SLA watchdog writes.

CSAT + surveys

After a conversation is closed, you can fire a survey to ask CSAT. Survey responses attach to the conversation and roll up into the reports dashboard.

API

GET /v1/conversations?status=open&limit=50 — Bearer token

GET /v1/conversations/{id} — Bearer token

GET /v1/conversations/{id}/messages — Bearer token

POST /v1/conversations/{id}/messages — Bearer token

POST /v1/conversations/{id}/close — Bearer token

POST /v1/conversations/{id}/assign — Bearer token

POST /v1/conversations/{id}/escalate — Bearer token

POST /v1/conversations/{id}/internal-notes — Bearer token

POST /v1/conversations/bulk — Bearer token

GET /v1/conversations/unattended — Bearer token

Danger — You cannot create a conversation over the REST API

There is no POST /v1/conversations. Conversations come into existence when a message arrives — through a channel adapter's inbound webhook, or through POST /v1/widget/conversations on the visitor surface. There is likewise no POST /v1/conversations/{id}/snooze: snooze a thread through POST /v1/conversations/bulk with action: "snooze".

Filtering the list

Filters are flat query parameters, not bracketed ones — ?status=open, not ?filter[status]=open:

status, assignee, unread, mentioned, channel, tags, from, to, segment, priority, team, createdFrom, createdTo, unattended, unattendedFor, sla, brand, plus sort, cursor, limit.

Repeatable values are comma-separated (?tags=vip,refund). An unrecognised value narrows or is dropped rather than 400ing — these parameters arrive from saved views and shared URLs that outlive the vocabularies they name. The one exception is ?filter=, which carries the whole filter as a JSON group tree when it needs OR or nesting, and supersedes the flat parameters when present.

Sending a message

POST /v1/conversations/{id}/messages takes body (string, ≤10,000 chars), kind (defaults to text), attachments (≤10), and metadata. conversationId in the body is optional and ignored — the path wins.

Warning — close and assign are not API-key accessible

An API key can read conversations and post messages, but /close and /assign require a signed-in user and reject a ck_… key with a 403. See Authentication.

See REST API for the full surface and the Node SDK for typed clients.

Was this page helpful?
Conversations, tickets, and messages