Sending API guide
Send OTP codes, sign-up confirmations, password resets and receipts from your own domain with one HTTP request. Copy, paste, done.
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. This proves you own the domain and keeps your emails out of spam.
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/jsonJSON 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]>" }
Limits
to + cc + bcc)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 | 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 50 emails/minute on this key. | Yes, after a minute |
| 429 | monthly_limit_reached | The monthly quota is used up. | Next month |
| 503 | send_failed | The mail server didn't accept the message right now. | Yes, after a few seconds |
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.POST. Use the cURL test above.invalid_api_key with a key you just mademp_ and is 35 characters), without quotes or spaces, and make sure it was created in this panel.MAIL_API_KEY in its own environment; .env files usually aren't deployed.- 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.