DEVELOPER DOCUMENTATION
Get Message Attachment
Download the verified bytes of one message attachment through an authorized, audited read. No signed URLs.
/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. 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
Section titled “Request”messageIdstringpathrequiredID of a message in a conversation you can read (1–128 characters).
attachmentIdstringpathrequiredOpaque attachment ID from the message read (att_ followed by 32 lowercase hex characters). A malformed value returns 400 before authentication.
deliverystringquerybytesHow 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
Section titled “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
Section titled “Limits and audit behavior”- Size ceiling: 20 MiB. A file above the ceiling returns
409withdetails.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
429withRetry-AfterandX-RateLimit-Limit,X-RateLimit-Remaining, andX-RateLimit-Resetheaders. 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
500and sends nothing. Requests rejected earlier (400, missing scope, rate limit, or the opaque404) 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
404withdetails.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
409withdetails.reasonMESSAGE_DELETEDorMESSAGE_HIDDEN. The response reveals nothing about the attachment.
Errors
Section titled “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 |
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 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.
TypeScript SDK
Section titled “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:
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