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.
Sending and verifying a one-time passcode by emailUserYour serverSendpositoryInboxenter emailcode = randomInt()store sha256(code), expiryPOST /v1/emailsIdempotency-Key: otp-…200 { id }delivered in secondsreads 481920 from the subjectsubmit 481920compare hashexpired? 5 wrong tries?signed in

Swipe the diagram to see the rest

An email OTP, end to end. Only a hash of the code is stored, the send is idempotent, and verification checks expiry and attempts.

What to include

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

Send it

Node.js
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.

otp.ts (Express)
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

PieceStops
crypto.randomIntPredictable codes - Math.random is not a secure generator
Hash in the databaseA database leak exposing live codes
timingSafeEqualTiming attacks that guess the hash byte by byte
Attempt counterBrute force - a 6-digit code falls to a million guesses, not five
One send a minuteUsing your form to flood someone else's inbox
idempotencyKeyTwo different emails when a timed-out request is retried
Delete on successReplaying 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

Send your first email in minutes

6,000 emails a month (up to 200 a day), free forever. No card needed.