Short Answer
The basic tutorial is straightforward: generate a code, save a challenge, send the email, and verify the submitted code later. That works in a happy-path demo. The production work starts when users click resend, emails arrive out of order, attackers guess codes, or delivery happens after the TTL is already gone.
Set Up Mailgun
Start with the provider account and sender identity. Keep this layer boring: the provider should receive one clear transactional message with the code and enough tags to debug delivery.
pnpm add mailgun.js form-data- Create a Mailgun API key.
- Configure and verify the sending domain.
- Tag OTP messages separately from other transactional email.
- Set MAILGUN_API_KEY in your server environment.
- Set MAILGUN_DOMAIN in your server environment.
- Set OTP_FROM_EMAIL in your server environment.
Create The OTP Challenge First
The email send should be downstream from your OTP state. Create the challenge first, store only a hash of the code, and keep the plaintext code in memory only long enough to render the email.
import { createHash, randomInt, randomUUID } from "node:crypto";
function createCode() {
return randomInt(0, 1_000_000).toString().padStart(6, "0");
}
function hashCode(code: string, secret: string) {
return createHash("sha256").update(`${secret}:${code}`).digest("hex");
}
export async function createOtpChallenge(email: string, purpose: string) {
const code = createCode();
const challenge = {
id: randomUUID(),
email: email.trim().toLowerCase(),
purpose,
codeHash: hashCode(code, process.env.OTP_HASH_SECRET ?? ""),
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
attemptsRemaining: 5,
};
await db.otpChallenges.insert(challenge);
return { challengeId: challenge.id, code };
}This is intentionally incomplete. It sends a working OTP, but it does not yet handle idempotent resend. The first version shows the shape of the work before we tighten the state machine.
Send The Code With Mailgun
Once the challenge exists, pass the email address and code to the provider. Keep the message short, transactional, and specific to the action the user started.
import formData from "form-data";
import Mailgun from "mailgun.js";
const mailgun = new Mailgun(formData);
const client = mailgun.client({
username: "api",
key: process.env.MAILGUN_API_KEY ?? "",
});
export async function sendOtpEmail(email: string, code: string) {
await client.messages.create(process.env.MAILGUN_DOMAIN ?? "", {
from: `Acme <${process.env.OTP_FROM_EMAIL ?? "[email protected]"}>`,
to: [email],
subject: `Your Acme verification code is ${code}`,
text: `Your verification code is ${code}. It expires in 10 minutes.`,
html: `<p>Your verification code is <strong>${code}</strong>.</p>
<p>It expires in 10 minutes.</p>`,
"o:tag": ["otp"],
});
}Verify Against Your Challenge State
Verification should check the challenge ID, expiration, attempt budget, and hashed code. Do not search for any matching code across all users or purposes.
export async function verifyOtp(challengeId: string, submittedCode: string) {
const challenge = await db.otpChallenges.findById(challengeId);
if (!challenge || challenge.expiresAt <= new Date()) {
return { ok: false, reason: "expired" };
}
if (challenge.attemptsRemaining <= 0) {
return { ok: false, reason: "locked" };
}
const submittedHash = hashCode(submittedCode, process.env.OTP_HASH_SECRET ?? "");
if (submittedHash !== challenge.codeHash) {
await db.otpChallenges.decrementAttempts(challengeId);
return { ok: false, reason: "invalid" };
}
await db.otpChallenges.markVerified(challengeId);
return { ok: true };
}What You Just Did Not Build
At this point you can send a code email with Mailgun. You have not yet built the parts that make OTP reliable under real user behavior.
Idempotent resend
Same email and purpose should reuse the same active code for the TTL instead of replacing it.
Code hashing
The database should store a hash of the code, not the plaintext answer visible in the email.
Attempt lockout
Verification needs a max-attempt budget so a six-digit code does not become an unlimited guessing game.
Rate limits
Limit sends and verifies by tenant, email, IP, purpose, and active challenge.
Delivered within TTL
Provider acceptance is not the same as the user receiving a usable code before expiration.
Purpose isolation
A login code should not verify signup, report access, account deletion, or another high-risk action.
- Mailgun sends the message, but the code still needs to be generated once, hashed, stored, reused, expired, and verified by your backend.
- Mailgun events help you inspect delivery, but your OTP flow still needs a policy for delayed or bounced messages inside a short TTL.
- You still need per-email, per-purpose, per-IP, and per-tenant limits so resend does not become inbox flooding.
Make Resend A Read-Or-Create
The key production change is to look for an active challenge before generating a new code. If one exists for the same tenant, normalized email, and purpose, reuse it and send another email containing the same code.
sendOtp(email, purpose):
key = tenant_id + normalize(email) + purpose
active = findActiveChallenge(key, now)
if active:
sendEmail(active.code)
return active.id, active.expiresAt, reused: true
challenge = createChallenge(key, ttl: 10 minutes)
sendEmail(challenge.code)
return challenge.id, challenge.expiresAt, reused: falseThis is the behavior the user expects. Clicking resend should help them receive the answer, not silently change the answer while an older email is still visible.
The Two-Endpoint Shortcut
If you use Mailgun directly, your app owns both the sender integration and the OTP state machine. sendotp.email moves the OTP behavior behind two endpoints.
POST /v1/send
{
"email": "[email protected]",
"purpose": "login"
}
POST /v1/verify
{
"id": "otp_01JZ4NQ8F2T7G2A9J6P0G5QX3K",
"code": "493021"
}The product invariant is the part these tutorial builds tend to rediscover late: the same email plus the same purpose returns the same active OTP for 10 minutes.
Official Provider Docs
Provider setup details change over time, so use the official provider reference when wiring the sender integration.
sendotp.email shortcut
Use Mailgun to send mail, or skip the OTP state machine.
sendotp.email is built for the behavior these tutorials need after the first demo works: stable 10-minute codes, purpose isolation, hashed verification, attempt budgets, and calm resend semantics.
FAQ
Can Mailgun send OTP emails?
Yes. Mailgun can deliver the email that contains the code. Your backend still needs the OTP storage and verification rules.
Should I use Mailgun webhooks for OTP?
Use them for observability, bounce handling, and delivery diagnostics. Do not make code validity depend only on webhook timing.
What does sendotp.email simplify compared with Mailgun?
It gives you send and verify endpoints for the OTP behavior, so you do not have to build challenge reuse, hashing, attempt lockout, and rate limits yourself.