Sending email
Send through the API with POST /v1/emails, or over SMTP from software that already speaks it. Both take the same route inside Postilio: the same checks, limits, suppression list and message log.
Through the API
curl https://api.postilio.eu/v1/emails \
-H "Authorization: Bearer $POSTILIO_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <no-reply@mail.example.com>",
"to": ["ada.lovelace@example.com"],
"subject": "Your sign-in code",
"text": "Your code is 482913.",
"html": "<p>Your code is <b>482913</b>.</p>",
"tag": "sign-in",
"replyTo": "support@example.com"
}'
The key needs the emails:send scope. Examples in Node and C# are in the Quickstart.
| Field | Rules |
|---|---|
from | Required. An address on a verified domain of the project, optionally with a name: Acme <no-reply@mail.example.com>. The name may not contain @ or ,. |
to | Required. 1 to 50 addresses, each a bare address without a name. |
subject | Required. At most 998 characters, no control characters. |
text, html | At least one of the two. Send both and mail clients pick. |
tag | Optional. Up to 64 letters, digits, - or _; filter on it in the portal and find it in webhook events. |
replyTo | Optional. One address, with or without a name. |
attachments | Optional. Up to 20 files of 10 MB together: fileName, contentType (application/pdf), content (base64). One with a contentId is inline: the HTML refers to it as cid: plus that id. |
The attachments' size times the number of recipients may be at most 25 MB; send to fewer recipients at a time otherwise. Everything a request can hold is in the reference.
The answer
202 Accepted means Postilio has stored the message and will deliver it:
{ "ids": ["01a10ce5-a09a-788b-9243-d7c9c59773d2", "01a10ce5-a09a-7dde-9fde-5676dcbd0094"], "suppressed": [] }
Every recipient becomes a message of its own with its own id, in the order of to. A recipient on the project's suppression list is accepted but not sent: it is listed in suppressed and its message gets the status suppressed (see Suppressions).
A request that cannot be sent answers an error and stores nothing; Errors and limits lists them. The common ones: 400 for a field that is not valid, 422 with unverified_sender_domain when the sender's domain is not verified (yet), and in the sandbox 422 sandbox_recipient_not_allowed and 429.
To send safely again after a timeout or a lost answer, add an Idempotency-Key header: see Idempotency.
What happens next
Each message moves through these statuses. A status only moves forward, so events that arrive late do not move a message back; a bounce or a complaint after a delivery still wins.
| Status | Meaning |
|---|---|
accepted | stored, waiting to be handed to the mail server |
queued | handed to the mail server |
deferred | the receiving server refused it for now; it is tried again |
delivered | the receiving server accepted it |
bounced | refused for good, also when the bounce arrives after a delivery |
expired | not delivered within a day; no more attempts |
complained | the recipient marked it as spam |
suppressed | not sent, because the address is on the suppression list |
Look a message up with GET /v1/emails/{id} (scope emails:read):
curl https://api.postilio.eu/v1/emails/$EMAIL_ID \
-H "Authorization: Bearer $POSTILIO_KEY"
{
"id": "01a10ce5-a09a-788b-9243-d7c9c59773d2",
"status": "bounced",
"from": "no-reply@mail.example.com",
"to": "bounced@simulator.postilio.eu",
"subject": "Welcome to Acme",
"tag": "welcome",
"acceptedAt": "2026-10-05T16:28:57.882+00:00",
"test": true,
"via": "api",
"events": [
{ "type": "accepted", "occurredAt": "2026-10-05T16:28:57.882+00:00", "smtpCode": null, "response": null, "attempt": null, "remoteHost": null, "enhancedCode": null, "classification": null },
{ "type": "queued", "occurredAt": "2026-10-05T16:28:57.882+00:00", "smtpCode": null, "response": null, "attempt": null, "remoteHost": null, "enhancedCode": null, "classification": null },
{ "type": "bounced", "occurredAt": "2026-10-05T16:28:57.882+00:00", "smtpCode": 550, "response": "550 5.1.1 Simulated: the mailbox does not exist", "attempt": 1, "remoteHost": "mx.simulator.postilio.eu", "enhancedCode": "5.1.1", "classification": "InvalidRecipient" }
]
}
subject is null when the project does not keep subjects. The message log keeps no bodies or attachments. To hear about events as they happen instead of asking, use Webhooks.
SMTP
For software that sends mail over SMTP: point it at Postilio and log in with an API key.
| Setting | Value |
|---|---|
| Host | smtp.postilio.eu |
| Port | 587 with STARTTLS (recommended), or 465 with TLS from the first byte |
| User name | apikey |
| Password | an API key with the emails:send scope |
Port 25 is not used: it is for traffic between mail servers, not for submitting mail. TLS 1.2 or 1.3 is required, and a login is accepted only after TLS is up.
With curl (for 465, use smtps://smtp.postilio.eu:465 without --ssl-reqd):
cat > welcome.eml <<'MAIL'
From: Acme <no-reply@mail.example.com>
To: delivered@simulator.postilio.eu
Subject: Welcome to Acme
Hi Ada, your account is ready.
MAIL
curl smtp://smtp.postilio.eu:587 --ssl-reqd \
--user "apikey:$POSTILIO_KEY" \
--mail-from no-reply@mail.example.com \
--mail-rcpt delivered@simulator.postilio.eu \
--upload-file welcome.eml
In C#, with System.Net.Mail:
// Sends an email over SMTP submission: dotnet run SmtpSend.cs (.NET 10, no packages needed).
// System.Net.Mail speaks STARTTLS (port 587) only; for TLS on port 465 use a library such as MailKit.
using System.Net;
using System.Net.Mail;
using var smtp = new SmtpClient("smtp.postilio.eu", 587)
{
EnableSsl = true, // STARTTLS, before the login
Credentials = new NetworkCredential("apikey", Environment.GetEnvironmentVariable("POSTILIO_KEY")),
};
using var message = new MailMessage("Acme <no-reply@mail.example.com>", "delivered@simulator.postilio.eu",
"Welcome to Acme", "Hi Ada, your account is ready.");
await smtp.SendMailAsync(message);
Console.WriteLine("Accepted");
In Node, with nodemailer:
// Sends an email over SMTP submission with nodemailer: npm install nodemailer, then node smtp.mjs.
import nodemailer from 'nodemailer';
const transport = nodemailer.createTransport({
host: 'smtp.postilio.eu',
port: 587, // STARTTLS; for port 465 set secure: true
secure: false,
requireTLS: true,
auth: { user: 'apikey', pass: process.env.POSTILIO_KEY },
});
const info = await transport.sendMail({
from: 'Acme <no-reply@mail.example.com>',
to: 'delivered@simulator.postilio.eu',
subject: 'Welcome to Acme',
text: 'Hi Ada, your account is ready.',
});
console.log(info.response); // 250 2.0.0 Ok: queued as <id>
How a message is read
- The envelope decides the recipients (
RCPT TO), so Bcc works and theToandCcheaders add no one. - The
Fromheader must hold exactly one address. It, and the envelope sender (MAIL FROM), must be on a verified domain of the key's project. - The first plain-text part and the first HTML part are the bodies; every other part is an attachment, inline when it has a
Content-ID.Reply-Tois kept; a tag cannot be set over SMTP. - The answer to the message names the id of the first recipient's message:
250 2.0.0 Ok: queued as <id>, followed byand 2 morefor more recipients and; 1 suppressed recipient(s) not sentwhen some are on the suppression list. - A client that sends a message again with the same
Message-IDto the same recipients within 24 hours does not send it twice (see Idempotency).
Limits and replies
| Limit | Reply |
|---|---|
| 10 MB per message | 552 5.3.4 |
| 50 recipients per message: the rest go in a next message | 452 4.5.3 |
| 20 messages per connection: reconnect for more | 421 4.7.0 |
| 600 messages per key per minute | 451 4.7.1 |
| 10 failed logins per 15 minutes from one address: wait | 454 4.7.0 |
| Log in within 30 seconds of connecting | the connection is closed |
| Reply | Why |
|---|---|
535 5.7.1 client_ip_not_allowed | the key is restricted to other networks |
535 5.7.1 insufficient_scope | the key lacks emails:send |
553 5.7.1 unverified_sender_domain | the envelope sender's domain is not verified (at MAIL FROM) |
550 5.7.1 unverified_sender_domain | the From header's domain is not verified (after the message) |
553 5.7.1 or 550 5.7.1 sender_domain_not_allowed_for_key | the key may not send from that domain (envelope or From header) |
550 5.7.1 sandbox_recipient_not_allowed | in the sandbox, a recipient outside your team and verified domains |
451 4.7.1 sandbox_daily_limit_reached, sandbox_rate_limit_reached | a sandbox limit; a mail server tries again later |
554 5.6.0 | the message is not valid: not MIME, no single From address, or a field the API would refuse (the reply names it) |
554 5.6.0 message_id_reused_with_different_content | the same Message-ID with other content within 24 hours |
530 5.7.0 | the key was revoked during the session |
451 4.3.0, 451 4.3.2 | a temporary failure at Postilio, or busy; try again |