Managing the Inbox
Read state, close and reopen, assignment, blocking, the AI toggle, chat fields, labels and collections
Everything that changes an inbox row's state rather than its messages.
Scope: CONVERSATIONS_MANAGE for everything on this page.
Read State
readMessages(ids: [String!]!): Int!
readAllMessages(chatId: String!): Float!| Operation | Description |
|---|---|
readMessages | Marks specific messages read. Takes message IDs, returns how many changed. |
readAllMessages | Marks the whole thread read. Returns how many changed. |
mutation MarkRead {
readAllMessages(chatId: "6804f4c2a6f9f35f6e66f1a1")
}Close and Reopen
closeChat(chatId: String!): Chat!
reopenChat(chatId: String!): Chat!Both act on a thread and return the updated Chat. The conversation's own status is a rollup over its threads.
Assignment
assignChatToOperator(chatId: String!, operatorId: String!): Chat!
unassignChat(chatId: String!): Chat!
assignConversationThreads(conversationId: String!, operatorId: String): Int!| Operation | Description |
|---|---|
assignChatToOperator | Assigns one thread to an operator. |
unassignChat | Clears the assignment on one thread. |
assignConversationThreads | Assigns every thread of a conversation at once. Returns how many changed. Pass operatorId: null to unassign them all. |
operatorId is a user ID, not a member ID. Get it from orgTeamMembers - the userId field, which exists for exactly this purpose.
mutation Assign {
assignConversationThreads(
conversationId: "6804f4d5a6f9f35f6e66f1c4"
operatorId: "66120a4e1c34d0012ab87001"
)
}Blocking
blockConversation(conversationId: String!, blocked: Boolean!): Int!Blocks or unblocks the person behind the conversation, not a single thread. Returns how many threads were affected.
mutation Block {
blockConversation(conversationId: "6804f4d5a6f9f35f6e66f1c4", blocked: true)
}On the conversation, isBlocked reflects the person-level flag and hasBlockedThread is true when any thread is blocked.
AI Toggle
setConversationAi(conversationId: String!, enabled: Boolean!): Int!Turns the org's AI assistant on or off for this conversation. Returns how many threads changed.
Use it to hand a conversation from a bot to a human, or back:
mutation HandOff {
setConversationAi(conversationId: "6804f4d5a6f9f35f6e66f1c4", enabled: false)
}Chat Fields
updateChatField(input: UpdateChatFieldInput!): Boolean!Sets one field on a chat. Returns true when the write landed.
UpdateChatFieldInput
| Field | Type | Required | Description |
|---|---|---|---|
chatId | String! | Yes | The thread to update. |
field | ChatFieldKey! | Yes | Which field - see below. |
value | JSON | No | The new value. Its shape depends on the field. |
blockScope | BlockScope | No | Only meaningful with IS_BLOCKED: CONTACT blocks the person, THREAD blocks just this thread. |
ChatFieldKey values:
| Key | Sets |
|---|---|
CHAT_STATUS | The thread's status. |
PRIORITY | Priority. |
CHAT_CATEGORY | Category. |
IS_BLOCKED | Blocked state - pair it with blockScope. |
AI_AVAILABLE | Whether the AI assistant may answer here. |
VERIFIED | Verified mark. |
One field per call.
mutation Prioritise {
updateChatField(input: {
chatId: "6804f4c2a6f9f35f6e66f1a1"
field: PRIORITY
value: "HIGH"
})
}IS_BLOCKED and AI_AVAILABLE overlap with blockConversation and setConversationAi. The difference is the level: updateChatField acts on one thread (and blockScope lets you choose person or thread), while the conversation mutations act across every thread of the conversation at once.
Retrying a Failed Send
retryFailedMessage(messageId: String!): Message!MESSAGES_SEND
Retries one message that failed. Only a failed outbound message qualifies:
| Target | Result |
|---|---|
| A failed outbound message | Retried. |
| An inbound message, or one that did not fail | 400 |
| A malformed, missing or foreign id | The same not-found either way. |
Check the failure first. A message whose errorMessage starts with outcome unknown may already have reached the contact - retrying it risks a duplicate. See delivery status.
Flows in a Chat
apiStartFlowForChat(chatId: String!, flowId: String!): Chat!
apiStopFlowForChat(chatId: String!): Chat!CONVERSATIONS_MANAGE
| Rule | Detail |
|---|---|
| The flow must be published | A draft, archived or foreign flow is one not-found. |
| Not in a group chat | A key starting a flow in a group chat gets a 400. |
| Flows are not listable over the API | Take the flowId from the dashboard. |
Both return the chat with its activeFlow - flow id, title, execution id, initiator and start time. A key-started run is recorded with initiator WEBHOOK and platform api.
The same ownership-and-published check now applies to members too: starting another org's flow, or a draft, is a not-found in the dashboard as well.
Deleting
Both operations are irreversible and need the separate CONVERSATIONS_DELETE scope - CONVERSATIONS_MANAGE does not imply it. Both are expensive, and every attempt writes an audit line.
apiDeleteChat(chatId: String!): DeleteChatResult!
apiDeleteConversation(conversationId: String!, removePeople: Boolean!): PartnerDeleteConversationResult!apiDeleteChat
CONVERSATIONS_DELETE
Deletes one thread with its messages and media. The contact stays, and so do their other threads. Returns { messages, media } - what was removed.
apiDeleteConversation
CONVERSATIONS_DELETE; with removePeople: true also CONTACTS_ERASE
Deletes the conversation with all its threads, messages and media. Returns { chats, messages, media, contactsErased }.
removePeople is the part to read carefully:
removePeople | What happens |
|---|---|
false | The conversation and its content go. The contacts remain intact. |
true | A GDPR erase as well: the conversation's contacts are anonymised everywhere in the org, and an erasure log is written. |
removePeople: true is not "also delete these contacts" - it is an erase with org-wide reach. The contacts' other conversations stay, but anonymised. Use it only for a genuine deletion request, and prefer the dedicated erase flow when erasure is what you actually mean.
Errors
| Situation | Response |
|---|---|
removePeople: true without CONTACTS_ERASE | 403, decided before any id is looked up |
| A contact of the conversation is mid-merge | 409 retry shortly - nothing is deleted or erased |
| A direct thread with no contact | 409 - nothing is deleted or erased |
Labels and Collections
A collection is a saved view of the inbox - what the dashboard shows as a label or folder. It can be a manual list or rule-driven.
apiCreateChatCollection(input: CreateChatCollectionInput!): ChatCollection!
apiUpdateChatCollection(idOrSlug: String!, input: UpdateChatCollectionInput!): ChatCollection!
apiMoveChatCollection(input: MoveCollectionInput!): ChatCollection!
apiDeleteChatCollection(idOrSlug: String!): Boolean!| Operation | Description |
|---|---|
apiCreateChatCollection | Creates a collection. |
apiUpdateChatCollection | Updates one, addressed by ID or slug. |
apiMoveChatCollection | Reorders it in the sidebar. |
apiDeleteChatCollection | Deletes it. The conversations inside are untouched. |
The read side - labels, activeLabels, label - needs MESSAGES_READ; creating, updating, archiving, restoring and setMessageLabels need CONVERSATIONS_MANAGE. A malformed label id is a not-found, not a server error, and a key without NOTES_WRITE cannot label an internal note or a system row.
CreateChatCollectionInput takes name (required), plus optional slug, description, icon, color, position, and rules - the same CollectionRuleInput shape as message search, which makes the collection rule-driven instead of manual.
ChatCollection carries _id, name, slug, description, icon, color and position.
Addressing by slug means you can write apiUpdateChatCollection(idOrSlug: "urgent", …) without storing IDs. The same slug works as collectionIdOrSlug in message search.
What These Emit
State changes fan out to your webhook as chat.updated and conversation.updated. Expect one inbox action to produce more than one event - deduplicate on the envelope id and reconcile by timestamp, never by arrival order.