Docs
Send and receive files
An attachment belongs to one inbox in one project, and it must clear storage, type, size and safety checks before it can be sent or downloaded. Uploading a file does not make it an available attachment — that is the whole design.
Files are the part of the agent email API where "it worked in testing" diverges most from production, because a scan takes time and an agent will happily send before it finishes. The flow below makes that impossible rather than unlikely.
The model in one paragraph
An upload is reserved against an inbox before any bytes move, which is what lets storage and quota be checked up front. The bytes are then streamed to a route that only accepts them into that reservation. Completing the upload verifies the length, an optional checksum and the detected media type, and queues a scan. Only after the scan returns clean can the attachment ID be used on a message. Every step is addressed by ID; there are no pre-signed URLs and no public object links anywhere in the system.
Limits
| Limit | Value | Error when exceeded |
|---|---|---|
| Bytes per file | 10 MiB | payload_too_large (413) |
| Files per message | 10, and they must be distinct | invalid_request (400) |
| Aggregate bytes per message | 15 MiB | payload_too_large (413) |
| Filename | 255 characters on input; stored safe name capped at 180 | invalid_request (400) |
| Reserved upload lifetime | 24 hours | The reservation is reaped and the bytes released |
| Test-mode storage per workspace | 10 MiB, shared with message content | test_limit_exceeded (403) |
| Free live storage per workspace | 100 MiB | payment_required or quota_exceeded (402) |
| Inbound raw MIME accepted | 25 MiB for the whole message | The message is not ingested |
The combined encoded message must also fit within the message limits, so several individually valid files can still be too large together. Reserving counts the declared size against your storage immediately; completing the upload with fewer bytes than declared releases the difference.
Sending a file from the console
- Open Compose in the intended inbox and select your files.
- Wait for upload completion, then use Check scan status if a file is still pending.
- Review recipients, content and files together before submitting.
- In received conversations, download only files marked available. Pending, blocked and quarantined files cannot be downloaded.
Sending a file over the API
1. Reserve the upload
Requires attachments:write and access to the target inbox. The inbox must be
active and in this project, or you get not_found.
POST /v1/projects/{project_id}/uploads
{
"inbox_id": "ibx_8e7f6a5b-...",
"filename": "receipt.pdf",
"declared_bytes": 12345,
"media_type": "application/pdf"
} HTTP/1.1 201 Created
{
"id": "upl_1c9d...",
"attachment_id": "att_1c9d...",
"upload_url": "/v1/projects/prj_.../uploads/upl_1c9d.../content",
"expires_at": "2026-09-22T09:14:02.118Z",
"scan_status": "pending",
"status": "staging",
"bytes": 12345,
"filename": "receipt.pdf"
}
Declare the byte count accurately: it is a reservation, and the upload route refuses anything
larger. Note that the response already contains the att_ ID you will eventually
put on the message — it is not usable yet.
2. Stream the bytes, then complete
PUT /v1/projects/{project_id}/uploads/{upload_id}/content
Authorization: Bearer YOUR_API_KEY
Content-Type: application/pdf
<raw bytes>
POST /v1/projects/{project_id}/uploads/{upload_id}/complete
{ "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" }
Both calls are authenticated. Completion is where the file is actually checked, and it can
fail for four distinct reasons, all 400 except the size one:
- No content, or zero bytes. The
PUTnever arrived. - Stored size exceeds the reservation —
payload_too_large. - The
sha256you supplied does not match the streamed bytes. Supplying it is optional and worth doing: it turns a silent truncation into an error. - The declared media type is incompatible with the detected one (below).
3. Wait for a clean scan
Completion sets the attachment to ready with scan_status: "pending"
and queues an isolated scan. Poll
GET /v1/projects/{project_id}/uploads/{upload_id} until
scan_status settles. There is no callback for this today.
4. Attach the ID to a message
POST /v1/projects/{project_id}/inboxes/{inbox_id}/messages
Idempotency-Key: 6f2b...
{
"to": [{ "email": "you@example.com" }],
"subject": "Your receipt",
"text": "Attached.",
"attachment_ids": ["att_1c9d..."]
}
Including attachment_ids also requires attachments:read. Each ID must
belong to the sending inbox — knowing an ID from another inbox is not enough to attach it. At
send time the filename, size, checksum and content type are snapshotted onto the message, and
the send job re-reads the bytes and re-verifies size and digest before submitting. If the
stored content has changed underneath, the send fails rather than transmitting something other
than what was authorised.
Do not resubmit a message with different attachments under an existing idempotency
key. Removing or swapping a file changes the canonical payload, so the key no
longer matches and you will get idempotency_conflict. That is correct: you are
authorising a different message. Mint a new key.
Status and scan status
Two independent fields, and both must be right before a file moves anywhere.
status | Meaning |
|---|---|
staging | Reserved, awaiting bytes or completion. Expires after 24 hours. |
ready | Bytes verified and stored. Still not sendable until the scan is clean. |
rejected | The scan quarantined or blocked it. Terminal. |
purged | The content has been removed. Terminal. |
scan_status | Meaning | Sendable / downloadable |
|---|---|---|
pending | Queued or in progress. | No |
clean | The scanner returned clean. | Yes |
quarantined | The scanner identified it as malicious. | No, permanently |
blocked | Refused without a verdict — for example, larger than the scanner will accept. | No |
scan_failed | The scan could not complete: the stored bytes were missing or had changed, or the file sat unscanned past its half-hour window. | No |
Note that scan_failed is not a soft pass. If scanning is unavailable, the
file stays unavailable. Never treat a timeout as a clean result; the API will not,
and a send referencing that attachment is refused with attachment_not_ready
(HTTP 422, non-retryable).
The scanner is a signature-based engine running in isolation from the API, and it is exercised
end to end in test mode: upload a file containing the standard EICAR antivirus test string and
it comes back quarantined, which is a safe way to prove your error handling works
without going anywhere near real malware.
Filenames and media types
The declared media type is checked against one detected from the leading bytes — PNG, JPEG, GIF, PDF and ZIP signatures, plus an HTML sniff over the first 256 characters. The rule is deliberately narrow:
application/octet-streamas the declared type is always accepted.- An exact match is accepted.
- Any
text/*declared against anytext/*detected is accepted. -
Everything else is rejected. Declaring
image/pngand uploading an HTML document fails at completion, which is the point: the stored type is what a recipient's client will be told.
Filenames are sanitised, not rejected. Path separators and control characters become
underscores, .. sequences are neutralised, leading dots are replaced and the
result is capped at 180 characters; an empty result becomes attachment. The safe
name is what is stored, what is sent, and what comes back in filename — so check
the response if the exact name matters to you.
Downloading a received file
Message metadata lists each attachment with its ID, safe filename, size, content type, scan
status and a downloadable flag that is true only for a clean scan. Inline images
additionally carry a disposition and a content ID so they can be matched to
cid: references in the sanitised HTML body.
GET /v1/projects/{project_id}/attachments/{attachment_id}/download Four things happen on that request:
- Authorisation.
attachments:read, and the attachment's inbox must be inside your grant. - Scan gate. Anything other than
clean, or a status ofrejectedorpurged, returnsattachment_not_ready. - Integrity re-check. The stored bytes are re-hashed and compared with the digest recorded at completion. A mismatch is refused, not served.
- Safe headers. The response carries
content-disposition: attachmentwith the safe filename andx-content-type-options: nosniff, so a browser downloads it rather than rendering it.
There is no public object-store URL to leak, and no unauthenticated variant of this route.
What a clean scan does and does not mean
A clean scan means a signature-based engine recognised nothing known-malicious in those bytes at that moment. It does not mean:
- that the file is safe to open automatically. Novel malware has no signature yet, by definition.
- that instructions inside the document are trustworthy. A PDF or spreadsheet that tells your agent to email its contents somewhere is untrusted input, exactly like the email body that carried it. Nothing here makes an agent resistant to that; apply your own permissions before acting, and keep recipient controls and human approval in front of anything consequential.
- that the sender is who they claim to be. Authentication of the sending domain is a separate question — see custom domains and DNS.