Attachments API
Two families of endpoints handle attachments:
- Reading inbound attachments — a safe extracted-text preview, or a raw byte download, of an attachment on a received message.
- Sending outbound attachments — a stage-then-reference flow: upload bytes to
get a single-use handle, then pass the handle's
idon a send, reply, or draft.
This page is the per-operation reference for those routes. For the machine-readable
contract, see /docs/openapi.json. For the agent-integration
narrative — exposure tiers, the full staging → poll → consume-once lifecycle, and
its failure-mode matrix — see /agents/attachments. For the
human conceptual overview, see /docs/guides/attachments.
Classification legend
Every operation below carries a classification tag:
| Tag | Meaning |
|---|---|
| read-only | Returns data; changes no state. |
| mutating | Creates or removes server state (a handle, an enablement flag). |
| send-triggering | Can put a message on the wire. |
| secret-revealing | Hands back raw attachment bytes, which may contain sensitive content. |
Auth model
All attachment routes accept a Bearer API key (admin or mailbox-scoped agent) or a
dashboard session cookie, except where noted. What an agent key can read is
governed by the owning mailbox's attachment exposure mode
(metadata_only, derived_content, raw_download_selected_types) and the
account's feature entitlements; admin keys and session callers pass the same
hard-safety gates but bypass the agent exposure-mode gate. The exposure mode is
configured per mailbox — see /docs/api/mailboxes — and the
entitlement tiers are described in /docs/limits.
Reading attachment metadata
There is no standalone metadata route. Attachment metadata for a message — each
entry's filename, sniffed content_type, size, av_verdict, policy_action,
retention flags, and preview_status — is delivered inline in the attachments[]
array of the message-detail response. See
/docs/api/messages for that projection. Use each attachment's
preview_status there to decide when to call the preview route below.
GET /v1/messages/:id/attachments/:idx/preview
Return a safe, extracted text preview of an attachment — UTF-8 text inline, no raw bytes. Supported source types are plain text, CSV, PDF, and Office Open XML (DOCX / PPTX / XLSX). Office previews surface visible text only (comments, speaker notes, hidden sheets, embedded media, and macros are excluded). The preview is generated asynchronously and cached; this endpoint serves the cached extraction.
Classification: read-only
Auth: Bearer (admin / agent) or session. Agent keys require the mailbox to be
in derived_content or raw_download_selected_types mode with the matching
entitlement; admin and session callers may read any ready preview that passes the
hard-safety gates.
Path params:
:id— message id.:idx— 0-based index into the message'sattachments[]array.
Response (200):
{
"attachment": {
"filename": "invoice.pdf",
"content_type": "application/pdf",
"size": 12345,
"hash": "sha256:..."
},
"preview": {
"kind": "text",
"content": "extracted text ...",
"truncated": true,
"char_count": 8123,
"page_count": 4,
"extractor": "pdfjs-dist",
"generated_at": "2026-04-27T18:00:00.000Z"
}
}contentis hard-capped at the shared body/scanner cap (20,000 characters by default).char_countis the extracted source size before the cap.truncatedistruewhen either extraction or the inline cap dropped content.page_countis non-null only for PDFs (Office and text derivatives carrynull).extractoridentifies the extraction path —plaintext(text/CSV),pdfjs-dist(PDF), orooxml-text(Office).
Errors:
| HTTP | code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | :idx is not a non-negative integer. |
| 403 | ATTACHMENT_PREVIEW_DISABLED | Agent key on a mailbox whose effective mode is metadata_only (or lacking the entitlement). |
| 403 | ATTACHMENT_PREVIEW_BLOCKED | The extracted-text scan rejected the content. |
| 404 | NOT_FOUND | Unknown message, unbound-agent mask, or :idx out of range. |
| 404 | ATTACHMENT_PREVIEW_NOT_AVAILABLE | Extraction failed, or no derivative exists (unsupported type or held source). |
| 409 | ATTACHMENT_PREVIEW_PENDING | Extraction is still in flight — retry later. |
| 500 | PREVIEW_STORAGE_ERROR | Internal storage error while fetching the preview. |
Polling. Don't poll this endpoint while preview_status is pending — read
the (cheaper) message detail until preview_status flips to ready. See the full
code catalog and remediation at /agents/errors.
GET /v1/messages/:id/attachments/:idx
Download a single attachment as raw bytes. Returns a short-lived presigned
download URL (5-minute expiry); the client fetches the bytes directly from object
storage, not through the API. The presigned URL forces
Content-Disposition: attachment and sets the trusted (sniffed) Content-Type.
Classification: read-only · secret-revealing
Auth: Bearer (admin / agent) or session. Agent keys require the mailbox to be
in raw_download_selected_types mode, the account to hold the approved-download
entitlement, and the attachment's file family (pdf | text | csv | image) to be in
the mailbox allowlist; raw image download additionally requires an explicit
image-risk acknowledgement. Admin and session callers use the human hard-safety
gate, under which clean, individually-stored attachments are downloadable.
Path params:
:id— message id.:idx— 0-based index into the message'sattachments[]array.
Downloadable only when the message is in state available or delivered, the
attachment's AV verdict is clean, and its per-attachment disposition is not a
hold or block. Quarantined / blocked messages and non-clean AV verdicts are
refused. (For the state and verdict vocabulary, see
/agents/messages.)
Response (200):
{
"url": "https://storage.example.com/signed?token=...",
"expires_at": "2026-04-16T12:05:00.000Z",
"content_type": "application/pdf",
"filename": "invoice.pdf",
"size": 52400,
"av_verdict": "clean"
}content_typeis the trusted, sniffed MIME type, not the declared header.av_verdictisclean/infected/error/skipped, ornullon legacy messages predating AV integration.
Errors:
| HTTP | code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Invalid attachment index. |
| 403 | ATTACHMENT_ACCESS_DISABLED | Agent key without the approved-download capability for this mailbox / tier. |
| 403 | ATTACHMENT_IMAGE_RISK_NOT_ACCEPTED | Image family without the separate image-risk acknowledgement. |
| 403 | ATTACHMENT_BLOCKED · ATTACHMENT_QUARANTINED · MESSAGE_NOT_AVAILABLE · other hard-safety codes | Per-attachment or message-state safety gate. |
| 404 | NOT_FOUND | Unknown message, unbound-agent mask, or index out of range. |
| 404 | ATTACHMENT_NOT_STORED | The raw bytes were never individually stored, or have been discarded under retention — authoritative even if a stale storage key lingers. |
See /agents/errors for the full catalog and remediation.
Sending attachments
Outbound attachments use a stage-then-reference flow. Bytes are provider-managed
encrypted at rest and TLS-protected in transit, and the platform scans attachment
content — so this is not end-to-end / zero-access encryption. A handle expires
24 hours after upload, is consumed once at send, and is scoped to a single
mailbox (a handle referenced from a different mailbox resolves as
404 ATTACHMENT_NOT_FOUND).
Per-message caps: 10 MB per file, 10 attachments per message, 15 MB total raw bytes per message, and filenames ≤ 255 printable-ASCII characters (no path separators, quotes, or control characters).
Prerequisite: enablement (dashboard-managed)
Outbound attachments are enabled per mailbox by a human account owner in the
dashboard, which requires a session plus fresh TOTP/password re-auth and the Pro+
outbound-attachments capability. A Bearer API key cannot enable it — the enablement
route returns 403 REAUTH_REQUIRES_SESSION. Once a mailbox is enabled, agent and
admin keys stage and send handles normally.
Classification: mutating (dashboard-managed; not an agent-API operation)
POST /v1/attachments
Stage one attachment and return a single-use handle.
Classification: mutating
Auth: Bearer (admin or mailbox-scoped agent) or session.
Request: multipart/form-data with exactly two parts —
- a text field named
mailbox_id(the mailbox to scope the handle to; name or UUID), and - a single file part (the attachment bytes).
A second file part is rejected with 400 ATTACHMENT_MULTIPLE_FILES.
Response (201):
{
"id": "uuid",
"filename": "invoice.pdf",
"content_type": "application/pdf",
"size": 18234,
"hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"scan": null,
"content_scan_status": "pending"
}idis the handle — place it in theattachment_idsarray on a send / reply / draft.content_typeis the server-sniffed type (magic-byte), not the client MIME.hashis the SHA-256 hex of the bytes.scanisnullon a clean upload (as shown); it is a populated scan summary only when the synchronous filename scan found something.content_scan_statusispending | clean | flagged | errorand is alwayspendingat 201 — the deep content scan runs asynchronously. PollGET /v1/attachments/:idfor the terminal status, or just send (the resolver enforces scan state at send time).
Errors:
| HTTP | code |
|---|---|
| 400 | VALIDATION_ERROR (missing mailbox_id or file part) |
| 400 | ATTACHMENT_TOO_LARGE (over the 10 MB per-file cap) |
| 400 | ATTACHMENT_MULTIPLE_FILES |
| 400 | OUTBOUND_ATTACHMENT_TYPE_NOT_ALLOWED (sniffed family not in the outbound allowlist) |
| 400 | OUTBOUND_ATTACHMENT_FILENAME_INVALID |
| 400 | ATTACHMENT_BLOCKED (type disallowed by policy) |
| 400 | ATTACHMENT_INFECTED · ATTACHMENT_AV_ERROR (AV scan) |
| 400 | OUTBOUND_IMAGE_DISCLAIMER_REQUIRED (image staged without image-risk acceptance; details.required_version) |
| 403 | OUTBOUND_ATTACHMENTS_DISABLED (mailbox not enabled) |
| 403 | TIER_LIMIT (account lacks the outbound-attachments capability) |
| 404 | NOT_FOUND (mailbox not found, or agent key not bound to it) |
| 429 | OUTBOUND_ATTACHMENT_STAGING_QUOTA_EXCEEDED (too many un-consumed staged handles) |
A filename or content secret/PII hit is a finding, not a 4xx — it flows into
the message verdict at send time. See /agents/errors.
GET /v1/attachments/:id
Fetch the status of a staged handle. Returns one of two shapes, discriminated by
the presence of status.
Classification: read-only
Auth: same as upload.
Response (200) — active handle (no status field):
{
"id": "uuid",
"filename": "invoice.pdf",
"content_type": "application/pdf",
"size": 18234,
"hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"scan": { "verdict": "warning", "categories": [{ "category": "secret_detected", "decision": "allow_with_warning" }], "findings": [] },
"content_scan_status": "flagged"
}scan (a scan summary) is present only when a finding exists; its verdict channel
(clean / warning / review_required / blocked / quarantined) is distinct
from the content_scan_status channel (pending / clean / flagged / error).
Response (200) — consumed handle (has status):
{
"id": "uuid",
"status": "consumed",
"consumed_at": "2026-06-01T00:00:05.000Z",
"consumed_message_id": "uuid"
}Errors: 404 NOT_FOUND — unknown handle, or a handle scoped to a mailbox the
caller cannot see.
DELETE /v1/attachments/:id
Delete an un-consumed staged handle.
Classification: mutating
Auth: same as upload.
Response (204): no body.
Errors:
| HTTP | code |
|---|---|
| 404 | NOT_FOUND (unknown handle) |
| 409 | ATTACHMENT_ALREADY_CONSUMED (already spent by a send — cannot be deleted) |
Referencing a handle on a send
Pass handle ids in an attachment_ids array (UUIDs, uniqueItems) on
POST /v1/messages/send, POST /v1/messages/:id/reply, or a draft; each handle is
consumed once at dispatch, attachment sends are synchronous only (async and
scheduled combinations are refused with 400 ATTACHMENTS_REQUIRE_SYNC_SEND), and a
flagged attachment folds into the message verdict rather than returning a 4xx.
The full consume-once lifecycle and its resolver failure modes
(404 ATTACHMENT_NOT_FOUND, 409 ATTACHMENT_ALREADY_CONSUMED,
409 ATTACHMENT_SCAN_PENDING) are the machine contract at
/agents/attachments; the send routes themselves are
documented at /docs/api/messages.
Classification: send-triggering