OTP email: send one-time passcodes by API
Updated
An OTP email carries a short code that proves the user controls an address. It is the most time-critical email you will send, because the user is staring at a form waiting for it.
When it fires
A user starts a login, signup, or sensitive action, and your backend generates a code and emails it. Nothing else should be in the message.
Workflow
- User submits their email on your form.
- Backend generates a code with a cryptographically secure random source.
- Backend stores a hash of the code, the user, an expiry and an attempt counter.
- Backend calls the Inboxili API.
- User types the code. Backend compares hashes, checks expiry and attempts, then marks the code used.
Implementation (Node.js 18+)
import { randomInt, createHash } from "node:crypto";
const hash = (code) => createHash("sha256").update(code).digest("hex");
export async function sendOtp(db, email) {
const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
await db.otps.upsert({
email,
codeHash: hash(code),
expiresAt: new Date(Date.now() + 10 * 60_000),
attempts: 0,
});
const res = await fetch("https://api.inboxili.com/api/v1/transactional/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INBOXILI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
to: email,
from_email: "security@yourdomain.com",
from_name: "Acme",
subject: "Your Acme verification code",
html_body: "<p>Your code is <strong>{{code}}</strong>. It expires in 10 minutes. If you did not request it, ignore this email.</p>",
text_body: `Your code is ${code}. It expires in 10 minutes.`,
template_data: { code },
}),
});
if (!res.ok) throw new Error((await res.json()).error?.message ?? `Inboxili ${res.status}`);
}
export async function verifyOtp(db, email, input) {
const row = await db.otps.find(email);
if (!row || row.expiresAt < new Date() || row.attempts >= 5) return false;
await db.otps.increment(email, "attempts");
const ok = row.codeHash === hash(input);
if (ok) await db.otps.delete(email);
return ok;
}
The API does not apply merge tags to text_body, so the code is interpolated into it directly.
Sample email
Subject: Your Acme verification code Your code is 482913. It expires in 10 minutes. If you did not request it, ignore this email.
Things that go wrong
- Predictable codes.
Math.random()is not suitable. UserandomIntor your language's CSPRNG. - Storing the code in plain text. Store a hash.
- No attempt limit. A six-digit code has a million values. Cap attempts and lock out.
- Resend abuse. Rate-limit by account and by IP, or your sender reputation pays for it.
- Duplicate sends on retry. The API has no idempotency key. On a timeout, ask the user to press resend rather than auto-retrying.
Delivery events
Register a webhook for bounced and delayed. A hard bounce on a code email usually means the address is wrong, so tell the user to check it instead of leaving them waiting.
Deliverability notes
Send from a verified subdomain you use only for security mail. Keep the body to the code and one sentence. Avoid links, images and promotional text in OTP emails.
Related: email verification, password reset, the Node.js guide.
Frequently asked questions
- How long should an OTP last?
- Ten minutes is common for email codes. Shorter is safer, but email delivery is not instant, so very short windows frustrate real users.
- How many digits?
- Six digits is the usual choice, paired with a limit on attempts and on how often a new code can be requested.
- Can I resend a code?
- Yes, but issue a new code and invalidate the old one, and cap resends per hour per account.
Send your first OTP email
Create a workspace, verify a domain, and make your first API call.