Short Answer

Postmark 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 Postmark

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 postmark
  • Create a Postmark server token.
  • Verify the sender signature or sending domain.
  • Use a transactional message stream for OTP traffic.
  • Set POSTMARK_SERVER_TOKEN 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 Postmark

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 postmark from "postmark";

const client = new postmark.ServerClient(process.env.POSTMARK_SERVER_TOKEN ?? "");

export async function sendOtpEmail(email: string, code: string) {
  await client.sendEmail({
    From: process.env.OTP_FROM_EMAIL ?? "[email protected]",
    To: email,
    Subject: `Your Acme verification code is ${code}`,
    TextBody: `Your verification code is ${code}. It expires in 10 minutes.`,
    HtmlBody: `<p>Your verification code is <strong>${code}</strong>.</p>
<p>It expires in 10 minutes.</p>`,
    MessageStream: "outbound",
    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 Postmark. 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.

  • Postmark handles the transactional send, not the challenge lifecycle behind the send button.
  • You still need to prevent code supersession when users click resend or submit from an older email.
  • You still need to record bounces and delivery delay against the OTP TTL so support can distinguish mail problems from state problems.

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

Postmark send a single email docs

sendotp.email shortcut

Use Postmark 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

Is Postmark good for OTP emails?

Yes. It is built for transactional email. The OTP correctness work still lives in your application state.

What is the biggest Postmark OTP mistake?

Treating every resend as a fresh code. That can make the code in the user's visible email invalid before they type it.

Can sendotp.email use Postmark under the hood?

sendotp.email is positioned above the sender layer. The important API surface is the two-endpoint OTP behavior, not which transactional sender you would otherwise wire up.