Email HTTP API
The four endpoints your site calls to send email, read a sent email back, list its history and check its allowance.
On this page
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 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.
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 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:
{
"success": true,
"message": "Email sent.",
"data": { "id": "66f7c2a14d2b8e0012ab3501", "messageId": "010001926d0c3a1b-4f2e8c1d-000000", "accepted": ["[email protected]"], "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.
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 <[email protected]>"],"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 |
headers |
object | Optional, at most 10. See Headers |
tags |
array | Optional, at most 10. See Tags |
idempotencyKey |
string | Optional, 1 to 256 printable ASCII characters. See 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 [email protected], see Use your own domain. There is no field to change it for one message. While your email domains are paused, 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:
{
"to": ["Ana Silva <[email protected]>"],
"cc": [],
"bcc": ["[email protected]"],
"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": "[email protected]",
"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:
{
"success": true,
"message": "Email sent.",
"data": {
"id": "66f7c2a14d2b8e0012ab3501",
"messageId": "010001926d0c3a1b-4f2e8c1d-000000",
"accepted": ["[email protected]", "[email protected]"],
"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.
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:
<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, 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:
200with "This email was already sent with the same idempotency key.", its originalidandduplicate: true. Nothing is sent and no allowance is used. - Same key, different email.
409with "This idempotency key was already used for a different email!", codeidempotency-conflictand the earlier email'sid. - Same key, first send still going out.
409with "An email with this idempotency key is still being sent!", codeidempotency-pendingand that email'sid. 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.
{
"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. 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.
Read one message this site sent, in full. The body is { id }, the id a send answered with or an item from /emails.
curl -X POST "$EMAIL_URL/email" \
-H "X-Email-Key: $EMAIL_PRIVATE_KEY" \
-H "Content-Type: application/json" \
-d '{"id":"66f7c2a14d2b8e0012ab3501"}'{
"success": true,
"message": "Email loaded.",
"data": {
"id": "66f7c2a14d2b8e0012ab3501",
"messageId": "010001926d0c3a1b-4f2e8c1d-000000",
"from": "[email protected]",
"to": ["[email protected]"],
"cc": [],
"bcc": ["[email protected]"],
"replyTo": ["[email protected]"],
"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": "[email protected]", "kind": "to", "status": "delivered", "detail": null, "updatedAt": "2026-09-28T09:12:05.090Z" },
{ "email": "[email protected]", "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": "[email protected]", "at": "2026-09-28T09:12:05.090Z", "detail": null },
{ "type": "bounced", "recipient": "[email protected]", "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 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.
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 |
{
"success": true,
"message": "Emails listed.",
"data": {
"items": [
{
"id": "66f7b91e4d2b8e0012ab34f7",
"messageId": "010001926cf1e2a4-7d3b9a0e-000000",
"from": "[email protected]",
"to": ["[email protected]"],
"cc": [],
"bcc": [],
"replyTo": ["[email protected]"],
"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.
curl -X POST "$EMAIL_URL/quota" \
-H "X-Email-Key: $EMAIL_PRIVATE_KEY" \
-H "Content-Type: application/json" \
-d '{}'{
"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": "[email protected]",
"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 covers the tab, the sending address, the blocked addresses and the allowance.
- Secrets explains the managed
EMAIL_URLandEMAIL_PRIVATE_KEYvalues. - Storage HTTP API is the other endpoint set your site calls at runtime.