SENDING · Published 2026-10-06 · Updated 2026-10-06 · SecureSMTP
Sending email
Recipients, From name, Reply-To, custom headers and the two sender modes — everything POST /mail/send can do.
Every email you send with the API is one call to POST /mail/send. This page explains each option with an example. For the full field list and every response, see the API reference.
A complete request
curl -X POST https://securessmtp.com/api/v1/mail/send \
-H "x-securessmtp-api-key: $SECURESSMTP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"subject": "Your order has shipped",
"html": "<p>Hi Ada,</p><p>Your order #1001 is on its way.</p>",
"text": "Hi Ada,\n\nYour order #1001 is on its way.",
"from": {
"name": "Acme Store"
},
"reply_to": "[email protected]"
}'Recipients
- tostring | string[]required
- One address, or a list of 1 to 50.
- ccstring | string[]optional
- One address, or a list of 1 to 50. Shown to all recipients.
- bccstring | string[]optional
- One address, or a list of 1 to 50. Hidden from other recipients.
{
"to": [
"[email protected]",
"[email protected]"
],
"cc": "[email protected]",
"bcc": [
"[email protected]"
],
"subject": "Project update",
"text": "The new version is live."
}- Each address must be a valid email address, or the request fails with
400 invalid_payload. - One request is one email, whatever the number of recipients. It counts once toward the monthly limit.
- Suppressed and unsubscribed addresses are removed from
tobefore sending. Addresses inccandbccare not checked. See who is skipped below. - The one-click unsubscribe header is only added when the email has exactly one recipient in total. To send the same email to many people who should not see each other, send one request per person.
Subject and body
- subjectstringoptional
- Up to 500 characters. If empty, we use
Message from <site name>. - htmlstringoptional
- The HTML part, up to 500,000 characters.
- textstringoptional
- The plain-text part, up to 500,000 characters.
Send html, text or both, unless you use a template. With neither the request fails with 400 no_body. If you send only one part we build the other: plain text becomes a simple HTML part (escaped, line breaks kept), and HTML becomes plain text with the tags removed. Sending both gives you control over how each looks.
From name
Set the display name with from.name. from is an object; a plain string fails with 400 invalid_payload.
{
"to": "[email protected]",
"subject": "Hi",
"text": "Hello.",
"from": {
"name": "Acme Support"
}
}- Only the name is used.
from.addressis accepted but ignored: the From address is decided by the sender mode. - Without a name we use the site’s default From name, then the site name.
- Quotes, angle brackets and line breaks are removed from the name.
Reply-To
- reply_tostring | string[]optional
- Where replies go. One address or a list of up to 50.
- original_fromstringoptional
- The real sender’s address, up to 320 characters. In relay mode, if you send no
reply_to, it becomes the Reply-To. Not used in domain mode.
A contact form usually sets reply_to to the visitor’s address, so pressing Reply answers the visitor:
{
"to": "[email protected]",
"subject": "New message from Ada Lovelace",
"text": "Name: Ada Lovelace\nEmail: [email protected]\n\nHello, I have a question about pricing.",
"reply_to": "[email protected]"
}Sender modes
| Mode | From address | Counts toward the monthly limit | Setup |
|---|---|---|---|
| Relay | [email protected] | Yes | None |
| Domain | The From address of your verified sending domain | No | Verify a domain |
Choose with sender_mode:
| sender_mode | What happens |
|---|---|
auto | Default. Domain mode if the site has a verified sending domain with a From address, otherwise relay mode. |
domain | Same as auto. If there is no verified domain, the email still goes out in relay mode. |
relay | Always relay mode, even when the site has a verified domain. |
The response tells you which mode was used in mode. You can also check the site’s domain with GET /mail/domain-status.
Custom headers
headers is an object of header names and values. Names can be up to 120 characters, values up to 2,000.
{
"to": "[email protected]",
"subject": "Re: Your ticket #4821",
"text": "We have fixed the problem.",
"headers": {
"X-Ticket-Id": "4821",
"In-Reply-To": "<[email protected]>",
"References": "<[email protected]>"
}
}- Use the
from,reply_to,ccandbccfields for those headers, notheaders. - We always set
Feedback-IDourselves. A value you send for it is replaced. - If you send your own
List-Unsubscribe, we do not add ours. See Unsubscribe links.
Attachments
- attachments[].filenamestringrequired
- 1 to 255 characters. The name the recipient sees.
- attachments[].contentstringrequired
- The file, base64-encoded (standard alphabet, no
data:prefix). - attachments[].content_typestringoptional
- The MIME type, for example
application/pdf. - attachments[].content_idstringoptional
- Makes the file an inline image. Refer to it in the HTML as
cid:plus this value.
- Up to 20 files and 15 MB in total (decoded size).
- Over 15 MB fails with
400 attachments_too_large. Content that is not valid base64 fails with400 invalid_attachment, and the response names the file infilename. - The whole request can be up to 25 MB. Base64 makes files about a third larger, so 15 MB of files is about 20 MB of request.
A small CSV file, encoded inline:
curl -X POST https://securessmtp.com/api/v1/mail/send \
-H "x-securessmtp-api-key: $SECURESSMTP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"subject": "Daily orders",
"text": "Today’s orders are attached.",
"attachments": [
{
"filename": "orders.csv",
"content": "b3JkZXIsdG90YWwKMTAwMSw0OS4wMAo=",
"content_type": "text/csv"
}
]
}'Reading a file from disk and encoding it:
import { readFileSync } from 'node:fs';
const res = await fetch('https://securessmtp.com/api/v1/mail/send', {
method: 'POST',
headers: {
'x-securessmtp-api-key': process.env.SECURESSMTP_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: '[email protected]',
subject: 'Your invoice',
text: 'Your invoice is attached.',
attachments: [
{
filename: 'invoice.pdf',
content: readFileSync('invoice.pdf').toString('base64'),
content_type: 'application/pdf',
},
],
}),
});
const result = await res.json();
if (!result.ok) throw new Error(result.reason);
console.log(result.message_id);An inline image. Replace the placeholder with the base64 of a PNG file:
{
"to": "[email protected]",
"subject": "Welcome",
"html": "<p><img src=\"cid:logo\" alt=\"Acme\" width=\"120\"></p><p>Welcome to Acme.</p>",
"attachments": [
{
"filename": "logo.png",
"content": "<base64 of logo.png>",
"content_type": "image/png",
"content_id": "logo"
}
]
}Templates
Instead of html and text, send a saved template by its slug or ID and fill its {{variables}} with data:
{
"to": "[email protected]",
"template": "order-shipped",
"data": {
"name": "Ada",
"order_id": 1001
}
}The template’s HTML and text replace any html and text in the request. Your subject wins if it is not empty. See Templates and variables.
Source label
source_plugin (up to 120 characters) is a label shown in the email log, for example billing-service. Some values mark the email as a contact-form notification, which turns on the contact-form spam check: the names of common WordPress form plugins such as contact-form-7, wpforms or gravityforms. See Deliverability.
Who is skipped
Before sending, we remove from to:
- addresses on the suppression list (they do not exist, or the person marked earlier mail as spam) — see Bounces and suppressions;
- addresses that unsubscribed from this site — see Unsubscribe links.
The email still goes to the other recipients, and the response does not say who was removed. If nobody is left, nothing is sent and you get HTTP 200 with:
{
"ok": false,
"sent": false,
"reason": "send_failed",
"error": "all_recipients_suppressed"
}Reading the response
Always read ok, not only the HTTP status. Some failures come back with HTTP 200 so that the WordPress plugin can fall back to WordPress’s own mailer.
| HTTP | Body | Meaning |
|---|---|---|
| 200 | "ok": true, "sent": true, "mode", "message_id" | Accepted for sending. |
| 200 | "ok": true, "held": true, "reason": "held_as_spam" | A contact-form notification held as spam. Not sent; do not retry. |
| 200 | "ok": false, "reason": "rate_limited" | More than 120 sends from this site in 60 seconds. Wait, then retry. |
| 200 | "ok": false, "reason": "over_quota" | Monthly limit reached (relay mode only). |
| 200 | "ok": false, "reason": "send_failed", "error" | Not sent. error says why. |
| 400 | "reason": "invalid_payload", "details" | A field failed validation. details lists which. |
| 400 | "reason": "content_flagged" | "ai_flagged" | Refused as likely spam. |
| 400 | "reason": "invalid_json" | "no_body" | "template_not_found" | "invalid_attachment" | "attachments_too_large" | Fix the request; do not retry as is. |
| 401 / 403 | "reason": "missing_api_key" | "invalid_api_key" | "site_disabled" | Check the key and the site. |
A successful send:
{
"ok": true,
"sent": true,
"mode": "domain",
"message_id": "<[email protected]>"
}Keep message_id. Webhook events for this email (bounced, opened, clicked and so on) carry the same value, and support can find the email with it. There is no idempotency key: a request you repeat after a timeout can send the email twice.
const res = await fetch('https://securessmtp.com/api/v1/mail/send', {
method: 'POST',
headers: {
'x-securessmtp-api-key': process.env.SECURESSMTP_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ to: '[email protected]', subject: 'Hello', text: 'Hi there.' }),
});
const result = await res.json();
if (result.ok && result.held) {
// Held as contact-form spam: not sent, nothing to retry.
} else if (result.ok) {
console.log('sent', result.mode, result.message_id);
} else if (result.reason === 'rate_limited') {
// Wait a minute, then send again.
} else if (result.reason === 'over_quota') {
// Monthly limit used up: wait for the new month or upgrade.
} else if (res.status === 400) {
// Fix the request first: result.reason says what is wrong.
console.error(result.reason, result.details);
} else {
console.error(result.reason, result.error);
}Every reason is listed in the error catalogue.