---
title: "Search messages"
method: POST
path: "/api/messages/search"
tags: ["messages"]
---

# Search messages

`POST /api/messages/search`

## Request

- Base URL: `https://beta.getsales.io/unibox/v1`
- URL: `https://beta.getsales.io/unibox/v1/api/messages/search`
- Auth: HTTP bearer and API key in header `Team-SID`

Read messages: LinkedIn and email, both directions, AI SDR drafts included (a draft is state pending_approval). Two jobs. Page one thread by filter.conversation_sid plus created_at { lt: the oldest row you hold }. Or work the cross-thread approval queue with filter.state { eq: "pending_approval" }, then approve / dismiss / regenerate. page_size 0 returns counts only, and counts.groups.state.pending_approval is the queue badge. Thread state, mode and reply rates are the conversations tools.

Contract:
- MCP tool `search_messages`, registry package `mcp.unibox/messages`, mount `unibox.inbox`.
- Operation `search`, response envelope `search`.
- Flags: read only.

## Request body

- SearchMessagesRequest: Request body of `search_messages`.
  - `filter` object: Field filters, ANDed: {field: {eq | ne | in | nin | gte | lte | gt | lt | is_null}}; q inside it is free text. Omit for every row.
    - `sid` object: Message sid(s), ub_ms_ prefix - also the point-lookup path (there is no get tool).
      - `eq` string: Message sid (ub_ms_…).
      - `in` string[]
    - `conversation_sid` object: Scope to one thread (or a handful); the thread-paging filter.
      - `eq` string
      - `in` string[]
    - `channel` object: linkedin | email.
      - `eq` 'linkedin' | 'email'
      - `in` string[]
    - `kind` object: Channel-native form, e.g. { eq: "linkedin_connection_note" } for connection notes only.
      - `eq` 'linkedin_message' | 'linkedin_connection_note' | 'linkedin_inmail' | 'email'
      - `in` string[]
    - `direction` object: inbound | outbound.
      - `eq` 'inbound' | 'outbound'
    - `state` object: The approval queue is { eq: "pending_approval" }; what actually left is { in: ["sent", "delivered"] }.
      - `eq` 'pending_approval' | 'queued' | 'sent' | 'delivered' | 'failed' | 'canceled'
      - `in` string[]
      - `nin` string[]
    - `author` object: contact | user | agent - the audit filter for what the AI SDR wrote.
      - `eq` 'contact' | 'user' | 'agent'
      - `in` string[]
    - `origin` object: synced (imported history) | native (our pipeline).
      - `eq` 'synced' | 'native'
    - `sent_at` object: ISO 8601 range over provider accept; is_null true = never handed over (draft, queued, failed).
      - `gte` string
      - `lte` string
      - `is_null` boolean
    - `created_at` object: ISO 8601 range in thread order; { lt: oldest_row_you_hold } is the older-history page.
      - `gte` string
      - `lte` string
      - `gt` string
      - `lt` string
    - `deleted_at` object: Reserved column with no writer in v1, so every row is { is_null: true } and this changes nothing.
      - `is_null` boolean
  - `include` string[]: Relations to attach to every item; omit for the bare rows.
  - `sort` object: One column and its direction; omit for the default order.
    - `field` 'created_at' | 'sent_at', required: The column to order by.
    - `direction` 'asc' | 'desc': asc or desc; default desc.
  - `page_size` integer: 0..500, default 50; 0 = counts only, 1 = the first row.
  - `cursor` string, nullable: pagination.next_cursor of the previous page.

## Response `200`

`search` success envelope.

