Email HTTP API
The endpoints your site calls to send email, one message or in bulk, 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. /bulk-send queues one email to many people, and /bulk-email, /bulk-emails and /bulk-cancel follow and stop it. /unsubscribes lists who unsubscribed through an unsubscribe link.
It sends any mail, from receipts to newsletters and marketing campaigns, and you can call it from anywhere: your site's own code, a server of yours, a scheduled job or any tool that can make an HTTP request. Keep the key on a server, never in a browser or an app.
Sites built from the Modulify starter already have a typed server only wrapper over all of them 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.
Every endpoint but /quota refuses a body that is not a JSON object with 400 and "The request body must be a JSON object!" /send and /bulk-send need a body, /email, /emails, /bulk-email, /bulk-emails, /bulk-cancel and /unsubscribes accept an empty one, and /quota ignores its body altogether. A body that is too large answers 413: over 10 MB on /send with "The request body can be at most 10 MB!", over 10 MB on /bulk-send with "The request body can be at most 10 MB!", both with code too-large, and over 256 KB on the others with "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, unsubscribe } |
{ 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 } |
| POST | /bulk-send |
{ recipients, subject, html, text, replyTo, fromName, headers, tags, unsubscribe, idempotencyKey } |
{ id, status, total, accepted, suppressed, duplicates, invalid, invalidCount, extraBlocks, extraCredits, remaining, code, reason, source, duplicate } |
| POST | /bulk-email |
{ id, status, limit, cursor } |
One bulk email with a page of its recipients |
| POST | /bulk-emails |
{ limit, cursor } |
{ items, nextCursor } |
| POST | /bulk-cancel |
{ id } |
The canceled bulk email |
| POST | /unsubscribes |
{ email, limit, cursor } |
{ items, nextCursor } |
/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 |
unsubscribe |
boolean | Optional. true for a marketing email: it adds an unsubscribe link and the one-click unsubscribe headers, skips an address that unsubscribed, and needs exactly one recipient. See Unsubscribe links |
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 |
403 |
spending-cap |
This site has 0 emails left this month, and the workspace reached its monthly email cap of 40 credits, so it cannot buy another 2,000 emails. The workspace owner can raise it in Settings > Spending, or it resets on 1 Dec 2026! |
No, wait until the owner raises the cap or the month resets |
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 three allowance messages are the site's own. plan-limit is a free workspace without enough of its sends left for the whole message, no-credits a paid one whose workspace cannot pay for the next block of 2,000, and spending-cap a paid one whose workspace reached a monthly spending cap its owner set. All three 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!"
- "unsubscribe must be true or false!", "An unsubscribe link belongs to one person, so send this to one recipient at a time, or use a bulk email!" or "Pass either unsubscribe or your own List-Unsubscribe headers, not both!"
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",
"bulkId": null,
"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. bulkId is the bulk email an email is one person's copy of, or null, and such a copy's html and text are the ones the bulk email was queued with, before that person's details were filled in. /emails carries bulkId on every item too.
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!", a recipient that is not a string with "That is not a valid email address!", 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,
"maxBulkRecipients": 10000,
"bulkPerMinute": 300,
"maxBulkRunning": 5,
"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, no-credits or spending-cap, 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, the workspace moves to a paid plan or the owner raises the cap, 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!"
/bulk-send
Queue one email to many people. recipients and subject are required, and so is at least one of html and text. The answer comes back as soon as it is queued, and Modulify sends it in the background, as one separate email per person. See Send a bulk email for how it behaves.
curl -X POST "$EMAIL_URL/bulk-send" \
-H "X-Email-Key: $EMAIL_PRIVATE_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: newsletter-2026-11" \
-d '{"recipients":["[email protected]",{"to":"Ben Silva <[email protected]>","data":{"name":"Ben","plan":"Pro"}}],"subject":"What is new this month","html":"<p>Hi {{name|there}}.</p>","text":"Hi {{name|there}}."}'| Field | Type | Rules |
|---|---|---|
recipients |
array | Required, at most 10,000. Each entry is an address, bare or as Name <address>, or an object with to and an optional data |
subject |
string | Required. Same rules as /send once it is filled in for each person |
html |
string | Same rules as /send. {{unsubscribeUrl}} marks where the unsubscribe link goes |
text |
string | Same rules as /send |
replyTo |
string or array of strings | Same rules as /send |
fromName |
string | Same rules as /send |
headers |
object | Same rules as /send. Your own List-Unsubscribe headers are refused unless unsubscribe is false |
tags |
array | Same rules as /send, stored on every email |
unsubscribe |
boolean | Optional, true by default. false leaves out the unsubscribe link and headers and also reaches addresses that unsubscribed, for mail people need rather than marketing |
idempotencyKey |
string | Optional, same rules as /send. Also accepted as the Idempotency-Key header |
to, cc, bcc and attachments are refused with "A bulk email takes recipients instead of to, cc and bcc, and cannot carry attachments, so remove to!", naming the field. In subject, html and text, {{email}}, {{name}} and any field of data are filled in for each person, values in html are escaped, and {{name|there}} gives a fallback. data holds text, numbers, true or false, or null, under names that start with a letter, use only letters, digits and underscores and are at most 64 characters long, at most 2 KB for one person, and anything else refuses the whole list. A name or email field in data replaces what {{name}} or {{email}} fills in, and a field with no value and no fallback comes out empty. A person whose filled-in subject comes out empty or too long, or whose body comes out too long, is marked failed, and everyone else still gets the email.
A list that is not valid answers 400 with code invalid and one of "A bulk email needs a recipients list with at least one address!", "A bulk email can go to at most 10,000 recipients. Split the list into several bulk emails!", "None of the recipients on this bulk email has a valid address!", "The data of [email protected] must be an object of short names with text, number or true and false values, at most 2 KB in total!", naming the address, or "unsubscribe must be true or false!", or with one of the /send messages for the subject, the bodies, the headers or the tags.
A queued bulk email:
{
"success": true,
"message": "Bulk email queued.",
"data": {
"id": "66f7c2a14d2b8e0012ab3777",
"status": "sending",
"total": 2,
"accepted": 2,
"suppressed": 0,
"duplicates": 0,
"invalid": [],
"invalidCount": 0,
"extraBlocks": 0,
"extraCredits": 0,
"remaining": 9913,
"code": null,
"reason": null,
"source": null,
"duplicate": false
},
"code": 200
}total is the people on it, accepted those it will try and suppressed those it skips because they are blocked or unsubscribed. duplicates counts repeated addresses, and an address that is not valid is left out, with the first 100 in invalid and all of them counted in invalidCount. remaining is what is left of this month's allowance before this bulk email, and extraBlocks and extraCredits are what it may buy on a paid plan. The same idempotencyKey with the same content answers 200 with "This bulk email was already queued, so it was not queued again.", "duplicate": true and the first bulk email, with remaining set to null and extraBlocks and extraCredits to 0, and with different content 409 and the code idempotency-conflict. A key stays taken while its bulk email is in the history and for 30 days after the history is cleared.
| Status | data.code |
When | Retry |
|---|---|---|---|
400 |
none | The body is not a JSON object, or the Idempotency-Key header and the idempotencyKey field differ |
No |
400 |
invalid |
The email or the list is not valid, or nobody on the list has a valid address, with data.invalid |
No |
400 |
all-suppressed |
Everyone on the list is blocked or unsubscribed | No |
403 |
disabled |
Sending is turned off | No |
403 |
plan-limit |
On Free, the list is longer than the sends left this month | No, wait for the 1st |
409 |
idempotency-conflict |
The key was used for a different bulk email | No |
413 |
too-large |
The body is over 10 MB | No, split the list |
429 |
bulk-busy |
The site already has 5 bulk emails running | Yes, after Retry-After seconds |
429 |
rate-limited |
The site already queued 60 bulk emails this hour | Yes, after Retry-After seconds |
503 |
unavailable |
Sending is not available right now | Yes, with the same key |
/bulk-email
Read one bulk email with a page of its recipients, oldest first. Pass status to keep only recipients with that status (pending, sending, sent, suppressed, failed or canceled), limit for the page size, 20 by default and up to 100, and nextCursor back as cursor for the next page. A limit outside the range answers 400 with "The limit must be a whole number from 1 to 100!", another status with "That recipient status is not valid!", a cursor that is not one with "That cursor is not valid!", and an id that is not one of this site's answers 404. html and text are the bulk email as it was queued, before each person's details were filled in.
{
"success": true,
"message": "Bulk email loaded.",
"data": {
"id": "66f7c2a14d2b8e0012ab3777",
"status": "sending",
"subject": "What is new this month",
"source": "site",
"unsubscribe": true,
"tags": [],
"pausedReason": null,
"stopReason": null,
"createdAt": "2026-09-29T10:15:00.000Z",
"finishedAt": null,
"counts": { "total": 2, "pending": 1, "sent": 1, "suppressed": 0, "failed": 0, "canceled": 0 },
"delivery": { "delivered": 1, "bounced": 0, "complained": 0, "tracked": 1, "opened": 1, "clicked": 0 },
"fromName": null,
"replyTo": [],
"recipients": [
{ "email": "[email protected]", "name": null, "status": "sent", "sendId": "66f7c2a14d2b8e0012ab3801", "error": null, "updatedAt": "2026-09-29T10:16:02.000Z" }
],
"nextCursor": "66f7c2a14d2b8e0012ab3790",
"html": "<p>Hi {{name|there}}.</p>",
"text": "Hi {{name|there}}."
},
"code": 200
}status is sending, paused, completed, canceled or stopped. pausedReason is disabled while sending is turned off, secret-conflict while a secret of the site overrides its email settings, no-credits, plan-limit, spending-cap or provider-paused, and a paused bulk email carries on by itself once it can. stopReason is reputation when sending was switched off on its own for bounces or spam reports, expired, moved or site-missing, and disabled on one stopped before turning sending off paused bulk emails instead. sendId is that person's own email, which /email reads in full. A recipient reads sent with an error when the mail provider did not confirm the email: it may have arrived, and it is never sent twice, so read /email with its sendId for what happened.
/bulk-emails
List the site's bulk emails, newest first, each with the same fields as /bulk-email apart from html, text, fromName, replyTo and recipients. Pass limit and cursor like /emails.
/bulk-cancel
Stop a bulk email that is still going out. Everyone it has not reached yet is marked canceled, and emails that already went out stay sent. It answers with the bulk email and its counts. One that is no longer sending, because it finished, stopped or was already canceled, answers 409 with "This bulk email is no longer sending, so there is nothing to cancel!", and an id that is not one of this site's answers 404.
/unsubscribes
List the people who unsubscribed through the unsubscribe link of one of the site's emails, newest first, so the site can take them off a list it keeps. Each item is { email, unsubscribedAt }, and an address taken off the blocked list no longer shows. Pass limit and cursor like /emails, and email to check one address, which answers with that one item or none. A limit outside the range answers 400 with "The limit must be a whole number from 1 to 100!", an email that is not a valid address with "That email address is not valid!", and a cursor that is not one this endpoint gave with "That cursor is not valid!"
{
"success": true,
"message": "Unsubscribes listed.",
"data": {
"items": [
{
"email": "[email protected]",
"unsubscribedAt": "2026-11-06T09:12:44.000Z"
}
],
"nextCursor": null
},
"count": 1,
"code": 200
}Limits
| Limit | Value |
|---|---|
Recipients on one message, to plus cc plus bcc |
50 |
| Recipients on one bulk email | 10,000 |
| Bulk emails a minute, per site, counted per recipient | 300 |
| Bulk emails running at once, per site | 5 |
| Bulk emails queued an hour, per site | 60 |
| Personal details for one bulk recipient | 2 KB |
| Longest a bulk email keeps trying | 7 days |
Request body for /bulk-send |
10 MB |
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, /quota, /bulk-email, /bulk-emails, /bulk-cancel and /unsubscribes |
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.