Reading the Inbox
List conversations, read threads, search messages, and pull media
Scope: MESSAGES_READ for everything on this page. Internal notes additionally need NOTES_WRITE; unmasked identity needs PII_READ.
A conversation is the inbox row for a contact. A chat (thread) is one channel thread inside it. Conversation-level reads take conversationId; thread-level reads take chatId. Sending always takes chatId.
searchConversations
searchConversations(
input: SearchChatsInput!
pagination: GetCursorPaginatedInput
sort: GetSortedInput
): SearchConversationsResult!The conversation list - this is the inbox.
Arguments
| Argument | Type | Required | Description |
|---|---|---|---|
input | SearchChatsInput! | Yes | Filters: free-text search, collection, and rule-based filters. |
pagination | GetCursorPaginatedInput | No | cursor, limit (default 20), before (default false). |
sort | GetSortedInput | No | Sort field and direction. |
Returns SearchConversationsResult!
| Field | Type | Description |
|---|---|---|
data | [Conversation!]! | The conversation rows. |
nextCursor | String | Pass back as pagination.cursor. |
previousCursor | String | Cursor for the previous page. |
hasNext | Boolean! | More rows after this page. |
hasPrevious | Boolean! | More rows before this page. |
totalCount | Int | Total matches. First page only. |
Conversation - useful fields:
| Field | Type | Description |
|---|---|---|
_id | String! | The conversation ID. |
primaryThread | Chat! | The main thread. Its _id is the chatId for reads and sends. |
threads | [Chat!]! | All channel threads in this conversation. |
unReadCount | Int | Unread messages. Note the capital R. |
lastMessage | Message | Most recent message. |
lastMessageAt | DateTime | When it arrived. |
lastActivityAt | DateTime | Last activity of any kind. Sort on this. |
status | ConversationStatus | OPEN or CLOSED. |
channels | [ConversationChannel!]! | Channels the contact has used. |
assignedTo | User | The common operator across open threads, else null. |
assignees | [User!]! | Distinct operators across open threads. |
isBlocked | Boolean! | The contact is blocked at person level. |
hasBlockedThread | Boolean! | Any thread is blocked. |
createdAt | DateTime! | Creation time. |
Example
query Inbox {
searchConversations(input: {}, pagination: { limit: 20 }) {
totalCount
hasNext
nextCursor
data {
_id
unReadCount
lastActivityAt
status
primaryThread { _id }
lastMessage { _id text direction createdAt }
}
}
}{
"data": {
"searchConversations": {
"totalCount": 311,
"hasNext": true,
"nextCursor": "eyJsYXN0QWN0aXZpdHlBdCI6IjIwMjYtMTAtMDFUMTc6NTk6MDBaIn0",
"data": [
{
"_id": "6804f4d5a6f9f35f6e66f1c4",
"unReadCount": 2,
"lastActivityAt": "2026-10-01T17:59:00.000Z",
"status": "OPEN",
"primaryThread": { "_id": "6804f4c2a6f9f35f6e66f1a1" },
"lastMessage": {
"_id": "6804f4f01c34d0012ab8a055",
"text": "where is my order?",
"direction": "INBOUND",
"createdAt": "2026-10-01T17:59:00.000Z"
}
}
]
}
}
}Without PII_READ, identity fields on the contact behind each row come back null and participant lists come back empty. The rows themselves are still returned.
Reading Messages
getMessages(chatId: String!, pagination: PaginateMessagesInput, messageId: String): PaginatedMessages!
conversationMessages(conversationId: String!, pagination: PaginateMessagesInput, messageId: String): PaginatedMessages!| Operation | Reads |
|---|---|
getMessages | One channel thread. |
conversationMessages | The whole conversation - all its threads, merged. |
Arguments
| Argument | Type | Required | Description |
|---|---|---|---|
chatId / conversationId | String! | Yes | What to read. |
pagination | PaginateMessagesInput | No | cursor, isBefore, limit (default 30). Note isBefore, not before. |
messageId | String | No | Center the page on a specific message, for deep-linking into history. |
Returns PaginatedMessages!
| Field | Type | Description |
|---|---|---|
data | [Message!]! | The messages. |
nextCursor / previousCursor | String | Paging cursors. |
hasNext / hasPrevious | Boolean! | Whether more exist. |
unreadCount | Float | Unread in this thread. Lowercase r here, unlike Conversation.unReadCount. |
totalMessages | Float | Total matches. Search only. |
Message - useful read fields: _id, direction, type, text, caption, media, replyToMessage, deliveryStatus, createdAt, externalMessageId (the channel-native ID - WhatsApp wamid, Telegram message_id).
Example
query Thread {
getMessages(chatId: "6804f4c2a6f9f35f6e66f1a1", pagination: { limit: 30 }) {
hasNext
nextCursor
unreadCount
data { _id direction type text createdAt media { _id url type } }
}
}Internal notes, system events and internal actions are excluded unless the key holds NOTES_WRITE. For a key without it, a MESSAGE_TEXT rule never matches a note - an excluded-type clause is appended to both the page and the count - and storing a collection with message-level rules is a 403.
Search
apiSearchMessages(input: SearchMessagesInput!, pagination: PaginateMessagesInput): PaginatedMessages!
apiSearchChatMessages(chatId: String!, input: SearchMessagesInput!, pagination: PaginateMessagesInput): PaginatedMessages!| Operation | Searches |
|---|---|
apiSearchMessages | Across the whole org. |
apiSearchChatMessages | Within one chat. |
SearchMessagesInput
| Field | Type | Description |
|---|---|---|
chatId | String | Narrow to one thread. |
conversationId | String | Narrow to one conversation. |
collectionIdOrSlug | String | Narrow to a collection, by ID or slug. |
rules | [CollectionRuleInput!] | Rule-based filters - the same shape the dashboard uses. |
CollectionRuleInput
| Field | Type | Description |
|---|---|---|
field | CollectionFilterField! | MESSAGE_LABEL, MESSAGE_TYPE, MESSAGE_TEXT, MESSAGE_DIRECTION, INTEGRATION_ID, ASSIGNED_TO, CHAT_UNREAD, CONTACT_NAME, CHAT_CREATED, PROFILE_FIELD. |
operator | FilterOperator! | EQUALS, NOT_EQUALS, IN, NOT_IN, CONTAINS, CONTAINS_ANY, CONTAINS_ALL, NOT_CONTAINS, STARTS_WITH, ENDS_WITH, REGEX, EXISTS, NOT_EXISTS, IS_EMPTY, IS_NOT_EMPTY, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, BETWEEN. |
value | JSON | The value to match. Not needed for IS_EMPTY, IS_NOT_EMPTY, EXISTS, NOT_EXISTS. See the value rules below. |
fieldPath | String | Which profile field, when field is PROFILE_FIELD. |
What a Rule Value May Be
| Accepted | Strings, numbers, booleans, ISO dates, and arrays of those. |
|---|---|
BETWEEN | An object { from, to } - the one object form allowed. An empty {} is not a valid range. |
| Other objects | 400, for keys and members alike. |
REGEX | Refused for keys. Members only. |
A string starting with $ | Refused for keys. |
The BETWEEN exception trips people up when a rule is edited: switch a rule from BETWEEN to another operator and the leftover { from, to } object stays in value, which is then rejected with a 400. Clear the value when you change the operator.
Filters a Masked Key Cannot Use
Without PII_READ, a rule on contact name, assignee or a person profile field is refused with 403 - not silently emptied. A filter over masked data would be an oracle on the very values the masking hides.
Free-text contact search needs PII_READ for the same reason.
Example - find unanswered inbound messages mentioning a refund
query FindRefunds {
apiSearchMessages(
input: {
rules: [
{ field: MESSAGE_TEXT, operator: CONTAINS, value: "refund" }
{ field: MESSAGE_DIRECTION, operator: EQUALS, value: "INBOUND" }
]
}
pagination: { limit: 50 }
) {
totalMessages
data { _id text createdAt }
}
}A key without PII_READ cannot filter on identity: a rule on CONTACT_NAME, ASSIGNED_TO or a person profile field is 403. See filters a masked key cannot use.
Collections and Counts
A key can read the org's collections and the inbox counts behind them:
chatCollections / chatCollection / collectionChats / collectionChatCount # MESSAGES_READ
chatCollectionCounts # MESSAGES_READ, expensive
inboxCounts # MESSAGES_READ, expensive| Key-specific rule | Detail |
|---|---|
| No personal inbox | The virtual mine and mentions collections are refused with a 400, and inboxCounts reports mine: 0, mentions: 0. |
| Unknown collection | A not-found for a key, where a member gets lenient "ignore it" behaviour. |
| Batch counts | At most 50 collection ids per call for a key. |
| Rule values | Null without PII_READ for assignee, contact-name, profile-field and message-text rules. createdBy is null too. |
A masked key can still use a collection it cannot fully read - the filtering runs server-side. You get the right conversations back; you just cannot see every rule value that produced them.
Media
getChatMedia(chatId: String!, pagination: PaginateMessagesInput, mediaTypes: [MediaType!]): PaginatedMessages!
conversationMedia(conversationId: String!, pagination: PaginateMessagesInput, mediaTypes: [MediaType!]): PaginatedMessages!
getChatMediaCounts(chatId: String!): [MediaTypeCount!]!
conversationMediaCounts(conversationId: String!): [MediaTypeCount!]!
allMedia: [Media!]!The media reads return the messages that carry media, not bare files, so you keep the context each file arrived in. Filter with mediaTypes. The counts operations return a per-type tally - use them to render a gallery header without fetching the media itself.
Media attachments on internal notes are hidden from a key without NOTES_WRITE, including in these media reads and counts.
Internal Notes
getChatNotes(chatId: String!, pagination: PaginateMessagesInput): PaginatedMessages!
conversationNotes(conversationId: String!, pagination: PaginateMessagesInput): PaginatedMessages!Scope: NOTES_WRITE - reading notes requires the same scope as writing them. Both return PaginatedMessages!.
Post a note with sendMessage and internal: true.