# Email HTTP API

Source: https://modulify.ai/docs/automations/email-http-api

The four endpoints your site calls to send email, read a sent email back, list its history and check its allowance.

Your site's email is a small REST API. Every endpoint is a `POST` with a JSON body and a key header. `/send` hands a message to the mail provider, `/email` reads one message back with every recipient's status, `/emails` lists the history, and `/quota` reports this month's allowance and every limit.

Sites built from the Modulify starter already have a typed server only wrapper over all four at `src/lib/modulify/email`, so you rarely call them by hand. See [Emails](https://modulify.ai/docs/automations/emails) for that helper, the tab and the allowance. This page is for calling the endpoints from outside the site or from another language, and for knowing exactly what the helper sends.

## Authentication

Send the key as an `X-Email-Key` header on every request. The base URL is the value of `EMAIL_URL`, and every path below goes after it.

```bash
curl -X POST "$EMAIL_URL/quota" \
  -H "X-Email-Key: $EMAIL_PRIVATE_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

Once your site's own CDN address is ready, `EMAIL_URL` points at it, such as `https://your-cdn-link/email`, so a send goes to `https://your-cdn-link/email/send`. Until then it holds a direct address that keeps working. A published site keeps the address it was published with until its next publish. The CDN address follows the site's subdomain, so after changing the subdomain, publish the site again to keep its email sending working.

Keys start with `mek_`. Your site reads its key from `process.env.EMAIL_PRIVATE_KEY` and the base URL from `process.env.EMAIL_URL`, both server side only. They are [managed secrets](https://modulify.ai/docs/data/secrets) that Modulify creates on its own the next time the preview starts or the site is published while sending is on, which it is by default, or at once when sending is turned on or the key is shown, copied or rotated. **API access** on the **Settings** tab of the **Emails** tab shows the two values. A missing, malformed or unknown key returns `401` with "A valid email key is required!"

A key belongs to exactly one site, and each endpoint only ever sees that site's email. Rotating the key under **Settings** in the **Emails** tab invalidates the old one immediately, and your published site keeps the old key until you publish again. A project that moves to another workspace gets a new key the same way.

## Response envelope

Every response from Modulify, success or failure, has the same shape:

```json
{
    "success": true,
    "message": "Email sent.",
    "data": { "id": "66f7c2a14d2b8e0012ab3501", "messageId": "010001926d0c3a1b-4f2e8c1d-000000", "accepted": ["ana@example.com"], "suppressed": [], "remaining": 913, "code": null, "reason": null, "source": null, "duplicate": false, "invalid": [] },
    "code": 200
}
```

Treat a call as failed unless the HTTP status is ok **and** `success` is `true`. Read the payload from `data`, and when a send is refused, branch on `data.code` rather than on the message. The envelope also carries `version`, the build of the API, and `/emails` adds `count`, the number of items on the page.

`/send`, `/email` and `/emails` refuse a body that is not a JSON object with `400` and "The request body must be a JSON object!" `/send` needs a body, `/email` and `/emails` accept an empty one, and `/quota` ignores its body altogether. A body over 10 MB on `/send`, or over 256 KB on the other three, answers `413` with "The request body can be at most 10 MB!" or "The request body can be at most 256 KB!"

## Endpoints

| Method | Path | Body | Returns |
|---|---|---|---|
| POST | `/send` | `{ to, cc, bcc, subject, html, text, replyTo, fromName, attachments, headers, tags, idempotencyKey }` | `{ id, messageId, accepted, suppressed, remaining, code, reason, source, duplicate, invalid }` |
| POST | `/email` | `{ id }` | One email in full |
| POST | `/emails` | `{ limit, cursor, status, recipient, engagement }` | `{ items, nextCursor }` |
| POST | `/quota` | `{}` | `{ usage, enabled, available, fromAddress, limits, tracking }` |

### /send

Send one message. `to` and `subject` are required, and so is at least one of `html` and `text`.

```bash
curl -X POST "$EMAIL_URL/send" \
  -H "X-Email-Key: $EMAIL_PRIVATE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1041-receipt" \
  -d '{"to":["Ana Silva <ana@example.com>"],"subject":"Your receipt","html":"<p>Thanks for your order.</p>","text":"Thanks for your order."}'
```

| Field | Type | Rules |
|---|---|---|
| `to` | string or array of strings | Required. At least one valid address must be left once duplicates are removed |
| `cc` | string or array of strings | Optional. Counts towards the same 50 recipients |
| `bcc` | string or array of strings | Optional. Counts towards the same 50 recipients |
| `subject` | string | Required. At most 300 characters after trimming, with no line breaks or control characters |
| `html` | string | At most 400,000 characters, and no `<script>` tag |
| `text` | string | At most 400,000 characters |
| `replyTo` | string or array of strings | Optional, at most 10 addresses. Without it the site's **Reply-to address** setting is used |
| `fromName` | string | Optional, at most 64 characters, no control characters. Replaces the site's **Sender name** for this message only |
| `attachments` | array | Optional, at most 10. See [Attachments](https://modulify.ai/docs/automations/email-http-api#attachments) |
| `headers` | object | Optional, at most 10. See [Headers](https://modulify.ai/docs/automations/email-http-api#headers) |
| `tags` | array | Optional, at most 10. See [Tags](https://modulify.ai/docs/automations/email-http-api#tags) |
| `idempotencyKey` | string | Optional, 1 to 256 printable ASCII characters. See [Idempotency](https://modulify.ai/docs/automations/email-http-api#idempotency) |

The From address is always the site's sending address, exactly as **Sends as** in the **Emails** tab and `fromAddress` in `/quota` show it. That is `<part in front of the @>@prj-xxxxxxxxxxxxxx.modulify.website` on the site's built-in domain, or the same part at a domain of your own once it is verified and chosen in the **Emails** tab, such as `hello@example.com`, see [Use your own domain](https://modulify.ai/docs/automations/emails#use-your-own-domain). There is no field to change it for one message. While your email domains are [paused](https://modulify.ai/docs/automations/emails#when-your-plan-ends), it is the built-in address again. When the mail provider refuses a message because that domain is no longer verified, it is sent again from the built-in address within the same call, and `from` in `/email` shows the address it went out from.

The full body:

```json
{
    "to": ["Ana Silva <ana@example.com>"],
    "cc": [],
    "bcc": ["orders@example.com"],
    "subject": "Your receipt",
    "html": "<p>Thanks for your order.</p><p><img src=\"cid:logo\" alt=\"Pottery Studio\"></p>",
    "text": "Thanks for your order.",
    "replyTo": "hello@example.com",
    "fromName": "Pottery Studio",
    "attachments": [
        { "filename": "receipt.pdf", "content": "JVBERi0xLjQKJ...", "contentType": "application/pdf" },
        { "filename": "logo.png", "content": "iVBORw0KGgoAAAANSUhEUg...", "contentType": "image/png", "contentId": "logo" }
    ],
    "headers": { "X-Order-Id": "1042" },
    "tags": [{ "name": "type", "value": "receipt" }],
    "idempotencyKey": "order-1042-receipt"
}
```

The base64 in each `content` is shortened here. Send the whole file.

A successful send:

```json
{
    "success": true,
    "message": "Email sent.",
    "data": {
        "id": "66f7c2a14d2b8e0012ab3501",
        "messageId": "010001926d0c3a1b-4f2e8c1d-000000",
        "accepted": ["ana@example.com", "orders@example.com"],
        "suppressed": [],
        "remaining": 913,
        "code": null,
        "reason": null,
        "source": null,
        "duplicate": false,
        "invalid": []
    },
    "code": 200
}
```

`id` is the email's id in the site's history, the one `/email` reads back, and `messageId` is the mail provider's own id for the message. `accepted` lists the addresses it actually went to, in lowercase, `suppressed` lists the ones skipped because they are on this site's blocked list, and `remaining` is how many sends are left in the allowance after this one. Every address in `accepted` counts as one send. `duplicate` is `true` when an earlier send with the same idempotency key was replayed instead, see [Idempotency](https://modulify.ai/docs/automations/email-http-api#idempotency).

A `200` means the provider took the message, not that it arrived. Delivery, a bounce or a spam report arrives afterwards, recipient by recipient, so read `/email` rather than treating the answer as proof.

#### Addresses

Every address in `to`, `cc`, `bcc` and `replyTo` can be bare, `Name <address>` or `"Name" <address>`. A name can use any language, up to 128 characters, and is encoded for the mail header for you. The address itself must be plain ASCII and at most 254 characters, with at most 64 in front of the `@`, no dot at either end of that part and no two dots in a row, and a real domain after the `@`. A domain in another script must be written in its `xn--` form, such as `xn--p1ai`, because a Unicode domain is refused.

One invalid address anywhere refuses the whole message. The answer is `400` with "Some addresses are not valid email addresses, so nothing was sent!", or "The reply-to address is not a valid email address!" when only `replyTo` is wrong, and `data.invalid` lists every address that failed. Nothing is ever dropped quietly.

Addresses are compared without regard to case. One that appears in more than one list goes out once: `to` wins over `cc`, and `cc` over `bcc`. What is left must hold at least one `to` address and at most 50 unique recipients. Duplicates are only removed within a first cap on the raw lists: more than twice that many entries across `to`, `cc` and `bcc`, counted before duplicates are removed, answers "An email can go to at most 50 recipients at once!", and more than twice the reply-to limit in `replyTo` answers "An email can carry at most 10 reply-to addresses!"

#### Attachments

Each attachment is an object.

| Field | Rules |
|---|---|
| `filename` | Required, at most 255 characters, with no slash, backslash, line break or control character |
| `content` | Required. The file as standard base64. Whitespace inside it is ignored |
| `contentType` | Optional, a MIME type such as `application/pdf`. Parameters such as `text/csv; charset=utf-8` are accepted, and only the bare type is sent and kept. Defaults to `application/octet-stream` |
| `contentId` | Optional, at most 255 printable characters without spaces, with or without angle brackets. Reference it from `html` as `cid:` followed by the id |
| `disposition` | Optional, `attachment` or `inline`. Defaults to `inline` when `contentId` is set, `attachment` otherwise |

The files on one message may add up to 7 MB once decoded, and more answers `413` with "Attachments can add up to at most 7 MB!" A broken entry answers `400` with "Every attachment needs a filename without slashes or line breaks and valid base64 content!", a `contentType` that is not a MIME type with "An attachment contentType must be a MIME type such as application/pdf!", and a bad `contentId` or `disposition` with "An attachment contentId must be printable text without spaces, and its disposition must be attachment or inline!"

Some file types are never sent, because the mail provider refuses them. A filename ending in one of these answers `400` with "That attachment type cannot be sent by email!": ade, adp, app, asp, bas, bat, cer, chm, cmd, com, cpl, crt, csh, der, exe, fxp, gadget, hlp, hta, inf, ins, isp, its, js, jse, ksh, lib, lnk, mad, maf, mag, mam, maq, mar, mas, mat, mau, mav, maw, mda, mdb, mde, mdt, mdw, mdz, msc, msh, msh1, msh2, mshxml, msh1xml, msh2xml, msi, msp, mst, ops, pcd, pif, plg, prf, prg, reg, scf, scr, sct, shb, shs, sys, ps1, ps1xml, ps2, ps2xml, psc1, psc2, tmp, url, vb, vbe, vbs, vps, vsmacros, vss, vst, vsw, vxd, ws, wsc, wsf, wsh and xnk.

Only the file name, type and size are kept in the history. The files themselves are never stored.

#### Headers

`headers` is an object of extra header names and values. A name must start with `X-`, followed by up to 75 letters, digits and dashes that begin with a letter or digit, or be `List-Unsubscribe` or `List-Unsubscribe-Post`. Names starting with `X-SES-` are reserved by the mail provider. Each name may appear once, whatever its case, and each value uses printable ASCII characters only, with no line breaks or control characters, at most 995 characters, and at most 996 together with the header name. Anything else answers `400` with "Custom headers must start with X- or be List-Unsubscribe or List-Unsubscribe-Post, with a value of printable ASCII characters only, at most 995 characters and at most 996 together with the header name!" The history keeps the header names, never the values.

#### Tags

`tags` is an array of `{ name, value }` pairs, stored with the message and returned by `/email` and `/emails`, so you can tell your own kinds of email apart. Names and values use only letters, digits, dashes and underscores, up to 256 characters each, and a name may appear once. `project`, `send` and `forward` are reserved and answer `400` with "The tag names project, send and forward are reserved!"

#### Open and click tracking

There is no field for tracking. It is the site's own setting, **Track opens and clicks** in the **Emails** tab, on unless somebody turned it off, and `/quota` reports it in `tracking`. While it is on, the mail provider adds an invisible tracking image to `html` and sends every link in `html` through a tracking address, and `text` is left exactly as sent. Add the `ses:no-track` attribute to a link to leave it out, and do that for any link that carries a token, because the clicked address is stored with the email, query string included:

```html
<a ses:no-track href="https://example.com/reset-password?token=...">Reset your password</a>
```

Tracking never refuses a send. When it is on but not set up on the platform, the email goes out untracked and reads `tracked: false`. It plays no part in [Idempotency](https://modulify.ai/docs/automations/email-http-api#idempotency), so turning it on or off never makes a retry count as a different email.

#### Idempotency

Pass an idempotency key, such as an order or a form submission id, and a retry can never send the same email twice. Send it as `idempotencyKey` in the body or as an `Idempotency-Key` header. Sending both with different values answers `400` with "The Idempotency-Key header and the idempotencyKey field must match!"

The helper in `src/lib/modulify/email` adds a key to every send on its own: yours when you pass `idempotencyKey`, otherwise a random one it makes for that call, so the same request arriving twice still sends one email. A random key protects only that one call, because calling `sendEmail` again makes a new one. To retry a failed send yourself, pass the key the failed call used, which the error carries as `error.idempotencyKey`, or pass your own key and reuse it on every retry.

- **Same key, same email.** The earlier send is replayed: `200` with "This email was already sent with the same idempotency key.", its original `id` and `duplicate: true`. Nothing is sent and no allowance is used.
- **Same key, different email.** `409` with "This idempotency key was already used for a different email!", code `idempotency-conflict` and the earlier email's `id`.
- **Same key, first send still going out.** `409` with "An email with this idempotency key is still being sent!", code `idempotency-pending` and that email's `id`. Try again in a moment. A first send that was interrupted, or that the mail provider never confirmed (`provider-unconfirmed`), stops blocking its key after 15 minutes: it is marked failed and the next call with that key sends the email.

The same email means the same recipients with the same names, the same reply-to addresses, subject, bodies, `fromName`, attachments, headers and tags. A key belongs to one site. It stays taken while the email is in the history and for 30 days after it is removed or the history is cleared, except that a send that never reached the mail provider gives its key back, so retrying it with the same key sends it.

#### Refusals

A refused send carries the same `data` shape with `code` filled in. Only a `401` and an unexpected `500` carry no `data` at all. A `504` has no envelope, because the CDN answers it rather than Modulify.

```json
{
    "success": false,
    "message": "This site sent more than 60 emails in a minute. Wait a minute and send again!",
    "data": { "id": null, "messageId": null, "accepted": [], "suppressed": [], "remaining": null, "code": "rate-limited", "reason": null, "source": null, "duplicate": false, "invalid": [] },
    "code": 429
}
```

`remaining` is `null` whenever the refusal came before the allowance was read.

| Status | `data.code` | Message | Retry |
|---|---|---|---|
| `400` | `invalid` | One of the messages under the table | No, fix the request |
| `400` | `all-suppressed` | `Every recipient on this message is on this site's suppression list, so nothing was sent!` `data.suppressed` names them | No |
| `401` | none | `A valid email key is required!` | No |
| `403` | `disabled` | `Email sending is turned off for this site!` | No |
| `403` | `plan-limit` | `This site has 3 emails left of the 50 emails it can send this month, which is not enough for this email. Sending more requires a paid plan!` | No, wait for the 1st |
| `403` | `no-credits` | `This site has 0 emails left this month, and the workspace does not have the 8 credits needed to unlock another 2,000 emails!` | No, wait until credits are added |
| `409` | `idempotency-conflict` | `This idempotency key was already used for a different email!` | No |
| `409` | `idempotency-pending` | `An email with this idempotency key is still being sent!` | Yes |
| `413` | `too-large` | A body, attachment or request size message | No |
| `422` | `provider-rejected` | `The mail provider rejected this email!` `data.reason` carries the provider's own reason | No |
| `429` | `rate-limited` | `This site sent more than 60 emails in a minute. Wait a minute and send again!` | Yes, after `Retry-After` seconds |
| `502` | `provider-failed` | `The email could not be handed to the mail provider!` | Yes |
| `503` | `unavailable` | `Email sending is not available right now, please try again in a few minutes!` | Yes |
| `503` | `charge-failed` | `The extra emails could not be paid for right now, please try again in a minute!` | Yes |
| `503` | `provider-busy` | `The mail provider is busy right now, please try again in a minute!` | Yes, after `Retry-After` seconds |
| `503` | `provider-paused` | `Sending is paused at the mail provider right now, please try again later!` | Yes |
| `503` | `provider-unconfirmed` | `The mail provider did not confirm this email in time, so it may still be delivered. Retry with the same idempotency key so it is never sent twice!` | Yes, with the same idempotency key, after `Retry-After` seconds |
| `504` | none | None, the CDN answers it: the request timed out between the CDN and Modulify, and the email may already have been sent | Yes, with the same idempotency key |

`provider-unconfirmed` means the request reached the mail provider but no definite answer came back, so the email may still be delivered. A retry with the same idempotency key answers `409` `idempotency-pending` until the provider confirms it, and then `200` with `duplicate: true`. If the provider never confirms it, the email is marked failed after 15 minutes and the next retry with that key sends it. A retry without a key can deliver it twice.

A `504` means the request timed out between the CDN and Modulify, so the email may already have been sent. Retry it with the same `Idempotency-Key`: the retry answers `200` with `duplicate: true` if the first request sent it, `409` `idempotency-pending` while that request is still going out, and sends it if it never reached the mail provider. A retry without a key can deliver it twice.

The numbers in the two allowance messages are the site's own. `plan-limit` is a free workspace without enough of its sends left for the whole message, and `no-credits` a paid one whose workspace cannot pay for the next block of 2,000. Both pause sending for the site until it can send again, and `/quota` then carries `usage.paused`, see [/quota](https://modulify.ai/docs/automations/email-http-api#quota). Nothing is queued, so a refused email is only sent if your code sends it again. When sending is off, `data.source` says who turned it off, `user`, `ai`, `mcp`, `api` or `reputation`, and is `null` when nobody is recorded as turning it off. `data.reason` says why Modulify did, `bounce-rate` or `complaint-rate`, and is `null` otherwise. An unexpected error answers `500` with "Something went wrong while sending the email!" and no `data`.

A message that cannot be sent as written answers `400` with code `invalid` and one of these, and nothing is attempted:

- "Some addresses are not valid email addresses, so nothing was sent!" or "The reply-to address is not a valid email address!", with `data.invalid`
- "At least one valid recipient address is required!"
- "An email can go to at most 50 recipients at once!"
- "An email can carry at most 10 reply-to addresses!"
- "A subject is required!", "The subject can be at most 300 characters!" or "The subject cannot contain line breaks or control characters!"
- "An html or text body is required!", "The html and text bodies must be strings!" or "The html body cannot contain a script tag!"
- "The sender name can be at most 64 characters and cannot contain control characters!"
- "An email can carry at most 10 attachments!", "Every attachment needs a filename without slashes or line breaks and valid base64 content!", "An attachment contentType must be a MIME type such as application/pdf!", "An attachment contentId must be printable text without spaces, and its disposition must be attachment or inline!" or "That attachment type cannot be sent by email!"
- "An email can carry at most 10 custom headers!" or the headers message above
- "An email can carry at most 10 tags!", "Tag names and values can only use letters, digits, dashes and underscores, up to 256 characters!" or "The tag names project, send and forward are reserved!"
- "The idempotency key must be 1 to 256 printable characters!" or "The Idempotency-Key header and the idempotencyKey field must match!"

Nothing is ever cut to fit. A body over 400,000 characters answers `413` with "The html and text bodies can each be at most 400,000 characters!", with code `too-large`, like oversized attachments and an oversized request.

A `403` is worth surfacing to whoever runs the site, since only they can turn sending on, upgrade the workspace or add credits. Every answer marked Yes above can succeed later unchanged, so queue it and try again, with the same idempotency key.

### /email

Read one message this site sent, in full. The body is `{ id }`, the `id` a send answered with or an item from `/emails`.

```bash
curl -X POST "$EMAIL_URL/email" \
  -H "X-Email-Key: $EMAIL_PRIVATE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"id":"66f7c2a14d2b8e0012ab3501"}'
```

```json
{
    "success": true,
    "message": "Email loaded.",
    "data": {
        "id": "66f7c2a14d2b8e0012ab3501",
        "messageId": "010001926d0c3a1b-4f2e8c1d-000000",
        "from": "noreply@prj-xxxxxxxxxxxxxx.modulify.website",
        "to": ["ana@example.com"],
        "cc": [],
        "bcc": ["orders@example.com"],
        "replyTo": ["hello@example.com"],
        "subject": "Your receipt",
        "status": "partial",
        "recipientCount": 2,
        "error": "smtp; 550 5.1.1 user unknown",
        "errorCode": null,
        "test": false,
        "source": "site",
        "tags": [{ "name": "type", "value": "receipt" }],
        "attachmentCount": 2,
        "createdAt": "2026-09-28T09:12:03.114Z",
        "sentAt": "2026-09-28T09:12:03.502Z",
        "deliveredAt": "2026-09-28T09:12:05.090Z",
        "lastEventAt": "2026-09-28T09:12:06.311Z",
        "tracked": true,
        "opens": 2,
        "clicks": 1,
        "firstOpenedAt": "2026-09-28T09:20:41.000Z",
        "firstClickedAt": "2026-09-28T09:21:02.000Z",
        "recipients": [
            { "email": "ana@example.com", "kind": "to", "status": "delivered", "detail": null, "updatedAt": "2026-09-28T09:12:05.090Z" },
            { "email": "orders@example.com", "kind": "bcc", "status": "bounced", "detail": "smtp; 550 5.1.1 user unknown", "updatedAt": "2026-09-28T09:12:06.311Z" }
        ],
        "events": [
            { "type": "sent", "recipient": null, "at": "2026-09-28T09:12:03.502Z", "detail": null },
            { "type": "delivered", "recipient": "ana@example.com", "at": "2026-09-28T09:12:05.090Z", "detail": null },
            { "type": "bounced", "recipient": "orders@example.com", "at": "2026-09-28T09:12:06.311Z", "detail": "smtp; 550 5.1.1 user unknown" },
            { "type": "opened", "recipient": null, "at": "2026-09-28T09:20:41.000Z", "detail": null },
            { "type": "clicked", "recipient": null, "at": "2026-09-28T09:21:02.000Z", "detail": "https://pottery.example.com/orders/1042" }
        ],
        "attachments": [
            { "fileName": "receipt.pdf", "contentType": "application/pdf", "size": 48213 },
            { "fileName": "logo.png", "contentType": "image/png", "size": 5120 }
        ],
        "headerNames": ["X-Order-Id"],
        "html": "<p>Thanks for your order.</p><p><img src=\"cid:logo\" alt=\"Pottery Studio\"></p>",
        "text": "Thanks for your order.",
        "bodyStored": true,
        "bodyTruncated": false,
        "bounceType": "Permanent",
        "bounceSubType": "General",
        "idempotencyKey": "order-1042-receipt",
        "period": "2026-09",
        "lastOpenedAt": "2026-09-28T11:02:17.000Z",
        "lastClickedAt": "2026-09-28T09:21:02.000Z",
        "links": [
            { "url": "https://pottery.example.com/orders/1042", "clicks": 1, "firstClickedAt": "2026-09-28T09:21:02.000Z", "lastClickedAt": "2026-09-28T09:21:02.000Z" }
        ]
    },
    "code": 200
}
```

`status` is the message as a whole: `pending`, `sent`, `delayed`, `delivered`, `partial` (some recipients got it and at least one did not), `bounced`, `complained` (marked as spam), `rejected` or `failed`. Each recipient has its own `status`: `pending`, `sent`, `delivered`, `delayed`, `bounced`, `complained`, `rejected`, `failed` or `suppressed`, the last for an address skipped because it is on this site's blocked list or because the mail provider already refuses mail to it. An address skipped before sending because it is on this site's blocked list does not change the message status, unless every recipient was skipped. `detail` carries the provider's reason, such as the bounce diagnostic. A temporary bounce reads `bounced`, with a `detail` starting "Temporary bounce, so the address was not blocked.", and does not block the address.

`events` is the delivery timeline, oldest first, each event stamped with the time the provider reported it and holding up to the latest 50. `source` is where the email came from: `site`, `dashboard`, `chat`, `mcp` or `api`. `test` is `true` for a test email, and `period` is the month its sends counted in, or `null` for a test email, which is free and counts none.

`html` and `text` hold the first 200,000 characters of each body, and `bodyTruncated` is `true` when a longer one was cut in storage, never in the email itself. `bodyStored` is `false` for an email sent before bodies were kept. Attachments come back as names, types and sizes only.

`tracked` is `true` when the email went out with [open and click tracking](https://modulify.ai/docs/automations/emails#track-opens-and-clicks) on. `opens` and `clicks` count every open and every click on the whole email, with the first and last time of each, and never say which recipient opened or clicked, because one email carries one tracking image and one set of tracked links for all its recipients. Opens are approximate, because some mail apps load images automatically and others block them. `links` lists each clicked link with its own `clicks` and first and last click, most clicked first, at most 50 different links, while clicks on any further link still count in `clicks`. `events` gains an `opened` entry at the first open and a `clicked` entry, with the link as `detail`, at the first click on each link. Opens and clicks never change `status`. An untracked email reads `tracked: false`, zero counts, `null` times and an empty `links`.

An id that is not one of this site's emails, including one removed from the history, answers `404` with "That email was not found!"

### /emails

List the site's emails, newest first, without their bodies.

```bash
curl -X POST "$EMAIL_URL/emails" \
  -H "X-Email-Key: $EMAIL_PRIVATE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"limit":20,"status":"bounced"}'
```

| Field | Rules |
|---|---|
| `limit` | A whole number from 1 to 100, 20 by default |
| `cursor` | The `nextCursor` of the previous page, with the same filters |
| `status` | One message status, from `pending` to `failed` as listed under `/email` |
| `recipient` | A full address matches exactly, in `to`, `cc` or `bcc`. Part of one, such as a domain, matches every address containing it |
| `engagement` | Keeps tracked emails only: `opened` for the ones opened at least once, `clicked` for the ones with at least one click, `not-opened` for the ones delivered to at least one recipient and never opened, even if a spam report or bounce came later. `all`, the default, keeps every email |

```json
{
    "success": true,
    "message": "Emails listed.",
    "data": {
        "items": [
            {
                "id": "66f7b91e4d2b8e0012ab34f7",
                "messageId": "010001926cf1e2a4-7d3b9a0e-000000",
                "from": "noreply@prj-xxxxxxxxxxxxxx.modulify.website",
                "to": ["sam@example.com"],
                "cc": [],
                "bcc": [],
                "replyTo": ["hello@example.com"],
                "subject": "Confirm your booking",
                "status": "bounced",
                "recipientCount": 1,
                "error": "smtp; 550 5.1.1 mailbox does not exist",
                "errorCode": null,
                "test": false,
                "source": "site",
                "tags": [{ "name": "type", "value": "booking" }],
                "attachmentCount": 0,
                "createdAt": "2026-09-28T08:40:11.206Z",
                "sentAt": "2026-09-28T08:40:11.588Z",
                "deliveredAt": null,
                "lastEventAt": "2026-09-28T08:40:13.042Z",
                "tracked": false,
                "opens": 0,
                "clicks": 0,
                "firstOpenedAt": null,
                "firstClickedAt": null
            }
        ],
        "nextCursor": null
    },
    "count": 1,
    "code": 200
}
```

Each item carries the same fields as `/email` from `id` to `firstClickedAt`. Read one with `/email` for its recipients, timeline, clicked links and body. Pass `nextCursor` back as `cursor` for the next page, until it is `null`. `engagement` combines with `status` and `recipient`. A limit outside the range answers `400` with "The limit must be a whole number from 1 to 100!", an unknown status with "That email status is not valid!", an unknown engagement with "The engagement filter must be opened, clicked or not-opened!", and a cursor that is not one this endpoint gave with "That cursor is not valid!"

### /quota

Read this month's allowance, whether sending works, the sending address and every limit, without sending anything.

```bash
curl -X POST "$EMAIL_URL/quota" \
  -H "X-Email-Key: $EMAIL_PRIVATE_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

```json
{
    "success": true,
    "message": "Email allowance loaded.",
    "data": {
        "usage": {
            "period": "2026-09",
            "sent": 87,
            "included": 10000,
            "overageBlocks": 0,
            "purchased": 0,
            "carriedOver": 0,
            "allowed": 10000,
            "remaining": 9913,
            "blockSize": 2000,
            "blockCredits": 8,
            "canBuyMore": true,
            "isPaid": true,
            "resetsAt": "2026-10-01T00:00:00.000Z",
            "paused": null
        },
        "enabled": true,
        "available": true,
        "fromAddress": "noreply@prj-xxxxxxxxxxxxxx.modulify.website",
        "limits": {
            "maxRecipients": 50,
            "perMinute": 60,
            "maxAttachments": 10,
            "maxAttachmentBytes": 7340032,
            "maxSubject": 300,
            "maxBody": 400000,
            "maxTags": 10,
            "maxHeaders": 10
        },
        "tracking": {
            "available": true,
            "enabled": true
        }
    },
    "code": 200
}
```

`sent` counts recipients, not messages, and never a test email. `allowed` is `included` plus `carriedOver` plus `purchased`, and `remaining` is what is left of it. `included` follows the plan: 50 on Free, 10,000 on Starter, 30,000 on Pro and 50,000 on Enterprise, and after a move to a smaller plan it keeps the bigger number until the month ends. `purchased` counts the sends bought this month, in `overageBlocks` blocks, and `carriedOver` the ones bought in an earlier month and not used yet. `canBuyMore` is `true` on a paid workspace, where crossing `allowed` unlocks another `blockSize` sends for `blockCredits` AI credits, 8 on Starter and Enterprise and 4 on Pro, and `false` on Free, where crossing it stops sending until `resetsAt`, the start of next month in UTC.

`paused` is `null` while the site can send. Once a send is refused with `plan-limit` or `no-credits`, it carries `reason`, which is that code, `since`, when the first send was refused, and `refused`, how many sends were refused since then. It goes back to `null` with the next send that goes through, once credits are added or the workspace moves to a paid plan, and at the start of the next month. Read it before a batch to skip sending while the site is paused.

`enabled` is the switch in the **Emails** tab. `available` is `false` while sending is not available on the platform at all, and every send then answers `503`. `fromAddress` is the address every send goes out from right now, on the built-in domain or on one of your verified email domains. `limits` are the numbers this page lists, so read them before a batch instead of hardcoding them. `maxAttachmentBytes` is the decoded total, and `maxBody` applies to `html` and `text` separately.

`tracking.enabled` is the **Track opens and clicks** switch in the **Emails** tab, which is on by default, and `tracking.available` is `false` while open and click tracking is not set up on the platform. Only emails sent while both are `true` are tracked. Nothing on this API changes the switch.

When the allowance cannot be read right now, `/quota` answers `503` with "Email sending is not available right now, please try again in a few minutes!"

## Limits

| Limit | Value |
|---|---|
| Recipients on one message, `to` plus `cc` plus `bcc` | 50 |
| Entries in `to`, `cc` and `bcc` before duplicates are removed | Twice the recipients on one message |
| Reply-to addresses on one message | 10 |
| Entries in `replyTo` before duplicates are removed | Twice the reply-to addresses on one message |
| Sends a minute, per site, counted per recipient | 60 |
| Request body for `/send` | 10 MB |
| Request body for `/email`, `/emails` and `/quota` | 256 KB |
| Subject | 300 characters |
| `html` or `text` body | 400,000 characters each |
| Display name, `fromName` | 64 characters |
| Display name on a recipient | 128 characters |
| Attachments | 10 per message, 7 MB decoded in total |
| Custom headers | 10 per message, printable ASCII, 995 characters per value and 996 for name plus value |
| Tags | 10 per message, 256 characters per name and per value |
| Idempotency key | 256 characters |
| Emails on one `/emails` page | 100 |
| Body kept in the history | 200,000 characters per body |
| Clicked links listed on one tracked email | 50 |
| One clicked link kept | 2,048 characters |
| Included sends per calendar month, Free | 50 |
| Included sends per calendar month, Starter | 10,000 |
| Included sends per calendar month, Pro | 30,000 |
| Included sends per calendar month, Enterprise | 50,000 |
| Extra sends on a paid plan | Blocks of 2,000 for 8 credits on Starter and Enterprise, 4 on Pro |
| History kept | Until you remove an email or clear the history |

A `<script>` tag anywhere in `html` is refused rather than stripped, because no mail client runs it and its presence is what files a message as spam.

## Next

- [Emails](https://modulify.ai/docs/automations/emails) covers the tab, the sending address, the blocked addresses and the allowance.
- [Secrets](https://modulify.ai/docs/data/secrets) explains the managed `EMAIL_URL` and `EMAIL_PRIVATE_KEY` values.
- [Storage HTTP API](https://modulify.ai/docs/data/storage-http-api) is the other endpoint set your site calls at runtime.