GraphQL API

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

ArgumentTypeRequiredDescription
inputSearchChatsInput!YesFilters: free-text search, collection, and rule-based filters.
paginationGetCursorPaginatedInputNocursor, limit (default 20), before (default false).
sortGetSortedInputNoSort field and direction.

Returns SearchConversationsResult!

FieldTypeDescription
data[Conversation!]!The conversation rows.
nextCursorStringPass back as pagination.cursor.
previousCursorStringCursor for the previous page.
hasNextBoolean!More rows after this page.
hasPreviousBoolean!More rows before this page.
totalCountIntTotal matches. First page only.

Conversation - useful fields:

FieldTypeDescription
_idString!The conversation ID.
primaryThreadChat!The main thread. Its _id is the chatId for reads and sends.
threads[Chat!]!All channel threads in this conversation.
unReadCountIntUnread messages. Note the capital R.
lastMessageMessageMost recent message.
lastMessageAtDateTimeWhen it arrived.
lastActivityAtDateTimeLast activity of any kind. Sort on this.
statusConversationStatusOPEN or CLOSED.
channels[ConversationChannel!]!Channels the contact has used.
assignedToUserThe common operator across open threads, else null.
assignees[User!]!Distinct operators across open threads.
isBlockedBoolean!The contact is blocked at person level.
hasBlockedThreadBoolean!Any thread is blocked.
createdAtDateTime!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!
OperationReads
getMessagesOne channel thread.
conversationMessagesThe whole conversation - all its threads, merged.

Arguments

ArgumentTypeRequiredDescription
chatId / conversationIdString!YesWhat to read.
paginationPaginateMessagesInputNocursor, isBefore, limit (default 30). Note isBefore, not before.
messageIdStringNoCenter the page on a specific message, for deep-linking into history.

Returns PaginatedMessages!

FieldTypeDescription
data[Message!]!The messages.
nextCursor / previousCursorStringPaging cursors.
hasNext / hasPreviousBoolean!Whether more exist.
unreadCountFloatUnread in this thread. Lowercase r here, unlike Conversation.unReadCount.
totalMessagesFloatTotal 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.

apiSearchMessages(input: SearchMessagesInput!, pagination: PaginateMessagesInput): PaginatedMessages!
apiSearchChatMessages(chatId: String!, input: SearchMessagesInput!, pagination: PaginateMessagesInput): PaginatedMessages!
OperationSearches
apiSearchMessagesAcross the whole org.
apiSearchChatMessagesWithin one chat.

SearchMessagesInput

FieldTypeDescription
chatIdStringNarrow to one thread.
conversationIdStringNarrow to one conversation.
collectionIdOrSlugStringNarrow to a collection, by ID or slug.
rules[CollectionRuleInput!]Rule-based filters - the same shape the dashboard uses.

CollectionRuleInput

FieldTypeDescription
fieldCollectionFilterField!MESSAGE_LABEL, MESSAGE_TYPE, MESSAGE_TEXT, MESSAGE_DIRECTION, INTEGRATION_ID, ASSIGNED_TO, CHAT_UNREAD, CONTACT_NAME, CHAT_CREATED, PROFILE_FIELD.
operatorFilterOperator!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.
valueJSONThe value to match. Not needed for IS_EMPTY, IS_NOT_EMPTY, EXISTS, NOT_EXISTS. See the value rules below.
fieldPathStringWhich profile field, when field is PROFILE_FIELD.

What a Rule Value May Be

AcceptedStrings, numbers, booleans, ISO dates, and arrays of those.
BETWEENAn object { from, to } - the one object form allowed. An empty {} is not a valid range.
Other objects400, for keys and members alike.
REGEXRefused 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 ruleDetail
No personal inboxThe virtual mine and mentions collections are refused with a 400, and inboxCounts reports mine: 0, mentions: 0.
Unknown collectionA not-found for a key, where a member gets lenient "ignore it" behaviour.
Batch countsAt most 50 collection ids per call for a key.
Rule valuesNull 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.

On this page