Skip to main content
MailCub Logo Image
Guidelines

Idempotency for Email Sends: Prevent Duplicate Messages

By MailCub TeamFeb 24, 202613 min read

Introduction

Duplicate emails rarely come from bad templates. They usually come from retries, timeouts, queue redelivery, and users pressing “Resend” multiple times. One slow request can trigger a retry that sends the same OTP twice.

That is why idempotency for email sends matters. The goal is not to disable retries. The goal is to make retries safe so that if the same request is processed more than once, the user still gets only one logical email.

This guide is for SaaS and development teams sending transactional email. You will learn a production-ready approach: choose a stable idempotency key, enforce it with a unique constraint, enqueue atomically, and return the same result on duplicates. You will also see where idempotency must exist (API and worker), and how to debug the most common duplicate-send issues. You can review setup and sending references in MailCub Documentation and explore the Transactional Email service for implementation context.

Quick Answer

  • Generate an idempotency key per logical email event (OTP attempt, reset request, receipt).
  • Store (tenant_id, idempotency_key) with a unique index as the dedupe gate.
  • Only enqueue or send when the key is new; duplicates should return the original message_id.
  • Make workers idempotent too, because queues can run the same job more than once.
  • Use TTL/expiry on keys so storage does not grow forever.
  • Track final outcomes via logs and events after accepted/queued status.

Why It Matters

Duplicates break user trust quickly, especially for OTP and password reset emails. Users try multiple codes, get confused, and support tickets increase.

Duplicates also amplify volume during incidents. When an outage triggers retries, duplicate sends can turn into a send storm, which makes rate limits and blocks more likely.

Idempotency is the control that keeps retries enabled for reliability without multiplying side effects such as duplicate messages.

How Idempotency for Email Sends Works

Idempotency means the same request produces the same result. The first request creates a record and schedules delivery.

If the client retries with the same idempotency key, your system should return the existing record instead of scheduling a new send.

Think of it as an exactly-once effect built on top of systems that are naturally at-least-once.

Idempotency for Email Sends in Queues and Retries

Even if your API is idempotent, your queue worker might still run the same job twice. That is normal behavior in many queue systems.

So you need idempotency in two places: the API handler and the worker. If either one is not idempotent, duplicates can still leak through.

Choosing an Idempotency Key Strategy

Strategy Key example Best for Pros Cons
Client UUID uuid() per send intent internal services simple, strong uniqueness client must reuse key on retries
Business entity key userId:resetTokenId password reset maps to business intent must define scope carefully
Windowed hash hash(recipient+template+vars+5min) OTP resend dedupes bursty resends risk of false dedupe if vars change
Server-issued key server returns key, client reuses public API consistent for client retries extra round trip

Step-by-step Solution

1) Define what “the same email” means

Write a rule per message type:

  • OTP: same user + purpose within a short window
  • Password reset: same reset token / request id
  • Receipts: same order id + recipient

If this definition is unclear, dedupe will be either too weak or too aggressive.

2) Generate and persist the idempotency key

Generate the key where retries happen. If the request times out, the retry must reuse the same key. If you generate a new key on retry, idempotency will not work.

3) Enforce dedupe with a unique constraint

Store a row with fields like:

  • tenant_id
  • idempotency_key
  • message_id
  • status (CREATED, QUEUED, SENT, FAILED)
  • created_at, expires_at

Create a unique index on (tenant_id, idempotency_key).

Handler logic:

  • insert intent row
  • if insert succeeds → enqueue
  • if insert conflicts → return existing message_id/status

4) Make enqueue atomic (avoid “saved but never sent”)

A common failure is:

  • idempotency record is inserted
  • enqueue fails
  • retries now hit duplicate, but no job exists

Fix this with one of these patterns:

  • transactional outbox (intent + outbox row in one DB transaction)
  • a reliable publisher that drains outbox rows

This is the difference between “dedupe works” and “dedupe causes stuck sends.” You can use MailCub Documentation as your integration reference while implementing the send path and delivery tracking.

5) Make the worker idempotent (job replay safe)

Protect the send step with a worker-side guard, such as:

  • a send lock by message_id, or
  • a send attempts row that only allows one first-attempt send

If the queue redelivers the job, the guard prevents a second outbound send.

6) Handle throttling and retries without creating duplicates

When you hit rate limits or transient errors, schedule a retry for the same message_id.

Do not create a new idempotency key. Do not create a new message record. Retries should advance the same logical send.

Use backoff and jitter, cap attempts, and keep a dead-letter path for messages that fail permanently.

7) Confirm final outcomes via events and logs

A provider accepted response is not the same as delivered. Track final outcomes using delivery logs and webhook events (delivered, bounced, complaint, unsubscribe) and update status cleanly without resending.

The Transactional Email product and MailCub Documentation can be used to implement and monitor this flow. You can also review MailCub Pricing if you are planning rollout for production volume.

Common Mistakes

  • Creating a new idempotency key on every retry.
  • Deduping only by recipient + template, which blocks valid sends.
  • No atomic enqueue/outbox, so duplicates are avoided but sends get stuck.
  • Not scoping by tenant, causing key collisions across customers.
  • Keeping idempotency keys forever without TTL.
  • Protecting only the API and not the worker, so queue replay still creates duplicates.

Troubleshooting

We still see duplicates sometimes

Check the following:

  • Does the client reuse the same key on retries?
  • Is there a DB unique constraint, or only an in-code check?
  • Can the worker run the same job twice without a guard?

Fixes:

  • DB unique index + consistent key reuse
  • worker send lock by message_id
  • idempotent side effects (no “send again” on partial failures)

Requests return duplicate but nothing was sent

This usually points to an enqueue/outbox gap.

Fixes:

  • transactional outbox
  • monitoring for “intent exists but not queued”
  • safe republish of outbox rows

FAQ

What is idempotency for email sends?

It means repeating the same send request produces the same effect as one request, preventing duplicate emails during retries.

Where should I generate the idempotency key—client or server?

Generate it where retries occur and persist it across retries. For internal calls, client UUIDs work well. For public APIs, server-issued keys can be simpler.

How long should idempotency keys live (TTL)?

At least as long as your retry window (hours to days for transactional sends). Use TTL/expiry so storage stays bounded.

How do I prevent duplicates from queue redelivery?

Make the worker idempotent using a send lock or attempts table keyed by message_id.

Should I dedupe OTP resends or allow multiple sends?

Usually dedupe within a short window per login attempt, then generate a new key for a new attempt. This avoids spam while still allowing legitimate resends.

What is the simplest idempotency design for a small SaaS?

Client UUID + DB unique index + enqueue-on-insert + worker send lock + TTL cleanup.

Conclusion

Idempotency is the foundation of reliable transactional email. It lets you keep retries, which are necessary for resilience, without creating duplicate OTPs, resets, or receipts.

If you implement a stable idempotency key, enforce it with a unique constraint, make enqueue atomic, and add worker-side guards, you will eliminate most duplicate-send incidents while keeping your system retry-friendly.

To put this into production, start with MailCub Documentation, use the Transactional Email service for sending and event tracking, and review MailCub Pricing for production planning.

Tags:
idempotency for email sendsidempotency keyprevent duplicate emailstransactional email retriesmessage deduplicationoutbox patternat-least-once deliveryemail queue workerwebhook eventssuppression list

You Might Also Like