Postilio docs

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.

FieldRules
fromRequired. 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 ,.
toRequired. 1 to 50 addresses, each a bare address without a name.
subjectRequired. At most 998 characters, no control characters.
text, htmlAt least one of the two. Send both and mail clients pick.
tagOptional. Up to 64 letters, digits, - or _; filter on it in the portal and find it in webhook events.
replyToOptional. One address, with or without a name.
attachmentsOptional. 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.

StatusMeaning
acceptedstored, waiting to be handed to the mail server
queuedhanded to the mail server
deferredthe receiving server refused it for now; it is tried again
deliveredthe receiving server accepted it
bouncedrefused for good, also when the bounce arrives after a delivery
expirednot delivered within a day; no more attempts
complainedthe recipient marked it as spam
suppressednot 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.

SettingValue
Hostsmtp.postilio.eu
Port587 with STARTTLS (recommended), or 465 with TLS from the first byte
User nameapikey
Passwordan 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

Limits and replies

LimitReply
10 MB per message552 5.3.4
50 recipients per message: the rest go in a next message452 4.5.3
20 messages per connection: reconnect for more421 4.7.0
600 messages per key per minute451 4.7.1
10 failed logins per 15 minutes from one address: wait454 4.7.0
Log in within 30 seconds of connectingthe connection is closed
ReplyWhy
535 5.7.1 client_ip_not_allowedthe key is restricted to other networks
535 5.7.1 insufficient_scopethe key lacks emails:send
553 5.7.1 unverified_sender_domainthe envelope sender's domain is not verified (at MAIL FROM)
550 5.7.1 unverified_sender_domainthe From header's domain is not verified (after the message)
553 5.7.1 or 550 5.7.1 sender_domain_not_allowed_for_keythe key may not send from that domain (envelope or From header)
550 5.7.1 sandbox_recipient_not_allowedin the sandbox, a recipient outside your team and verified domains
451 4.7.1 sandbox_daily_limit_reached, sandbox_rate_limit_reacheda sandbox limit; a mail server tries again later
554 5.6.0the 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_contentthe same Message-ID with other content within 24 hours
530 5.7.0the key was revoked during the session
451 4.3.0, 451 4.3.2a temporary failure at Postilio, or busy; try again