---
title: "Attachments"
description: "Attach files to a send with base64 content, embed inline images with cid, and stay inside the 40 MiB message ceiling."
---

> **For AI agents:** the complete documentation index is at [llms.txt](/docs/llms.txt). Append `.md` to any page URL for its markdown version.

Any send can carry an `attachments` array. Each entry is a file: a name, the bytes as base64, and optionally a MIME type and a Content-ID. A message with at least one attachment is assembled as a full MIME message rather than the plain subject-and-body form.

## The attachment object

| Field | Type | Required | Behavior |
|---|---|---|---|
| `filename` | `string` | yes | The name the recipient sees. |
| `content` | `string` | yes | The file's bytes, base64-encoded. Bare base64 only, with no `data:` URI prefix. |
| `contentType` | `string` | no | MIME type, for example `application/pdf`. Omit it and the type is derived from `filename`. |
| `cid` | `string` | no | Content-ID. Setting it makes the part inline rather than a listed attachment. See [Inline images](#inline-images). |

`contentType` and `cid` are camelCase, unlike the snake_case fields elsewhere in the send body. Unknown keys are ignored rather than rejected, so a `content_type` spelled the snake_case way is not read and the type falls back to whatever `filename` implies.

## Attach a file

<Tabs>
<Tab title="curl">

```bash
curl -X POST https://api.mailfully.com/v1/emails \
  -H "Authorization: Bearer mf_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "billing@mail.acme.com",
    "to": "you@yourcompany.com",
    "subject": "Invoice 4212",
    "text": "Invoice 4212 is attached.",
    "attachments": [
      {
        "filename": "invoice-4212.pdf",
        "content": "JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9UeXBlL...",
        "contentType": "application/pdf"
      }
    ]
  }'
```

</Tab>
<Tab title="Node SDK">

```typescript
import { readFile } from "node:fs/promises";

const pdf = await readFile("invoice-4212.pdf");

const { data, error } = await mailfully.emails.send({
  from: "billing@mail.acme.com",
  to: "you@yourcompany.com", // replace with an address you own
  subject: "Invoice 4212",
  text: "Invoice 4212 is attached.",
  attachments: [
    {
      filename: "invoice-4212.pdf",
      content: pdf.toString("base64"),
      contentType: "application/pdf",
    },
  ],
});
```

</Tab>
</Tabs>

The response is the standard `202` acceptance. Attachments are not echoed back, the same way `html` and `text` are not.

In the SDK, `attachments` is typed `unknown[]` and sent as-is, so TypeScript will not catch a misspelled `filename`. The API will: a bad entry comes back as a `422` naming it by index.

## Inline images

Give an attachment a `cid` and it becomes part of the message body rather than a file listed at the bottom. Reference it from your HTML with `cid:` and the same value:

```json
{
  "from": "news@mail.acme.com",
  "to": "you@yourcompany.com",
  "subject": "This month at Acme",
  "html": "<p>Hello.</p><img src=\"cid:header-logo\" alt=\"Acme\">",
  "attachments": [
    {
      "filename": "logo.png",
      "content": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAA...",
      "contentType": "image/png",
      "cid": "header-logo"
    }
  ]
}
```

Pick a `cid` that is unique within the message. Whether the image renders is up to the receiving client, and many hold images until the reader allows them, so keep the `alt` text meaningful and never put content only in an image.

## Size limit

The ceiling is 40 MiB (41,943,040 bytes) of base64, totaled across every attachment on the message. Base64 inflates bytes by about a third, so that budget is roughly 30 MiB of original file.

The limit is per message, not per file. A [batch](/guides/batch-sending) of 100 gets 40 MiB each, not 40 MiB between them.

Going over returns a `422` on the send, with `param` set to `attachments`.

<Note>
  This changed. The size limit used to be enforced only at send time, so an oversized attachment returned a `202` and then failed later with no response to point at. It is now a `422` on the original request.
</Note>

## Validation

Attachments are validated when the request is received. A malformed attachment returns a `422` with the standard [error envelope](/concepts/errors), and `param` names the offending entry by index. Nothing is silently dropped.

| Rejected | `param` |
|---|---|
| `filename` missing, not a string, or empty | `attachments.<i>.filename` |
| `content` missing, not a string, or empty | `attachments.<i>.content` |
| `content` carries a `data:` URI prefix | `attachments.<i>.content` |
| `content` is not base64 | `attachments.<i>.content` |
| Total base64 across all attachments exceeds 41,943,040 bytes | `attachments` |

So a bad `content` on the first attachment reports as `attachments.0.content`.

Line-wrapped base64 and the URL-safe base64 alphabet (`-` and `_`) are both accepted. Unknown keys on an attachment object are ignored rather than rejected.

## Attachments elsewhere

- **Batch**: [`POST /v1/emails/batch`](/api-reference/emails/send-batch) takes the same object per entry, with the same rules.
- **Templates**: a [template](/guides/templates) supplies `html` and `text`; attachments stay on the send. Templates cannot carry them.
- **Test mode**: attachments are assembled exactly as in live mode. Only recipients are rewritten. See [Test mode](/guides/test-mode).
- **Scheduled sends**: attachments are validated when you schedule the send, not when it dispatches. See [Scheduled sends](/guides/scheduled-sends).

## Related

- The `attachments` field on [`POST /v1/emails`](/api-reference/emails/send)
- [How sending works](/concepts/how-sending-works) for the full lifecycle and what `202` covers
- [Errors](/concepts/errors) for the error envelope
