OTP email API
How to send OTP emails from Node.js with an email API
Send one-time passcodes and verification codes by email with an API: what to include, expiry and rate limits, deliverability, and complete code.
In short
To send an OTP by email, generate a short random code on your server, store only its hash with an expiry of 5 to 10 minutes, and send it with one API call from a verified domain. Put the code in the subject line so it shows in the notification, leave open and click tracking off, and send an Idempotency-Key so a retried request never delivers two different codes.Swipe the diagram to see the rest
What to include
- The code itself, large and easy to copy - and in the subject line, so it can be read from the notification.
- How long it is valid for, e.g. "expires in 10 minutes".
- What it is for ("to sign in to Acme") so a code the recipient didn't request looks suspicious.
- A line saying to ignore the email if they didn't ask for it. No links are needed.
Best practices
Generate codes with a CSPRNG
Use crypto.randomInt (Node), secrets (Python) or random_int (PHP) - never Math.random. Six digits is the usual length.
Store a hash, not the code
Keep a hash of the code with an expiry and an attempt counter. Lock the code after 5 wrong tries.
Rate-limit sends per address
Cap resends (for example one a minute, five an hour) so the form can't be used to flood somebody's inbox.
Keep tracking off
Open pixels and rewritten links add nothing to an OTP and make it look more like marketing. Leave track_opens and track_clicks off.
Make retries safe
Send an Idempotency-Key tied to the code's id. If your request times out and retries, the recipient still gets exactly one email.
Send text as well as HTML
A plain-text part helps deliverability and is what some watches and notification previews show.
Subject lines that work
481920 is your Acme sign-in codeYour Acme verification code: 481920Acme code 481920 - expires in 10 minutes
Send it
import { randomInt, createHash } from "node:crypto";
const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
await db.otp.create({
email,
hash: createHash("sha256").update(code).digest("hex"),
expiresAt: new Date(Date.now() + 10 * 60_000),
});
const res = await fetch("https://api.sendpository.com/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SENDPOSITORY_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `otp-${email}-${code}`,
},
body: JSON.stringify({
from: "Acme <security@mail.yourdomain.com>",
to: [email],
subject: `${code} is your Acme sign-in code`,
html: `<p>Your code is <strong style="font-size:24px">${code}</strong>.</p><p>It expires in 10 minutes. If you didn't try to sign in, ignore this email.</p>`,
text: `Your code is ${code}. It expires in 10 minutes.`,
tags: { type: "otp" },
}),
});
if (!res.ok) throw new Error((await res.json()).error.type);Using another language? The same request works from any stack - it is JSON over HTTPS.
A complete Node.js implementation
Two endpoints: one that issues a code and emails it, one that checks it. The storage is a single table - swap the db calls for your ORM. Codes are compared as hashes in constant time, expire after ten minutes, and lock after five wrong attempts.
import crypto from "node:crypto";
import express from "express";
import { Sendpository, SendpositoryError } from "sendpository";
const app = express().use(express.json());
const sendpository = new Sendpository(process.env.SENDPOSITORY_API_KEY);
const TTL_MS = 10 * 60_000;
const MAX_ATTEMPTS = 5;
const hash = (code: string) => crypto.createHash("sha256").update(code).digest();
app.post("/auth/otp", async (req, res) => {
const email = String(req.body.email ?? "").trim().toLowerCase();
// One code a minute per address, so the form can't flood an inbox.
if (await db.otp.sentWithin(email, 60_000)) return res.status(429).json({ error: "wait" });
const id = crypto.randomUUID();
const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
await db.otp.insert({ id, email, hash: hash(code), attempts: 0, expiresAt: Date.now() + TTL_MS });
try {
await sendpository.emails.send(
{
from: "Acme <security@mail.acme.com>",
to: [email],
subject: `${code} is your Acme sign-in code`,
html: `<p>Your code is <strong>${code}</strong>. It expires in 10 minutes.</p>`,
text: `Your code is ${code}. It expires in 10 minutes.`,
tags: { type: "otp" },
},
{ idempotencyKey: `otp-${id}` },
);
} catch (err) {
// A suppressed address can't receive mail - tell the user to use another one.
if (err instanceof SendpositoryError && err.type === "suppressed_recipient") {
return res.status(422).json({ error: "undeliverable_address" });
}
throw err;
}
res.json({ id }); // the client sends this back with the code
});
app.post("/auth/otp/verify", async (req, res) => {
const row = await db.otp.find(String(req.body.id));
if (!row || row.expiresAt < Date.now() || row.attempts >= MAX_ATTEMPTS) {
return res.status(400).json({ error: "expired" });
}
await db.otp.incrementAttempts(row.id);
const ok = crypto.timingSafeEqual(row.hash, hash(String(req.body.code ?? "")));
if (!ok) return res.status(400).json({ error: "wrong_code" });
await db.otp.delete(row.id); // single use
res.json({ ok: true, session: await createSession(row.email) });
});Why each piece is there
| Piece | Stops |
|---|---|
crypto.randomInt | Predictable codes - Math.random is not a secure generator |
| Hash in the database | A database leak exposing live codes |
timingSafeEqual | Timing attacks that guess the hash byte by byte |
| Attempt counter | Brute force - a 6-digit code falls to a million guesses, not five |
| One send a minute | Using your form to flood someone else's inbox |
idempotencyKey | Two different emails when a timed-out request is retried |
| Delete on success | Replaying a code that has already been used |
For the delivery side - what happens between the API call and the inbox - see how transactional email works.
Questions
How long should an email OTP be valid?
5 to 10 minutes is the common range. Long enough for delayed delivery and switching apps, short enough that a leaked code is soon useless.
Should the OTP be in the subject line?
Usually yes - it lets people read the code from the notification without opening the email. Leave it out only if lock-screen previews are a concern for your users.
Why is my OTP email going to spam?
Most often the domain isn't authenticated. Publish SPF and DKIM, send from your own verified domain, keep tracking off and include a plain-text part. Check the headers with Gmail's Show original.
How fast are OTP emails delivered?
Sendpository hands the message to the recipient's mail server within seconds of the API call. The delivery log shows the timestamp of every attempt.
Related
- Password reset emails
- Idempotency keys
- Email deliverability checklist
- Order confirmation emails
- Invoice emails
Send your first email in minutes
6,000 emails a month (up to 200 a day), free forever. No card needed.