1. Get started (5 minutes)
1

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.

2

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.

3

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.

2. Add a sendEmail() helper to your backend

One 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!")
3. OTP / email verification: complete example

Two endpoints on your server: one emails a 6-digit code, the other checks it. Your sign-up or login form calls them.

How it works
  1. User enters their email → your form calls POST /otp/send.
  2. Your server creates a random code, saves a hash of it and emails the code.
  3. User types the code → your form calls POST /otp/verify.
  4. If it matches, mark the email as verified or log the user in.
Built-in safety rules
  • 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)
The examples keep codes in memory (or the PHP session) so they run as-is. In production, store hash, expires_at, attempts and sent_at in your database, e.g. on the user row, so codes survive restarts and work across servers.
4. Other emails: same helper, different content
Welcome
After sign-up or verification. Subject: Welcome to Your App!. Include a link to get started.
Password reset
Create a long random token (e.g. 32 bytes), store its hash with a 1-hour expiry and email a link like 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.
Receipts & alerts
Order confirmations, invoices, "new login" alerts. Send both 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.

API reference: POST https://mailions.com/api/v1/emails

Headers

Authorization
Bearer YOUR_API_KEY
Content-Type
application/json
Idempotency-Key
Recommended: a unique identifier for this email, such as receipt/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

from *
"[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.
to *
an address, or an array of addresses
subject *
string
html / text *
at least one; sending both gives the best deliverability
cc, bcc
optional; an address or an array
reply_to
optional; where replies go, e.g. [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

Recipients
max 50 per email (to + cc + bcc)
Request size
max 1 MB
Speed
30 emails per minute per API key
Monthly
100 API recipients per UTC calendar month by default (we can set a different limit for your plan). Sending needs an active trial or paid plan; without one the API answers with 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

Errors return a non-2xx status and { "error": { "code": "...", "message": "..." } }. Log the message: it says exactly what's wrong.

StatusCodeMeaning / fixRetry?
401missing_api_key, invalid_api_keyKey missing, mistyped or revoked. Check MAIL_API_KEY, or create a new key.No
403from_not_allowedThe from address isn't on the key's domain.No
403plan_requiredThe account has no active trial or paid plan (not started yet, or ended). Contact us.No, until the plan is active
403domain_not_verifiedAdd the DNS records and click Check DNS on your domain page.No
422invalid_to, missing_body, …A field is missing or invalid; the message names it.No
429rate_limitedMore than 30 emails/minute on this key.Yes, after a minute
429monthly_limit_reachedThis request exceeds the available recipient allowance.After allowance is available, start a new attempt with a new key
409idempotency_conflictThis key was used with different content.Use the original body, or a new key for a different email
409send_in_progressThe attempt is pending or its result was not recorded.Same key only; contact support if it persists
503send_failedThe relay definitively rejected the attempt. Replays return the same failure.After fixing the cause, use a new key for a new attempt
Troubleshooting

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.

Security checklist
  • 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.