Attachments
Inline base64 versus upload-once R2 keys — which to use, the 10 MB ceiling, and why a download returns 404 instead of 403.
A message carries up to 10 attachments. Each needs a filename and a
content_type, plus the bytes — supplied one of two ways.
Inline: base64 content
Simplest, and right for a one-off file you already have in memory. Max 10 MB each.
"attachments": [
{
"filename": "invoice.pdf",
"content_type": "application/pdf",
"content": "JVBERi0xLjQK..."
}
]The bytes travel with every send, so a file you attach to a thousand messages is uploaded a thousand times.
Upload once: r2_key
Better for a file you will attach repeatedly, or one large enough that you
don't want it in every request body. POST /v1/attachments is a
multipart/form-data upload:
curl https://api.emitd.com/v1/attachments \
-H "Authorization: Bearer $EMITD_API_KEY" \
-F "file=@invoice.pdf" \
-F "message_id=order-4021"It returns an r2_key you reference instead of the bytes:
"attachments": [
{
"filename": "invoice.pdf",
"content_type": "application/pdf",
"r2_key": "ten_8f2a/order-4021/invoice.pdf"
}
]The key is {tenant_id}/{message_id}/{filename}. message_id is just a
grouping value you choose — it does not have to be a real message id, and
it certainly doesn't have to exist yet. Omit it and a random uuid is used.
Pick something meaningful to you (an order number, a tenant-side record id)
and the resulting keys stay browsable.
A file over 10 MB returns 413 with
{ "error": "file_too_large", "max_bytes": 10485760 }. A multipart body with
no file field returns 400 with { "error": "missing file" }.
Downloading
GET /attachments/{key} serves the file. Note it is not under /v1 — it
is a tenant-scoped convenience endpoint, not a public CDN:
curl -O https://api.emitd.com/attachments/ten_8f2a/order-4021/invoice.pdf \
-H "Authorization: Bearer $EMITD_API_KEY"A key belonging to another tenant returns 404, never 403
The endpoint requires an API key whose tenant matches the {tenant_id}/
prefix on the object key. A tenant mismatch returns 404 —
deliberately not 403, so a leaked or guessed key cannot even be used to
confirm that another tenant's attachment exists. Don't read a 404 here as
proof the file is gone; it may simply not be yours.
A missing or invalid API key is a separate case and returns 401,
before the tenant check runs at all.
Responses are forced to download rather than rendered inline
(Content-Disposition: attachment, X-Content-Type-Options: nosniff) and
are privately cached for five minutes.
Which to use
Inline content | Uploaded r2_key | |
|---|---|---|
| Setup | none | one extra request |
| Re-sending the same file | re-uploads every time | upload once, reference many |
| Request body size | grows with the file | stays small |
| Best for | one-off, small | reused, or large |
Both paths cap at 10 MB per file and 10 attachments per message. See
Send an email for where attachments sits in the
request body.