- SearchMessagesResponse
  - `success` true, required
  - `operation` 'search', required
  - `items` object[], required
    - `item` object, required
      - `sid` string, required: Message sid (ub_ms_…).
      - `team_sid` string, required: Owning team (ts_tm_…, external - gs.service.id).
      - `conversation_sid` string, required: The thread this row hangs on (ub_cv_…); re-pointed when two threads merge.
      - `channel` 'linkedin' | 'email', required: linkedin | email, inherited from the thread.
      - `kind` 'linkedin_message' | 'linkedin_connection_note' | 'linkedin_inmail' | 'email', required: Channel-native form; must belong to `channel`.
      - `direction` 'inbound' | 'outbound', required: inbound = from the counterpart, outbound = from our sender profile.
      - `state` 'pending_approval' | 'queued' | 'sent' | 'delivered' | 'failed' | 'canceled', required: pending_approval = an AI draft awaiting you; queued = accepted, not out yet (it may be in the middle of its hand-over to the provider); sent/delivered = out; failed = the send did not leave (failure_reason says why, retry_messages sends it again, cancel_messages drops it); canceled = a dismissed draft, a withdrawn or canceled send: it never went out, and it is not on the thread.
      - `origin` 'synced' | 'native', required: synced = imported by the sender profile history sync (any tool sent it), native = it flowed through this pipeline live.
      - `body_text` string, required: The canonical body, normalized to plain text. It lives here and nowhere else: events, webhooks and the playtables mirror carry the sid only.
      - `author` 'contact' | 'user' | 'agent', required: contact | user | agent. The AI-disclosure fact, derived from the acting JWT at send and never writable.
      - `subject` string, nullable, required: Email only (max 998); null on LinkedIn rows and on email replies that inherited the thread subject.
      - `body_html` string, nullable, required: Email only, sanitized at render; null when the provider gave no HTML part. body_text stays the canonical copy.
      - `approved_by_user_sid` string, nullable, required: The user who approved this draft; null on anything that was never a draft.
      - `agent_run_sid` string, nullable, required: The agent run that wrote this draft (ub_rn_…); null on human and orchestration sends. regenerate_message needs it.
      - `ai_confidence` number, nullable, required: 0..1 on agent drafts, the model self-score the approval queue sorts and colors by; null otherwise.
      - `ai_source` string, nullable, required: One free-text provenance line for a draft, max 255 ("Knowledge: security pack", "Behavior: reschedule_meeting"), shown under the draft. Not a closed vocabulary.
      - `workflow_task_sid` string, nullable, required: The playtables workflow task that commanded this send (wf_tk_…), and the dedup key for it; null on inbox-authored rows.
      - `automation_ref` object, nullable, required: The workflow and the step, or the play, that sent this row, frozen when playtables reported the send (the provenance line "Automation · {workflow} · step {n}", or "Play · {play}"); null on anything no automation sent. The conversation's own automation_ref says which automation owns the lead now.
        - `workflow_sid` string, nullable, required: The playtables workflow (wf_wf_..., external); null when a play sent the row.
        - `workflow_name` string, nullable, required: Display denorm, frozen when playtables reported the send.
        - `step_id` integer, nullable, required: The workflow step that sent it; null when a play did.
        - `play_sid` string, nullable: The playtables play that sent the row (pt_pl_..., external, 2026-10-14); null when a workflow did, absent on rows frozen before.
        - `play_name` string, nullable: Display denorm, frozen when playtables reported the send.
      - `provider_message_sid` string, nullable, required: The gtm-api side reference (max 64), and the upsert key for inbound and synced rows; null until the provider accepts an outbound one.
      - `author_participant_sid` string, nullable, required: On a group thread, the participant (ub_pt_...) who wrote an inbound row (2026-09-24); null on a one-to-one thread (the primary counterpart wrote it) and on every outbound row (the sender profile did). Read the thread's participants include to name them.
      - `reactions` object[], nullable, required: Emoji reactions on a LinkedIn message, one summary per emoji in the order LinkedIn reports them; the one fact on a sent row that keeps moving (people react long after arrival). Null on email rows, drafts and rows LinkedIn has not served with reactions; [] = seen, none. The sender profile's own reaction is the entry with is_viewer_reacted; the counterpart's reaction reaches this row with gtm-api's next sync of the account. Written by react_to_message and by every re-read of the thread.
        - `emoji` string, required: The emoji, e.g. "👍" (max 32 chars).
        - `count` integer, required: Everyone who reacted with it, the sender profile included.
        - `is_viewer_reacted` boolean, required: True when the sender profile's own LinkedIn account is among them: react_to_message with remove takes it back.
        - `first_reacted_at` string, nullable, required: ISO 8601 UTC, when the first of them reacted; null when LinkedIn did not say.
      - `attachments` object[], nullable, required: The files the message carried (a voice message, an image, a PDF), in the provider's order, the index get_message_attachment_link takes. Null when it carried none or was read before files were kept (2026-10-18).
        - `kind` 'voice' | 'audio' | 'image' | 'video' | 'gif' | 'file', required: voice (a LinkedIn voice message), audio, image, video, gif (from LinkedIn's library, never fetched) or file.
        - `state` 'pending' | 'stored' | 'failed' | 'skipped', required: stored = GetSales holds it (get_message_attachment_link opens it); pending = on its way; failed or skipped = not held, reason says why.
        - `name` string, nullable, required: The file name as sent; null when the channel gave none (a voice message, most images).
        - `mime` string, nullable, required: The file type, e.g. application/pdf; null until known.
        - `size` integer, nullable, required: Bytes; null until known.
        - `pages` integer, nullable, required: A stored PDF's page count; null otherwise.
        - `is_inline` boolean, required: A mail's inline part (a signature logo, a picture in the HTML): never fetched, never shown as a file.
        - `provider_attachment_id` string, nullable, required: gtm-api's id of a mail part; null on LinkedIn.
        - `reason` string, nullable, required: Why it failed or was skipped, a token: history, outbound, inline, gif, too_large, listed_late, mailbox_unsupported, not_found, refused, unreadable, unavailable, unsupported, retries_exhausted.
        - `storage_key` string, nullable, required: The object's key in the private store, internal: read the file through get_message_attachment_link.
        - `transcript` string, nullable, required: A voice message's words as transcribed; empty when nothing was said.
        - `transcript_state` 'pending' | 'done' | 'failed', nullable, required: A voice message's words: pending, done or failed; null on every other kind.
      - `is_wordless` boolean, required: True when the person sent files and no words: body_text is then GetSales' sentence naming them ("Sent a voice message"), never their words. A voice message's words are in attachments[].transcript.
      - `failure_reason` string, nullable, required: Why the last dispatch failed, as a reason TOKEN (gtm-api's own: browser_unreachable = the sender's browser is not running, delay_not_elapsed = too soon after the last send from this sender profile, gtm_unavailable = the sending service did not answer, gtm_connection_revoked = the workspace connection the sender lives in was revoked (a workspace admin links it again), retries_exhausted = the queue gave up, send_unconfirmed = the sending service's answer to the send never came, it answered that its browser could not tell whether the message went out, or the hand-over was cut off, and in 15 minutes nothing showed whether it left; or the code of a refusal such as not_connected), never a sentence: relay it in your own words. Set with state failed and kept when a person cancels the row (cancel_messages), null on every other state and on rows that failed before the token existed; retry_messages clears it, and so does the provider's own record of the send arriving later (the row turns sent).
      - `scheduled_at` string, nullable, required: ISO 8601 UTC. Holds a queued row until then; null = dispatch as soon as the per-sender-profile queue allows.
      - `sent_at` string, nullable, required: ISO 8601 UTC, provider accept; null while pending_approval, queued or failed.
      - `delivered_at` string, nullable, required: ISO 8601 UTC, provider delivery confirmation (email; on LinkedIn sent is as good as delivered).
      - `opened_at` string, nullable, required: ISO 8601 UTC, FIRST open only (email tracking rollup); per-tick history is not stored here.
      - `clicked_at` string, nullable, required: ISO 8601 UTC, FIRST click only.
      - `bounced_at` string, nullable, required: ISO 8601 UTC, email only; set once by the engagement ingest.
      - `recalled_at` string, nullable, required: ISO 8601 UTC: when this sent LinkedIn message was deleted for everyone (LinkedIn's recall, 2026-10-07), by a person through delete_message_on_linkedin or by its author on LinkedIn, which the next sync of the thread observes. The state stays sent or delivered and body_text keeps the words for the team, while the thread's members see that a message was deleted: never quote it to the lead as something they have, and never answer a deleted message of theirs. Null = never deleted.
      - `recalled_by_user_sid` string, nullable, required: The person who deleted it from GetSales; null when the deletion was observed on LinkedIn.
      - `person_edited_at` string, nullable, required: ISO 8601 UTC: when a person changed the agent's words before approving the draft (2026-10-19, read since 2026-10-20); null when it went as the agent wrote it, and on a row the agent never wrote. approved_by_user_sid names the person who sent it.
      - `created_at` string, required: ISO 8601 UTC. For a synced row this is the PROVIDER timestamp, so thread order survives a backfill; it is also the default sort.
      - `updated_at` string, required: ISO 8601 UTC.
      - `deleted_at` string, nullable, required: ISO 8601 UTC. Reserved: nothing writes it in v1 (a dismissed draft is state canceled, a sent message is never removed), so it is always null.
    - `included` object, required
      - `conversation` object: The owning thread (ConversationDomain, 1:1) - see ./conversations.md.
        - `sid` string, required: Thread sid (ub_cv_…).
  - `pagination` object, required
    - `next_cursor` string, nullable, required
    - `has_more` boolean, required
    - `total_count` integer, nullable, required
  - `applied_filters` object, required
  - `includes` string[], required
  - `meta` object, required
    - `trace_id` string, required: UUID v7; same 128-bit value as the X-Trace-Id header.
    - `span_id` string, required: 16 hex chars, root span of this request.
    - `timestamp` string, required: ISO 8601 UTC (Y-m-dTH:i:sZ), response time.
    - `duration_ms` integer, required: Server-side wall clock.
    - `team_sid` string, nullable: The team this call ran in (the token team, or the team_sid override). Null when unauthenticated; absent from pre-2026-08-20 backends.
    - `actor_type` string, nullable: user | agent | api_key | system. Null when unauthenticated; absent from pre-2026-08-20 backends.
  - `counts` object
    - `total_count` integer, required: Messages matching the filter (full filter scope, not the page).
    - `groups` object, required
      - `state` union, required: groups.state.pending_approval is the approval-queue badge; absent key = zero.
        - object
          - `pending_approval` integer
          - `queued` integer
          - `sent` integer
          - `delivered` integer
          - `failed` integer
          - `canceled` integer
        - unknown[]
          - unknown
      - `channel` union, required: Volume split across the two channels; absent key = zero.
        - object
          - `linkedin` integer
          - `email` integer
        - unknown[]
          - unknown
      - `author` union, required: Who wrote what: the AI-disclosure split; absent key = zero.
        - object
          - `contact` integer
          - `user` integer
          - `agent` integer
        - unknown[]
          - unknown

## Other responses

- `4XX`: MCP error envelope. `error.code` is one of validation_failed, nothing_to_update, not_found, relation_not_found, invalid_transition, limit_exceeded, payment_required, duplicate_rejected, conflict, delete_blocked, unauthorized, forbidden, rate_limited, not_implemented.
- `5XX`: MCP error envelope with `error.code` internal_error or service_unavailable.

---

[API](https://skmtc.dev/getsales/apis/getsales-public-contract-gs-service-unibox.md) - [All operations](https://skmtc.dev/getsales/apis/getsales-public-contract-gs-service-unibox/llms.txt) - [OpenAPI document](https://skmtc.dev/getsales/apis/getsales-public-contract-gs-service-unibox/revisions/8f1bc317897f?raw)
