Node 18 and newer ships with fetch, so you can send email from your own domain without installing a single package. The part that takes care is not the request. It is what you do when the request fails.
What you need first
- A domain you control with SPF, DKIM and DMARC published and verified. Without them, your mail will be treated with suspicion. See what an SPF record is and what DKIM does.
- An API key for that domain.
- A
fromaddress on that domain, such as[email protected].
With Mailions you add the domain in the panel, publish the records it shows, click verify, then create a key on the domain's API tab. A key belongs to one domain, and the from address has to be on it.
The request
The endpoint takes a POST with a JSON body and a Bearer token. The body needs from, to, subject, and at least one of html or text. Send both if you can. cc, bcc and reply_to are optional, and to can be one address or an array, up to 50 recipients in total.
// mailer.js (Node.js 18+)
async function sendEmail({ to, subject, html, text }, idempotencyKey) {
const res = await fetch('https://panel.example.com/api/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MAIL_API_KEY}`,
'Content-Type': 'application/json',
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
},
body: JSON.stringify({ from: 'Your App <[email protected]>', to, subject, html, text }),
signal: AbortSignal.timeout(15000),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`${data.error?.code || res.status}: ${data.error?.message || 'send failed'}`);
return data; // { id, message_id, status }
}
Replace the URL with your panel's address. The key comes from the environment, never from source code.
Read the response properly
200 with status: accepted means the mail server took the message. It doesn't mean the recipient's inbox did. Treat it as "handed off", not "delivered".
202 with status: queued means the mail server couldn't take it right now. It's stored and will be sent automatically for up to 24 hours. Don't resend, or the person gets two copies. The queue exists for outages on the mail server, and the message ID is final, so you can store it straight away.
Errors arrive as { "error": { "code": "...", "message": "..." } }. Log the message, since it says what's wrong. The ones you'll meet:
| Status | Code | What to do |
|---|---|---|
| 403 | plan_required | The account has no active trial or paid plan. Contact us |
| 429 | rate_limited | Too many emails per minute on this key. Wait, then retry |
| 429 | monthly_limit_reached | The recipient allowance is used up |
| 409 | idempotency_conflict | Same key, different content. Use the original body |
Set a timeout and use an idempotency key
A request with no timeout can hang your sign-up page when the network misbehaves. The example above gives up after fifteen seconds.
Timeouts raise a harder question. If the request dies halfway, did the email go? Retry blindly and you might send it twice. Add an Idempotency-Key header that identifies that specific email, like welcome/user-4821. Repeat the request with the same key and body and no second copy is sent. The key is scoped to your API key, and you can use up to 128 printable characters without spaces.
await sendEmail(
{ to: user.email, subject: 'Welcome aboard', text: 'Thanks for signing up.' },
`welcome/${user.id}`
);
Don't block the user on the send
For anything that isn't a login code, return to the user first and send in the background. A simple pattern is to put the job in a queue you already have, such as a database table or a job library, and let a worker call sendEmail. If you have no queue yet, a plain await inside a try/catch that logs failures is fine for low volume.
For bursts like a product launch, feed sends out at a steady pace. Each key has a per-minute cap, and bursts above it come back as rate_limited.
Test before you ship
Send your first message to an address you control and read the result. Don't use a real customer to find out whether the from address is accepted. If the domain isn't verified yet, or the from address isn't on the key's domain, the API tells you in the error message.
For flows that create a lot of test mail, such as sign-up and password reset, send to a public temporary inbox while you develop. Choose any name, send, and the message appears in the browser. Free inboxes delete mail after 30 minutes and anyone who knows the address can read it, so keep it to dummy data.
When you check the received message, open the headers too. You want SPF, DKIM and DMARC all passing before you point real users at the code.
Keep the key private
Never put the key in front-end JavaScript, a mobile app or a public repository. Anyone holding it can send mail as your domain. Call the API only from your server. If a key leaks, delete it in the panel and create a new one.
What to send through it
Transactional email is mail a person's action triggers: receipts, codes, password resets. Newsletters and promotions need a different setup, with unsubscribe handling and their own sender reputation. What is transactional email draws that line.
Next steps: read the API guide, look at OTP email design, and contact us to get a trial for your domain.