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

  1. User submits their email on your form.
  2. Backend generates a code with a cryptographically secure random source.
  3. Backend stores a hash of the code, the user, an expiry and an attempt counter.
  4. Backend calls the Inboxili API.
  5. 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. Use randomInt or 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.

Related