Sending API guide
Send OTP codes, sign-up confirmations, password resets and receipts from your own domain with one HTTP request. Copy, paste, done.
Use private mailboxes for correspondence and public temporary inboxes for receive-only testing. Compare all features and paid options. Public inboxes cannot send mail, and API sending does not provide attachment access.
Last updated
Add and verify your domain
Create an account, add your domain (e.g. yourdomain.com), add the DNS records shown at your domain provider and click Check DNS. Choose sending only to keep your existing inbox provider. Verification proves control of the domain and checks sending authentication; it does not guarantee inbox placement.
Create an API key
On your domain's page, under Send emails from your website, click Create API key. Copy it right away: it is shown only once. Save it on your server as the environment variable MAIL_API_KEY.
Send a test email
Paste this into a terminal with your key and your own email address. You should get the email within a minute.
curl -X POST https://mailions.com/api/v1/emails \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Your App <[email protected]>",
"to": "[email protected]",
"subject": "Test from my website",
"text": "It works!",
"html": "<p>It works!</p>"
}'
Replace yourdomain.com with your domain. The sender can be any address on it (noreply@, support@…); no mailbox is needed.
sendEmail() helper to your backendOne small function you call from anywhere in your app. It reads the key from the environment, times out after 15 seconds and throws a clear error if sending fails.
// mailer.js — Node.js 18+ (no packages needed).
// Put your key in .env (never in code): MAIL_API_KEY=your_api_key
async function sendEmail({ to, subject, html, text }) {
const res = await fetch('https://mailions.com/api/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MAIL_API_KEY}`,
'Content-Type': 'application/json',
},
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 }
}
module.exports = { sendEmail };
// Usage:
// await sendEmail({ to: '[email protected]', subject: 'Hello', text: 'Hi!', html: '<p>Hi!</p>' });
<?php
// mailer.php — PHP 7.4+ with the curl extension.
// Put your key in the server environment (never in code): MAIL_API_KEY=your_api_key
function send_email(string $to, string $subject, string $html, string $text): array
{
$ch = curl_init('https://mailions.com/api/v1/emails');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('MAIL_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'from' => 'Your App <[email protected]>',
'to' => $to,
'subject' => $subject,
'html' => $html,
'text' => $text,
]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($body === false) {
throw new RuntimeException("Mail API unreachable: $error");
}
$data = json_decode($body, true) ?: [];
if ($status !== 200) {
$code = $data['error']['code'] ?? $status;
$msg = $data['error']['message'] ?? 'send failed';
throw new RuntimeException("$code: $msg");
}
return $data; // ['id' => ..., 'message_id' => ...]
}
// Usage:
// send_email('[email protected]', 'Hello', '<p>Hi!</p>', 'Hi!');
# mailer.py — Python 3.8+. pip install requests
# Put your key in the environment (never in code): MAIL_API_KEY=your_api_key
import os
import requests
def send_email(to, subject, html, text):
r = requests.post(
"https://mailions.com/api/v1/emails",
headers={"Authorization": f"Bearer {os.environ['MAIL_API_KEY']}"},
json={
"from": "Your App <[email protected]>",
"to": to,
"subject": subject,
"html": html,
"text": text,
},
timeout=15,
)
data = r.json() if r.content else {}
if not r.ok:
err = data.get("error", {})
raise RuntimeError(f"{err.get('code', r.status_code)}: {err.get('message', 'send failed')}")
return data # {"id": ..., "message_id": ...}
# Usage:
# send_email("[email protected]", "Hello", "<p>Hi!</p>", "Hi!")
Two endpoints on your server: one emails a 6-digit code, the other checks it. Your sign-up or login form calls them.
- User enters their email → your form calls
POST /otp/send. - Your server creates a random code, saves a hash of it and emails the code.
- User types the code → your form calls
POST /otp/verify. - If it matches, mark the email as verified or log the user in.
- Cryptographically random 6-digit code
- Only a hash is stored, never the code itself
- Expires after 10 minutes, works once
- Max 5 wrong attempts, then a new code is needed
- At most 1 email per minute per address
// otp.js — Express routes: email a 6-digit code, then check it.
// Needs mailer.js from step 1. Mount with: app.use(express.json()); app.use(require('./otp'));
const crypto = require('crypto');
const express = require('express');
const { sendEmail } = require('./mailer');
const router = express.Router();
// Demo storage. In production save these fields on the user row in your database.
const codes = new Map(); // email -> { hash, expiresAt, attempts, sentAt }
const OTP_TTL_MS = 10 * 60 * 1000; // a code is valid for 10 minutes
const RESEND_AFTER_MS = 60 * 1000; // at most one email per minute
const MAX_ATTEMPTS = 5; // then a new code is needed
const hash = (code) => crypto.createHash('sha256').update(code).digest('hex');
// 1) POST /otp/send { "email": "[email protected]" }
router.post('/otp/send', async (req, res) => {
const email = String(req.body.email || '').trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return res.status(400).json({ error: 'Enter a valid email address' });
}
const previous = codes.get(email);
if (previous && Date.now() - previous.sentAt < RESEND_AFTER_MS) {
return res.status(429).json({ error: 'Please wait a minute before requesting a new code' });
}
const code = crypto.randomInt(0, 1000000).toString().padStart(6, '0'); // secure random
codes.set(email, { hash: hash(code), expiresAt: Date.now() + OTP_TTL_MS, attempts: 0, sentAt: Date.now() });
try {
await sendEmail({
to: email,
subject: `Your verification code: ${code}`,
text: `Your verification code is ${code}. It expires in 10 minutes.\n\nIf you didn't request this, you can ignore this email.`,
html: `<p>Your verification code is</p>
<p style="font-size:28px;font-weight:bold;letter-spacing:6px">${code}</p>
<p>It expires in 10 minutes. If you didn't request this, you can ignore this email.</p>`,
});
res.json({ ok: true });
} catch (err) {
codes.delete(email);
console.error('OTP email failed:', err.message);
res.status(502).json({ error: 'Could not send the code. Please try again.' });
}
});
// 2) POST /otp/verify { "email": "[email protected]", "code": "123456" }
router.post('/otp/verify', (req, res) => {
const email = String(req.body.email || '').trim().toLowerCase();
const code = String(req.body.code || '').trim();
const entry = codes.get(email);
if (!entry || Date.now() > entry.expiresAt) {
return res.status(400).json({ error: 'This code has expired or was already used. Request a new one.' });
}
if (entry.attempts >= MAX_ATTEMPTS) {
return res.status(429).json({ error: 'Too many wrong attempts. Request a new code.' });
}
entry.attempts += 1;
const ok = crypto.timingSafeEqual(Buffer.from(hash(code)), Buffer.from(entry.hash));
if (!ok) return res.status(400).json({ error: 'Wrong code. Please try again.' });
codes.delete(email); // a code works only once
// ✅ Verified: mark the user's email as verified or log them in here.
res.json({ verified: true });
});
module.exports = router;
<?php
// otp.php — POST action=send&email=... then POST action=verify&code=...
// Needs mailer.php from step 1. Stores the code in the PHP session.
session_start();
require __DIR__ . '/mailer.php';
header('Content-Type: application/json');
function reply(int $status, array $body): void
{
http_response_code($status);
echo json_encode($body);
exit;
}
$action = $_POST['action'] ?? '';
// 1) Send a code
if ($action === 'send') {
$email = strtolower(trim($_POST['email'] ?? ''));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
reply(400, ['error' => 'Enter a valid email address']);
}
if (isset($_SESSION['otp']) && time() - $_SESSION['otp']['sent_at'] < 60) {
reply(429, ['error' => 'Please wait a minute before requesting a new code']);
}
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); // secure random
$_SESSION['otp'] = [
'email' => $email,
'hash' => password_hash($code, PASSWORD_DEFAULT),
'expires' => time() + 600, // 10 minutes
'attempts' => 0,
'sent_at' => time(),
];
try {
send_email(
$email,
"Your verification code: $code",
"<p>Your verification code is</p>"
. "<p style=\"font-size:28px;font-weight:bold;letter-spacing:6px\">$code</p>"
. "<p>It expires in 10 minutes. If you didn't request this, you can ignore this email.</p>",
"Your verification code is $code. It expires in 10 minutes."
);
reply(200, ['ok' => true]);
} catch (Throwable $e) {
unset($_SESSION['otp']);
error_log('OTP email failed: ' . $e->getMessage());
reply(502, ['error' => 'Could not send the code. Please try again.']);
}
}
// 2) Verify the code
if ($action === 'verify') {
$otp = $_SESSION['otp'] ?? null;
if (!$otp || time() > $otp['expires']) {
reply(400, ['error' => 'This code has expired or was already used. Request a new one.']);
}
if ($otp['attempts'] >= 5) {
reply(429, ['error' => 'Too many wrong attempts. Request a new code.']);
}
$_SESSION['otp']['attempts']++;
if (!password_verify(trim($_POST['code'] ?? ''), $otp['hash'])) {
reply(400, ['error' => 'Wrong code. Please try again.']);
}
unset($_SESSION['otp']); // a code works only once
// ✅ Verified: mark $otp['email'] as verified or log the user in here.
reply(200, ['verified' => true, 'email' => $otp['email']]);
}
reply(400, ['error' => 'Unknown action']);
# otp.py — Flask routes: email a 6-digit code, then check it.
# pip install flask requests. Needs mailer.py from step 1.
import hashlib
import hmac
import secrets
import time
from flask import Blueprint, jsonify, request
from mailer import send_email
otp = Blueprint("otp", __name__) # app.register_blueprint(otp)
# Demo storage. In production save these fields on the user row in your database.
codes = {} # email -> {"hash", "expires_at", "attempts", "sent_at"}
OTP_TTL = 10 * 60 # a code is valid for 10 minutes
RESEND_AFTER = 60 # at most one email per minute
MAX_ATTEMPTS = 5 # then a new code is needed
def _hash(code):
return hashlib.sha256(code.encode()).hexdigest()
# 1) POST /otp/send {"email": "[email protected]"}
@otp.post("/otp/send")
def send_code():
email = str((request.get_json(silent=True) or {}).get("email", "")).strip().lower()
if "@" not in email or "." not in email.split("@")[-1]:
return jsonify(error="Enter a valid email address"), 400
previous = codes.get(email)
if previous and time.time() - previous["sent_at"] < RESEND_AFTER:
return jsonify(error="Please wait a minute before requesting a new code"), 429
code = f"{secrets.randbelow(1_000_000):06d}" # secure random
codes[email] = {"hash": _hash(code), "expires_at": time.time() + OTP_TTL, "attempts": 0, "sent_at": time.time()}
try:
send_email(
email,
f"Your verification code: {code}",
"<p>Your verification code is</p>"
f'<p style="font-size:28px;font-weight:bold;letter-spacing:6px">{code}</p>'
"<p>It expires in 10 minutes. If you didn't request this, you can ignore this email.</p>",
f"Your verification code is {code}. It expires in 10 minutes.",
)
return jsonify(ok=True)
except Exception as e:
codes.pop(email, None)
print("OTP email failed:", e)
return jsonify(error="Could not send the code. Please try again."), 502
# 2) POST /otp/verify {"email": "[email protected]", "code": "123456"}
@otp.post("/otp/verify")
def verify_code():
body = request.get_json(silent=True) or {}
email = str(body.get("email", "")).strip().lower()
code = str(body.get("code", "")).strip()
entry = codes.get(email)
if not entry or time.time() > entry["expires_at"]:
return jsonify(error="This code has expired or was already used. Request a new one."), 400
if entry["attempts"] >= MAX_ATTEMPTS:
return jsonify(error="Too many wrong attempts. Request a new code."), 429
entry["attempts"] += 1
if not hmac.compare_digest(_hash(code), entry["hash"]):
return jsonify(error="Wrong code. Please try again."), 400
codes.pop(email, None) # a code works only once
# ✅ Verified: mark the user's email as verified or log them in here.
return jsonify(verified=True)
hash, expires_at, attempts and sent_at in your database, e.g. on the user row, so codes survive restarts and work across servers.Welcome to Your App!. Include a link to get started.https://yourapp.com/reset?token=…. When it's used, set the new password and delete the token. Always show the same message whether or not the email exists.html and text.// Node.js example: welcome email
await sendEmail({
to: user.email,
subject: 'Welcome to Your App!',
text: `Hi ${user.name}, your account is ready: https://yourapp.com/dashboard`,
html: `<p>Hi ${user.name}, your account is ready.</p><p><a href="https://yourapp.com/dashboard">Open your dashboard</a></p>`,
});
Escape user-provided values (names etc.) before putting them into html.
POST https://mailions.com/api/v1/emailsHeaders
Bearer YOUR_API_KEYapplication/jsonreceipt/order-123 (1–128 printable ASCII characters, no spaces). Reuse the same key and body after a timeout. Keys are scoped to the API key and retained with the email log.JSON body
"[email protected]" or "Your App <[email protected]>". Must be on the API key's domain; a subdomain like @app.yourdomain.com is a different domain.[email protected]Success: 200
{ "id": "66f2c1...", "message_id": "<[email protected]>", "status": "accepted" }
Accepted means the local relay accepted the message, not delivery to an inbox. The accepted and rejected arrays describe individual relay recipients; do not resend to recipients already accepted. Dry-run returns status: simulated and sends nothing.
Mail server unavailable: 202 queued
{ "id": "66f2c1...", "message_id": "<[email protected]>", "status": "queued" }
If the mail server cannot take the message right now, it is not lost and you do not need to resend: it is kept in a queue and sent automatically as soon as the server is back, for up to 24 hours. The message_id is final. It counts against your monthly allowance once; if it is finally given up (for example after 24 hours) the allowance is returned and the status becomes failed. Ask what happened with GET /api/v1/emails/<id> (same Bearer key); it returns status (queued, accepted, failed, unknown), plus detail or error when there is something to say.
202 with status: unknown means the SMTP outcome could not be confirmed. Contact support with the returned ID before resending. A replay with the same idempotency key will never send another copy.
Limits
to + cc + bcc)plan_required. Each distinct address across To, CC and BCC uses one unit per request. Reservations include pending, unknown and simulated attempts; definite relay failures release their units. Partial acceptance retains the full reservation. Mailbox SMTP is separate.Errors return a non-2xx status and { "error": { "code": "...", "message": "..." } }. Log the message: it says exactly what's wrong.
| Status | Code | Meaning / fix | Retry? |
|---|---|---|---|
| 401 | missing_api_key, invalid_api_key | Key missing, mistyped or revoked. Check MAIL_API_KEY, or create a new key. | No |
| 403 | from_not_allowed | The from address isn't on the key's domain. | No |
| 403 | plan_required | The account has no active trial or paid plan (not started yet, or ended). Contact us. | No, until the plan is active |
| 403 | domain_not_verified | Add the DNS records and click Check DNS on your domain page. | No |
| 422 | invalid_to, missing_body, … | A field is missing or invalid; the message names it. | No |
| 429 | rate_limited | More than 30 emails/minute on this key. | Yes, after a minute |
| 429 | monthly_limit_reached | This request exceeds the available recipient allowance. | After allowance is available, start a new attempt with a new key |
| 409 | idempotency_conflict | This key was used with different content. | Use the original body, or a new key for a different email |
| 409 | send_in_progress | The attempt is pending or its result was not recorded. | Same key only; contact support if it persists |
| 503 | send_failed | The relay definitively rejected the attempt. Replays return the same failure. | After fixing the cause, use a new key for a new attempt |
Why are my emails going to spam?
Make sure your domain has exactly one SPF record (TXT starting with v=spf1) and that all DNS records on your domain page show as verified. Use a clear From name, send a text version, and mark the first emails as "Not spam". New domains build reputation over a few days.
Why does opening the API URL in a browser show an error?
That is expected: the endpoint only accepts POST requests. Use the cURL test above.
Why do I get invalid_api_key with a key I just created?
Copy the whole key (it starts with mp_ and is 35 characters), without quotes or spaces, and make sure it was created in this panel.
Why does sending work locally but fail in production?
The production server needs MAIL_API_KEY in its own environment; .env files usually aren't deployed.
How do I check what was sent?
Your domain's page lists recent emails with their status; hover a failed entry to see why.
- Call the API from your server only, never from browser JavaScript or a mobile app: anyone who sees the key can send email as your domain.
- Keep the key in an environment variable, not in code or git. Use a separate key per website so you can revoke one without affecting the others.
- If a key leaks, revoke it on your domain's page right away and create a new one.
- Rate-limit your own OTP and password-reset forms (the examples above do) so nobody can use your site to spam people.