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. This proves you own the domain and keeps your emails out of spam.

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

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]>" }

Limits

Recipients
max 50 per email (to + cc + bcc)
Request size
max 1 MB
Speed
50 emails per minute per API key
Monthly
100 emails per month on the free plan (shown on your domain's page)
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
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 50 emails/minute on this key.Yes, after a minute
429monthly_limit_reachedThe monthly quota is used up.Next month
503send_failedThe mail server didn't accept the message right now.Yes, after a few seconds
Troubleshooting
Email in 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.
Opening the URL in a browser shows an error
Expected: the endpoint only accepts POST. Use the cURL test above.
invalid_api_key with a key you just made
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.
Works locally, fails in production
The production server needs MAIL_API_KEY in its own environment; .env files usually aren't deployed.
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.