Skip to content
OpenTrain AIOpenTrain AIOpenTrain AIDocs

Ask OpenTrain

Answers from the documentation, with sources.

What would you like to do with OpenTrain?

AI answers can be mistaken. Check the linked sources. Don’t include private account information.

Open app

DEVELOPER DOCUMENTATION

Get Message Attachment

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

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. 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.

messageIdstringpathrequired

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

attachmentIdstringpathrequired

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

deliverystringquery
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.

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

HeaderMeaning
Content-TypeDetected 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-Dispositionattachment with a bounded, sanitized file name. Falls back to attachment-<attachmentId>.<ext>
X-OpenTrain-Byte-SizeVerified byte count of the exact body
X-OpenTrain-Sha256Hex SHA-256 of the exact body bytes. Compare it with your downloaded file
X-OpenTrain-Mime-TypeSame value as Content-Type
X-OpenTrain-SourcePRIVATE_STORAGE or LEGACY_EXTERNAL
X-OpenTrain-DeliveryResolved presentation: bytes, inline, or resource
X-OpenTrain-Message-Id / X-OpenTrain-Attachment-IdEcho 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.

  • 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.
StatuscodeMeaning
400BAD_REQUESTMalformed messageId, attachmentId, or delivery. details.reason is MESSAGE_ID_REQUIRED, INVALID_MESSAGE_ID, INVALID_ATTACHMENT_ID, or INVALID_DELIVERY. Rejected before authentication
401UNAUTHORIZEDMissing or invalid token
403FORBIDDENMissing messages:read scope. This is the only typed 403
404NOT_FOUNDOne opaque response for any message, conversation, or attachment you cannot read
409ATTACHMENT_UNAVAILABLEThe 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
429RATE_LIMITEDRate limit reached on your token or client IP. Retry after Retry-After
500INTERNAL_ERRORThe access record could not be written, so nothing was sent. Retryable
503SERVICE_UNAVAILABLETemporary 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.

@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