# Get Message Attachment

Download the verified bytes of one message attachment through an authorized, audited read. No signed URLs.

Source: https://www.opentrain.ai/docs/developers/api-reference/messages/get-attachment/

Endpoint: GET /api/public/v1/messages/{messageId}/attachments/{attachmentId}

Retrieves the bytes of one message attachment. This is the only way to read attachment content through the API. Message reads return opaque attachment metadata (`attachments[].attachmentId`), and this endpoint exchanges that ID for the verified bytes. The response never contains a signed URL, storage path, bucket name, or external file URL, and there is no signed-link mode.

Get the `attachmentId` from the `attachments[]` array on the messages returned by [`GET /messages`](https://www.opentrain.ai/docs/developers/api-reference/messages/list/). Job message audits and thread reads return the same `attachments[]` metadata. Check `attachments[].retrieval.bytes` first: it tells you whether the file is eligible for this read before you call it.

**Requirements:** `messages:read` scope and read access to the message's conversation. This is the same authorization every message read uses.

## Request

#### messageId (string, required)

ID of a message in a conversation you can read (1–128 characters).

#### attachmentId (string, required)

Opaque attachment ID from the message read (`att_` followed by 32 lowercase hex characters). A malformed value returns `400` before authentication.

#### delivery (string, default: bytes)

How you will present the bytes: `bytes` (default), `inline`, or `resource`. `auto` lets the server choose from the verified file type and size. It resolves to `inline` for a verified PNG, JPEG, WebP, or GIF at or below 4 MiB and to `resource` otherwise, and reports the result in `X-OpenTrain-Delivery`. Delivery never changes authorization or which bytes are returned. Empty, unknown, or repeated values return `400` before authentication.

## Response

Returns `200` with the attachment bytes as a chunked stream with no `Content-Length` header. Verification metadata is in the response headers:

| Header | Meaning |
| --- | --- |
| `Content-Type` | Detected from the actual bytes. Only verified PNG, JPEG, WebP, and GIF files are served as image types. Everything else, including files declared as images whose bytes disagree, HTML, SVG, and PDF, is `application/octet-stream` |
| `Content-Disposition` | `attachment` with a bounded, sanitized file name. Falls back to `attachment-<attachmentId>.<ext>` |
| `X-OpenTrain-Byte-Size` | Verified byte count of the exact body |
| `X-OpenTrain-Sha256` | Hex SHA-256 of the exact body bytes. Compare it with your downloaded file |
| `X-OpenTrain-Mime-Type` | Same value as `Content-Type` |
| `X-OpenTrain-Source` | `PRIVATE_STORAGE` or `LEGACY_EXTERNAL` |
| `X-OpenTrain-Delivery` | Resolved presentation: `bytes`, `inline`, or `resource` |
| `X-OpenTrain-Message-Id` / `X-OpenTrain-Attachment-Id` | Echo of the selector, so your client can check it |

Every response also carries `X-Content-Type-Options: nosniff`, a sandboxing `Content-Security-Policy`, and `Cache-Control: no-store`.

## Limits and audit behavior

- **Size ceiling:** 20 MiB. A file above the ceiling returns `409` with `details.reason: "OVERSIZE"`. The ceiling is enforced before and during the transfer.
- **Rate limit:** 10 requests per minute, counted separately for your token and for your client IP. If either limit is reached, you get the standard `429` with `Retry-After` and `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. The limit is counted only after the scope check passes.
- **Audit:** every read that passes authorization writes an append-only access record (verified byte count, SHA-256, detected media type, source, delivery, and actor) before any byte is sent. If OpenTrain cannot write that record, it returns a retryable `500` and sends nothing. Requests rejected earlier (`400`, missing scope, rate limit, or the opaque `404`) write no record.
- **Opacity:** a missing message, a message you cannot read, a sender hidden from you, and an unknown attachment ID all return one identical `404` with `details.resource: "message_attachment"`. The response never reveals which check failed.
- **Deleted and hidden messages:** for a deleted or moderation-hidden message, every attachment ID returns `409` with `details.reason` `MESSAGE_DELETED` or `MESSAGE_HIDDEN`. The response reveals nothing about the attachment.

## Errors

| Status | `code` | Meaning |
| --- | --- | --- |
| `400` | `BAD_REQUEST` | Malformed `messageId`, `attachmentId`, or `delivery`. `details.reason` is `MESSAGE_ID_REQUIRED`, `INVALID_MESSAGE_ID`, `INVALID_ATTACHMENT_ID`, or `INVALID_DELIVERY`. Rejected before authentication |
| `401` | `UNAUTHORIZED` | Missing or invalid token |
| `403` | `FORBIDDEN` | Missing `messages:read` scope. This is the only typed `403` |
| `404` | `NOT_FOUND` | One opaque response for any message, conversation, or attachment you cannot read |
| `409` | `ATTACHMENT_UNAVAILABLE` | The file cannot be returned. `details.reason` is `MESSAGE_DELETED`, `MESSAGE_HIDDEN`, `NO_REFERENCE`, `PATH_NOT_BOUND_TO_CONVERSATION`, `EXTERNAL_HOST_NOT_ALLOWED`, `STORAGE_OBJECT_MISSING`, `EXTERNAL_FETCH_FAILED`, or `OVERSIZE` |
| `429` | `RATE_LIMITED` | Rate limit reached on your token or client IP. Retry after `Retry-After` |
| `500` | `INTERNAL_ERROR` | The access record could not be written, so nothing was sent. Retryable |
| `503` | `SERVICE_UNAVAILABLE` | Temporary rate-limiter or file-storage outage. Retryable; it says nothing about the file |

## CLI

`opentrain messages attachment get` writes the bytes to `--output` and never prints binary data to stdout. The bytes go to a temporary `.part` file first, and the command renames it only after the byte count and SHA-256 match the response headers. If `--output` names an existing directory, the command saves the sanitized file name inside it. The command refuses to overwrite an existing file unless you pass `--force`. The JSON envelope reports `data.path`, `data.byteSize`, `data.sha256`, and `data.mimeType` for the file it wrote.

## MCP

MCP agents use `opentrain_get_message_attachment`, which makes one audited `delivery=auto` request. A verified PNG, JPEG, WebP, or GIF at or below 4 MiB comes back as an inline image block the agent can inspect. Every other file becomes a resource link at `opentrain://messages/{messageId}/attachments/{attachmentId}`. See [MCP tools](https://www.opentrain.ai/docs/developers/mcp/tools/#opentrain_get_message_attachment).

## TypeScript SDK

`@opentrain-ai/sdk` 0.25.0 or later exposes the same read. The SDK checks every `X-OpenTrain-*` header and returns the bytes only after their length and SHA-256 match:

```ts
import { OpenTrainClient } from '@opentrain-ai/sdk';

const opentrain = new OpenTrainClient({
  apiToken: process.env.OPENTRAIN_API_TOKEN!,
});

const file = await opentrain.getMessageAttachment({
  messageId: '<MESSAGE_ID>',
  attachmentId: '<ATTACHMENT_ID>',
});
// file.bytes, file.fileName, file.mimeType, file.byteSize, file.sha256
```

```bash curl
curl -sS "https://app.opentrain.ai/api/public/v1/messages/<MESSAGE_ID>/attachments/<ATTACHMENT_ID>" \
  -H "Authorization: Bearer $OT_API_TOKEN" \
  -o evidence.png -D headers.txt
```

```bash CLI
opentrain messages attachment get \
  --message-id <MESSAGE_ID> \
  --attachment-id <ATTACHMENT_ID> \
  --output ./evidence.png
```

```json MCP: opentrain_get_message_attachment
{
  "messageId": "<MESSAGE_ID>",
  "attachmentId": "<ATTACHMENT_ID>"
}
```

```text 200 (response headers)
Content-Type: image/png
Content-Disposition: attachment; filename="evidence.png"; filename*=UTF-8''evidence.png
X-OpenTrain-Byte-Size: 482133
X-OpenTrain-Sha256: <64_HEX_CHARACTERS>
X-OpenTrain-Mime-Type: image/png
X-OpenTrain-Source: PRIVATE_STORAGE
X-OpenTrain-Delivery: bytes
X-Content-Type-Options: nosniff
Cache-Control: no-store
```

```json 409 (unavailable)
{
  "error": "Attachment bytes cannot be disclosed",
  "code": "ATTACHMENT_UNAVAILABLE",
  "requestId": "<REQUEST_ID>",
  "details": {
    "reason": "MESSAGE_DELETED"
  }
}
```
