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.
JSON
{
  "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 to before sending. Addresses in cc and bcc are 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.

JSON
{
  "to": "[email protected]",
  "subject": "Hi",
  "text": "Hello.",
  "from": {
    "name": "Acme Support"
  }
}
  • Only the name is used. from.address is 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:

JSON
{
  "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

ModeFrom addressCounts toward the monthly limitSetup
Relay[email protected]YesNone
DomainThe From address of your verified sending domainNoVerify a domain

Choose with sender_mode:

sender_modeWhat happens
autoDefault. Domain mode if the site has a verified sending domain with a From address, otherwise relay mode.
domainSame as auto. If there is no verified domain, the email still goes out in relay mode.
relayAlways 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.

JSON
{
  "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, cc and bcc fields for those headers, not headers.
  • We always set Feedback-ID ourselves. 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 with 400 invalid_attachment, and the response names the file in filename.
  • 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:

JSON
{
  "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:

JSON
{
  "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:

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:

Response
{
  "ok": false,
  "sent": false,
  "reason": "send_failed",
  "error": "all_recipients_suppressed"
}
error is all_recipients_unsubscribed when everyone left had unsubscribed.

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.

HTTPBodyMeaning
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:

Response
{
  "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.

Node.js
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);
}
Handling each outcome.

Every reason is listed in the error catalogue.