Guides

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 contentUploaded r2_key
Setupnoneone extra request
Re-sending the same filere-uploads every timeupload once, reference many
Request body sizegrows with the filestays small
Best forone-off, smallreused, 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.

On this page