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

LimitValueError when exceeded
Bytes per file10 MiBpayload_too_large (413)
Files per message10, and they must be distinctinvalid_request (400)
Aggregate bytes per message15 MiBpayload_too_large (413)
Filename255 characters on input; stored safe name capped at 180invalid_request (400)
Reserved upload lifetime24 hoursThe reservation is reaped and the bytes released
Test-mode storage per workspace10 MiB, shared with message contenttest_limit_exceeded (403)
Free live storage per workspace100 MiBpayment_required or quota_exceeded (402)
Inbound raw MIME accepted25 MiB for the whole messageThe 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

  1. Open Compose in the intended inbox and select your files.
  2. Wait for upload completion, then use Check scan status if a file is still pending.
  3. Review recipients, content and files together before submitting.
  4. 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 PUT never arrived.
  • Stored size exceeds the reservationpayload_too_large.
  • The sha256 you 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.

statusMeaning
stagingReserved, awaiting bytes or completion. Expires after 24 hours.
readyBytes verified and stored. Still not sendable until the scan is clean.
rejectedThe scan quarantined or blocked it. Terminal.
purgedThe content has been removed. Terminal.
scan_statusMeaningSendable / downloadable
pendingQueued or in progress.No
cleanThe scanner returned clean.Yes
quarantinedThe scanner identified it as malicious.No, permanently
blockedRefused 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-stream as the declared type is always accepted.
  • An exact match is accepted.
  • Any text/* declared against any text/* detected is accepted.
  • Everything else is rejected. Declaring image/png and 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:

  1. Authorisation. attachments:read, and the attachment's inbox must be inside your grant.
  2. Scan gate. Anything other than clean, or a status of rejected or purged, returns attachment_not_ready.
  3. Integrity re-check. The stored bytes are re-hashed and compared with the digest recorded at completion. A mismatch is refused, not served.
  4. Safe headers. The response carries content-disposition: attachment with the safe filename and x-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.