Short Answer

Make send a read-or-create operation. If an active OTP already exists for the same tenant, normalized email, and purpose, return it and send it again. Only create a new code when the active challenge has ended.

Idempotency is the difference between a resend flow users can trust and a flow that randomly rejects visible emails. Email delivery is not instant or ordered, so the backend should not depend on users opening the newest message.

Build The Key Around Intent

The idempotency key should describe exactly what the user is trying to prove. In most products that means tenant context, normalized email address, and purpose.

key = [
  tenant_id,
  normalize_email(email),
  purpose
].join(":")

Purpose is not optional. A login code, signup verification code, report-access code, and high-risk action code are different challenges even when they go to the same inbox.

The Read-Or-Create Algorithm

The send endpoint should first look for an active challenge. If one exists, reuse its code and expiration. If none exists, create a new challenge with a fresh code and TTL.

sendOtp(email, purpose):
  key = tenant_id + normalize(email) + purpose
  active = findActiveChallenge(key, now)

  if active:
    enqueueEmail(active.code)
    return active

  challenge = createChallenge(key, ttl: 10 minutes)
  enqueueEmail(challenge.code)
  return challenge

In production, that lookup and create step needs to be atomic. Two quick clicks should not create two active codes for the same key.

End The Challenge Deliberately

Active

Send and resend reuse the same code. Verify can succeed, fail an attempt, or expire the challenge.

Verified

The product grants access and prevents replay. A later login can create a new challenge.

Expired

The TTL has elapsed. The next send creates a new code with a new expiration window.

Locked

Too many failed attempts ended the challenge. A later retry should follow your risk policy.

Implementation Checklist

  • Normalize email addresses before lookup.
  • Include tenant context in multi-tenant products.
  • Include purpose so different flows stay isolated.
  • Use one active challenge per idempotency key.
  • Keep verify attempt limits on the challenge.
  • Mark a successful challenge as verified to prevent replay.
  • Rate-limit send even when the active code is reused.

sendotp.email invariant

Same email + same purpose = same active code.

This is the core product behavior: a stable 10-minute OTP window for login, signup verification, generated reports, private content, and high-risk actions.

FAQ

What does idempotent OTP mean?

It means repeated send requests for the same tenant, normalized email address, and purpose return the same active challenge during the TTL instead of creating a new code every time.

Does idempotent send mean only one email is sent?

No. The system can still send another email on resend. Idempotency means the code and challenge state are reused, so every email in the active window contains the same answer.

What should be in an OTP idempotency key?

Use tenant or project context, the normalized email address, and a purpose such as login, signup-verification, report-access, or account-delete.

When should a new OTP be generated?

Generate a new OTP when there is no active challenge, after the previous challenge expires, after it is verified, or after it is locked by too many failed attempts.