Short Answer

SendGrid is the email sender, not the OTP system. Use it to deliver the code, then build the state machine around code creation, resend, verify, expiry, and lockout.

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 SendGrid

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 @sendgrid/mail
  • Create a SendGrid API key with mail send permission.
  • Authenticate the sending domain before production traffic.
  • Use categories or custom args so OTP mail is observable separately from marketing mail.
  • Set SENDGRID_API_KEY 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 SendGrid

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 sgMail from "@sendgrid/mail";

sgMail.setApiKey(process.env.SENDGRID_API_KEY ?? "");

export async function sendOtpEmail(email: string, code: string, purpose: string) {
  await sgMail.send({
    to: email,
    from: process.env.OTP_FROM_EMAIL ?? "[email protected]",
    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>`,
    categories: ["otp"],
    customArgs: {
      purpose,
    },
  });
}

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 SendGrid. 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.

  • SendGrid accepts mail send requests, but your database still needs one active OTP per email and purpose.
  • SendGrid event data can help with observability, but it does not automatically make a code delivered before the challenge expires.
  • You still need abuse controls for send volume, repeated verify guesses, and users who press resend several times.

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: false

This 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 SendGrid 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.

Twilio SendGrid Mail Send API docs

sendotp.email shortcut

Use SendGrid 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 SendGrid send verification codes?

Yes. Use SendGrid to send the email body that contains the code. Keep the OTP challenge and verification rules in your backend.

Does SendGrid provide OTP verification?

No. SendGrid is an email delivery API. Verification code generation, hashing, expiry, and attempt lockout are application behavior.

What should I track for SendGrid OTP emails?

Track message IDs, purpose, challenge ID, send attempts, delivery events, bounces, and whether delivery arrived while the code was still valid.