---
title: "Get a link to a message file"
method: GET
path: "/api/messages/{sid}/attachment-link"
tags: ["messages"]
---

# Get a link to a message file

`GET /api/messages/{sid}/attachment-link`

## Request

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

A link to one file a message carried (an image, a voice message, a PDF), signed for 15 minutes. Pick the file by its index in item.attachments; the LLM context names it [file {sid}#{index}]. Only a stored file has one: any other is 409 attachment_not_stored (context.state and context.attachment_reason say why), an index past the list 404. A voice message's words need no link: they are attachments[].transcript.

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

## Path parameters

- `sid` string, required: The message that carried the file.

## Query parameters

- `index` integer, required: The file's place in item.attachments, from 0.

## Response `200`

`action` success envelope.

- GetMessageAttachmentLinkResponse
  - `success` true, required
  - `operation` 'action', required
  - `action` string, required: kebab-case verb; matches the route segment.
  - `item` object, nullable, 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.
  - `result` object, required
    - `index` integer, required: The file's place in the message's attachments.
    - `attachment` object, required: The file as stored: kind, name, type, size, a PDF's pages, a voice message's words.
      - `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.
    - `download_url` string, required: A link to the file's bytes, signed for 15 minutes; every call signs a new one. Never store it or pass it on.
    - `expires_at` string, required: ISO 8601 UTC, when download_url stops working.
  - `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.

## 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)
