# Send large attachments

Source: https://developer.nylas.com/docs/v3/email/send-large-attachments/

The standard [Send Message endpoint](/docs/reference/api/messages/send-message/) accepts attachments as base64-encoded content inside the JSON body, which caps the entire request at **3 MB** (or 25 MB using [multipart form data](/docs/v3/email/attachments/#multipart-schema)). For files larger than that, use the **attachment-uploads API** to upload the file directly to Nylas-managed storage, then reference it by ID when you send the message. This flow supports attachments up to **150 MB**.

> **Info:** 
> **Beta feature.** The attachment-uploads API is currently in beta. The behavior described here is stable, but the API may evolve before general availability.

**Supported providers:** Microsoft (Outlook and Exchange Online via Microsoft Graph).

Google, IMAP, Yahoo, iCloud, and EWS grants are rejected with `401 Unauthorized`. For these providers, use the [standard attachment flow](/docs/v3/email/attachments/) with its 25 MB multipart limit.

## Before you begin

You need the following before sending large attachments:

- A Microsoft grant with at least one of these scopes: `Mail.Send`, `Mail.ReadWrite`, or `Mail.ReadWrite.Shared`. See [Create an Azure app](/docs/provider-guides/microsoft/create-azure-app/) for how to configure the auth app.
- An Exchange Online tenant with message size limits raised to accommodate your largest attachment (see below).

### Raise the Exchange Online message size limit

Exchange Online defaults to a **35 MB** maximum message size. If your attachment plus message overhead exceeds this, the send fails and recipients on Exchange-based mailboxes do not receive the message.

> **Warn:** 
> **These limits are enforced by Microsoft, not Nylas.** Even when the Nylas API accepts the upload, Exchange rejects the outbound message if the mailbox or transport limit is below the total send size.

Raise the limit using Exchange Online PowerShell as an Exchange administrator. Set the value to match your expected maximum attachment size. Changes can take up to 60 minutes to propagate.

**Per-mailbox (recommended for a targeted rollout):**

```powershell
Set-Mailbox -Identity "user@yourdomain.com" -MaxSendSize 150MB -MaxReceiveSize 150MB
```

**Organization-wide:**

```powershell
Set-TransportConfig -MaxSendSize 150MB -MaxReceiveSize 150MB
```

## How it works

Instead of embedding the file in the send request, you:

1. [**Create an upload session**](#step-1-create-an-upload-session). Nylas returns a direct-upload URL.
2. [**Upload the file bytes**](#step-2-upload-the-file). `PUT` the file directly to the returned URL. No Nylas auth header required.
3. [**Complete the session**](#step-3-complete-the-upload-session). Nylas verifies the upload and marks the attachment as ready.
4. [**Send the email**](#step-4-send-the-email). Reference the `attachment_id` in the `attachments` array of your send request.

> **Info:** 
> **Session lifetime is 1 hour.** You must complete steps 2 through 4 within this window. Once the session expires, the uploaded file is deleted from storage. Plan your workflow so the send happens inside the 1-hour window.

## Step 1: Create an upload session

Make a [Create Upload Session request](/docs/reference/api/attachments/create-attachment-upload-session/) to start the flow.

```bash
curl --request POST \
  --url 'https://api.us.nylas.com/v3/grants/<GRANT_ID>/attachment-uploads' \
  --header 'Content-Type: application/json' \
  --header 'Accept: application/json' \
  --header 'Authorization: Bearer <NYLAS_API_KEY_OR_ACCESS_TOKEN>' \
  --data-raw '{
    "filename": "document.pdf",
    "content_type": "application/pdf",
    "size": 1048576
  }'

```

```js [createUpload-Node.js SDK]

import Nylas from "nylas";

const nylas = new Nylas({
  apiKey: "<NYLAS_API_KEY>",
  apiUri: "<NYLAS_API_URI>",
});

async function createUploadSession() {
  try {
    const session = await nylas.attachments.createUploadSession({
      identifier: "<NYLAS_GRANT_ID>",
      requestBody: {
        filename: "quarterly-report.pdf",
        contentType: "application/pdf",
        size: 5242880,
      },
    });

    console.log("Upload session:", session);
  } catch (error) {
    console.error("Error creating upload session:", error);
  }
}

createUploadSession();


```

```python
import requests

api_key = "<NYLAS_API_KEY>"
grant_id = "<NYLAS_GRANT_ID>"
api_uri = "https://api.us.nylas.com"

response = requests.post(
    f"{api_uri}/v3/grants/{grant_id}/attachment-uploads",
    headers={
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
        "Accept": "application/json",
    },
    json={
        "filename": "quarterly-report.pdf",
        "content_type": "application/pdf",
        "size": 5242880,
    },
)
response.raise_for_status()
session = response.json()["data"]
print(session)


```

```json
{
  "request_id": "5fa64c92-e840-4357-86b9-2aa364d35b88",
  "data": {
    "attachment_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "method": "PUT",
    "url": "https://storage.googleapis.com/upload/storage/v1/b/BUCKET/o?uploadType=resumable&upload_id=...",
    "headers": {
      "Content-Type": "application/pdf"
    },
    "expires_at": "2026-04-22T19:00:00Z",
    "max_size": 157286400,
    "size": 5242880,
    "content_type": "application/pdf",
    "filename": "quarterly-report.pdf",
    "grant_id": "abc123"
  }
}
```

| Field | Required | Notes |
| --- | --- | --- |
| `filename` | Yes | Name of the file as it appears in the email. |
| `content_type` | Yes | MIME type (for example, `application/pdf`, `image/png`). Any non-empty string is accepted. |
| `size` | No | Expected file size in bytes. **Recommended.** When provided, Nylas verifies the uploaded bytes match this value during completion. Maximum: `157286400` (150 MB). |

Save the `attachment_id` from the response. You need it for steps 3 and 4.

> **Hint:** 
> **Always set `size`.** If you omit it, Nylas skips the size-match check at completion and accepts any non-zero upload. Providing `size` catches truncated or corrupt uploads before the email is sent.

## Step 2: Upload the file

`PUT` the file bytes to the `url` returned in step 1. This is a Google Cloud Storage resumable upload URL, so **no Nylas authorization header is required** on this request.

Set `Content-Type` to match the value you declared in step 1. A successful upload returns HTTP `200` or `201`.

```bash
curl -X PUT "{url from step 1}" \
  -H "Content-Type: application/pdf" \
  -H "Content-Length: 5242880" \
  --data-binary @quarterly-report.pdf
```

```js [putUpload-Node.js]


const file = await readFile("quarterly-report.pdf");

const response = await fetch(session.url, {
  method: session.method, // "PUT"
  headers: {
    ...session.headers,
    "Content-Length": String(file.length),
  },
  body: file,
});

if (!response.ok) {
  throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
}
```

```python
with open("quarterly-report.pdf", "rb") as f:
    file_bytes = f.read()

response = requests.put(
    session["url"],
    headers={
        **session["headers"],
        "Content-Length": str(len(file_bytes)),
    },
    data=file_bytes,
)
response.raise_for_status()
```

> **Info:** 
> **Use resumable uploads for large files.** The `url` follows Google's [resumable upload protocol](https://cloud.google.com/storage/docs/resumable-uploads). For files over ~10 MB, upload in chunks using `Content-Range` headers so you can resume from the last successful byte if the connection drops. The GCS session URI has its own multi-day expiration that is independent of the Nylas 1-hour window.

## Step 3: Complete the upload session

Once the file is uploaded, notify Nylas so it can verify the upload and mark the attachment ready to send. See the [Complete Upload Session reference](/docs/reference/api/attachments/complete-attachment-upload-session/).

```bash
curl --request POST \
  --url 'https://api.us.nylas.com/v3/grants/<GRANT_ID>/attachment-uploads/<ATTACHMENT_ID>/complete' \
  --header 'Accept: application/json' \
  --header 'Authorization: Bearer <NYLAS_API_KEY_OR_ACCESS_TOKEN>'

```

```js [completeUpload-Node.js SDK]

import Nylas from "nylas";

const nylas = new Nylas({
  apiKey: "<NYLAS_API_KEY>",
  apiUri: "<NYLAS_API_URI>",
});

async function completeUploadSession() {
  try {
    const result = await nylas.attachments.completeUploadSession({
      identifier: "<NYLAS_GRANT_ID>",
      attachmentId: "<ATTACHMENT_ID>",
    });

    console.log("Completed:", result);
  } catch (error) {
    console.error("Error completing upload session:", error);
  }
}

completeUploadSession();


```

```python
import requests

api_key = "<NYLAS_API_KEY>"
grant_id = "<NYLAS_GRANT_ID>"
attachment_id = "<ATTACHMENT_ID>"
api_uri = "https://api.us.nylas.com"

response = requests.post(
    f"{api_uri}/v3/grants/{grant_id}/attachment-uploads/{attachment_id}/complete",
    headers={
        "Authorization": f"Bearer {api_key}",
        "Accept": "application/json",
    },
)
response.raise_for_status()
result = response.json()["data"]
print(result)


```

```json
{
  "request_id": "5fa64c92-e840-4357-86b9-2aa364d35b88",
  "data": {
    "attachment_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "grant_id": "abc123",
    "status": "ready"
  }
}
```

The request body is empty. Once `status` is `ready`, the attachment can be referenced in a send request.

## Step 4: Send the email

Pass the `attachment_id` as `id` inside the `attachments` array of your [Send Message](/docs/reference/api/messages/send-message/) or [Create Draft](/docs/reference/api/drafts/post-draft/) request. Do not include `content` or `content_type`. Nylas fetches the file from storage and attaches it at send time.

```bash
curl --request POST \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{
    "to": [{ "name": "Jane Smith", "email": "jane@example.com" }],
    "subject": "Q1 Report",
    "body": "Please find the report attached.",
    "attachments": [
      { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" }
    ]
  }'
```

```js [sendUpload-Node.js SDK]


const nylas = new Nylas({
  apiKey: "<NYLAS_API_KEY>",
  apiUri: "<NYLAS_API_URI>",
});

const message = await nylas.messages.send({
  identifier: "<NYLAS_GRANT_ID>",
  requestBody: {
    to: [{ name: "Jane Smith", email: "jane@example.com" }],
    subject: "Q1 Report",
    body: "Please find the report attached.",
    // Reference the uploaded attachment by ID. The CreateAttachmentRequest
    // type expects content + filename for inline uploads, but the API also
    // accepts a bare { id } reference for upload-session attachments.
    attachments: [{ id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } as any],
  } as any,
});

console.log(message);
```

```python
from nylas import Client

nylas = Client(
    api_key="<NYLAS_API_KEY>",
)

message = nylas.messages.send(
    "<NYLAS_GRANT_ID>",
    request_body={
        "to": [{"name": "Jane Smith", "email": "jane@example.com"}],
        "subject": "Q1 Report",
        "body": "Please find the report attached.",
        "attachments": [{"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"}],
    },
)

print(message)
```

If the referenced attachment is not `ready` at send time (for example, it is still `uploading`, has `failed`, or has `expired`), the send fails. Nylas deletes the draft in both the synchronous and `use_draft=true` scheduled paths, and the synchronous send returns a `500` to the caller.

## Inline attachments

To embed a large image or file inline in the email body, set `content_id` on the attachment entry and reference it in the HTML body using a `cid:` URI.

```bash
curl --request POST \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{
    "to": [{ "name": "Jane Smith", "email": "jane@example.com" }],
    "subject": "Q1 Report",
    "body": "<p>See chart below:</p><img src=\"cid:chart1\">",
    "attachments": [
      {
        "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
        "content_id": "chart1"
      }
    ]
  }'
```

```js [inlineUpload-Node.js SDK]


const nylas = new Nylas({
  apiKey: "<NYLAS_API_KEY>",
  apiUri: "<NYLAS_API_URI>",
});

await nylas.messages.send({
  identifier: "<NYLAS_GRANT_ID>",
  requestBody: {
    to: [{ name: "Jane Smith", email: "jane@example.com" }],
    subject: "Q1 Report",
    body: '<p>See chart below:</p><img src="cid:chart1">',
    attachments: [
      {
        id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
        contentId: "chart1",
      } as any,
    ],
  } as any,
});
```

```python
from nylas import Client

nylas = Client(api_key="<NYLAS_API_KEY>")

nylas.messages.send(
    "<NYLAS_GRANT_ID>",
    request_body={
        "to": [{"name": "Jane Smith", "email": "jane@example.com"}],
        "subject": "Q1 Report",
        "body": '<p>See chart below:</p><img src="cid:chart1">',
        "attachments": [
            {
                "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
                "content_id": "chart1",
            }
        ],
    },
)
```

Nylas treats the attachment as inline when the `content_id` value appears as `cid:{content_id}` inside the message body. If you include a `content_id` but the body does not reference it, the file is still attached but is displayed as a regular attachment rather than inline. See [Working with inline attachments](/docs/v3/email/attachments/#working-with-inline-attachments) for the full rules.

## Upload session status

An upload session moves through one of the following statuses during its lifecycle.

| Status | Meaning |
| --- | --- |
| `uploading` | Session created; Nylas is waiting for the file upload. |
| `ready` | Upload verified and complete. The attachment can be referenced in a send or draft. |
| `failed` | Upload initiation failed (for example, Nylas could not create the storage URL during session creation). Create a new session. |
| `expired` | Session was not completed within 1 hour. Create a new session. |

The `Create Upload Session` response returns the initial state. The `Complete Upload Session` response returns `ready` once Nylas has verified the upload.

## Errors

All error responses follow the standard Nylas error shape:

```json
{
  "request_id": "5fa64c92-e840-4357-86b9-2aa364d35b88",
  "error": {
    "type": "invalid_request",
    "message": "file_size exceeds maximum allowed size of 157286400 bytes"
  }
}
```

| HTTP status | When it occurs |
| --- | --- |
| `400 Bad Request` | `filename` or `content_type` is missing, `size` is negative or zero, `size` exceeds 150 MB, or the request body is not valid JSON. |
| `401 Unauthorized` | Grant cannot be resolved, the provider is not Microsoft, the grant lacks the required Microsoft scopes, or the `grant_id` in the path does not match the session on the complete endpoint. |
| `404 Not Found` | The `attachment_id` does not exist. |
| `409 Conflict` | `/complete` was called on a session that is already `ready` or has `failed`. |
| `410 Gone` | `/complete` was called on an `expired` session, or the session was already past `expires_at`. |
| `422 Unprocessable Entity` | The file is not in storage, the uploaded size does not match the declared `size`, or no `size` was declared and the object is empty. |
| `500 Internal Server Error` | Storage or database failure. Retry the operation. |

Rate limiting is not enforced at the attachment-uploads service itself. Any `429 Too Many Requests` response is returned by the upstream API gateway. See [Sending errors](/docs/v3/email/sending-errors/) for general troubleshooting.

## Limits and retention

| Parameter | Limit |
| --- | --- |
| Maximum file size | 150 MB (`157286400` bytes) |
| Session expiry | 1 hour from creation |
| Supported providers | Microsoft (Graph) |
| Exchange Online default message size | 35 MB. Raise via PowerShell (see [Before you begin](#before-you-begin)). |
| File retention after `ready` | Until `expires_at` (1 hour after creation). Send before the window closes. |
| Metadata retention | 60 days. The session record is kept for observability after the file is deleted. |

> **Warn:** 
> **Reusing an `attachment_id` across sends is allowed but limited.** As long as the session is `ready` and the file is still in storage, you can reference the same `attachment_id` in multiple sends. Because the file is deleted when `expires_at` passes, you have at most 1 hour from session creation to send. For later sends, upload the file again.

## Related

- [Using the Attachments API](/docs/v3/email/attachments/). Standard attachment flow (up to 3 MB JSON or 25 MB multipart), inline attachments, and downloading attachments.
- [Sending messages with Nylas](/docs/v3/email/send-email/). End-to-end guide to sending and scheduling messages.
- [Sending errors](/docs/v3/email/sending-errors/). Troubleshooting delivery failures.
- [Create an Azure app](/docs/provider-guides/microsoft/create-azure-app/). Set up the Microsoft auth app and scopes needed to authenticate a grant.
- API reference: [Create an upload session](/docs/reference/api/attachments/create-attachment-upload-session/), [Complete upload session](/docs/reference/api/attachments/complete-attachment-upload-session/).