Email Idempotency Keys: Prevent Duplicate Sends
A practical framework for using email idempotency keys to make retries safe, prevent duplicate sends, and keep email operations observable.
Overview
Email idempotency keys are stable identifiers that tell your sending system, 'this logical email has already been requested.' When a worker crashes, a webhook arrives twice, or an API client retries after a timeout, the key lets the system return the original result instead of sending the same password reset, receipt, onboarding nudge, or campaign message again.
The safest model is simple: every logical send gets one deterministic key, every send attempt is stored, and retries reuse the key until the message reaches a terminal state. That turns duplicate prevention from a hopeful code comment into a database-backed operating rule.
Why duplicate emails happen in healthy systems
Duplicate emails are usually not caused by careless developers clicking 'send' twice. They happen because good production systems retry work: HTTP clients retry after timeouts, queues redeliver jobs after worker crashes, providers retry webhooks, and operators replay failed batches after an incident. Without a stable idempotency boundary, every retry can look like a brand-new send request.
Email makes this worse than many product events because the side effect is external. You can delete a duplicate database row; you cannot unsend a second invoice email or a repeated security alert. Idempotency gives the system a memory of intent so retry logic can be aggressive without becoming recipient-hostile.
- Timeouts can hide a successful provider request from your app
- Queue workers can crash after sending but before marking the job complete
- Webhook replays can trigger downstream automation more than once
- Manual incident replays are safer when the dedupe rule is already enforced
Design the key around the logical email, not the attempt
An idempotency key should describe the email the recipient is supposed to receive, not the worker attempt that happens to process it. If a password reset request creates one email, the key might combine `password_reset`, the user ID, and the reset token ID. If a receipt is tied to an order, use the order ID and receipt type. If a campaign targets a contact, combine campaign ID, recipient ID, and variant ID.
Do not use random UUIDs generated inside the worker as your only key. A random value per attempt prevents deduplication because every retry looks unique. Random IDs are useful for provider message IDs and attempt rows; the idempotency key should remain stable across retries of the same logical send.
- The same logical email should always produce the same key
- Different variants or materially different versions should get distinct keys
- Include recipient identity when a campaign sends the same message to many people
- Keep keys opaque enough that logs do not leak personal data
| Mailstream | Good key shape | Avoid |
|---|---|---|
| Password reset | password_reset:user_id:reset_token_id | A new UUID per worker retry |
| Receipt | receipt:order_id:receipt_version | Customer email plus timestamp only |
| Trial onboarding | onboarding:user_id:step_id | A daily cron timestamp with no user state |
| Campaign | campaign:campaign_id:recipient_id:variant_id | Only the campaign ID |
| Webhook-triggered follow-up | followup:source_event_id:rule_id | The webhook delivery attempt ID |
Store a send state machine, not just a sent flag
A single boolean like `sent = true` is too weak for real email operations. Store the key, recipient, template or campaign version, provider, provider message ID, current state, first requested time, last attempt time, attempt count, and terminal result. Then make the idempotency key unique in the database for the scope that matters: usually workspace plus mailstream plus key.
The state machine should let callers distinguish 'already accepted' from 'still processing' and 'safe to retry later.' A request that finds the same key in `accepted` can return the original provider message ID. A request that finds `processing` can return a 202-style response or wait behind a lock. A request that finds a terminal failure can decide whether the same key may be retried or whether a new corrected send is required.
- Use a unique constraint, not only application-level checks
- Separate logical send intent from provider attempts
- Keep provider message IDs connected to your internal send IDs
- Make duplicate requests observable so clients learn they are retrying too often
- Create a send-intent row with a unique idempotency key before calling the provider.
- Acquire the row or lock so only one worker performs the external send.
- Record every provider attempt with timestamps and responses.
- Move the intent to accepted, failed, suppressed, or cancelled with a clear reason.
- On duplicate requests, return the existing state instead of creating a new send.
Make retries, webhooks, and replays idempotent together
Sending idempotency is only half the pattern. Delivery, bounce, complaint, and open/click webhooks can also be delivered more than once or arrive out of order. Treat provider event IDs, provider message IDs, and event timestamps as their own dedupe layer so analytics, suppression, and automations do not double-count the same signal.
For batch or campaign replays, require the replay tool to reuse the original idempotency key for each recipient unless the operator explicitly creates a new send version. That small constraint prevents the most painful incident pattern: a team fixes a partial failure by replaying the whole segment and accidentally emails everyone twice.
- Retry policy and idempotency policy should be designed together
- Webhook dedupe protects both analytics and compliance actions
- Operator replay tools must show which recipients are already terminal
- A new key should mean a genuinely new email, not a hidden retry
| Workflow | Idempotency rule | Operational benefit |
|---|---|---|
| API retry | Client sends the same idempotency key for the same request | Timeouts do not create duplicate messages |
| Queue retry | Worker locks the existing send-intent row | Crashes are safe to recover from |
| Webhook replay | Store provider event ID plus message ID | Analytics and suppression are not double-applied |
| Campaign replay | Reuse campaign-recipient-variant keys | Partial failures can be retried without resending successes |
| Template correction | Create a new versioned key deliberately | A corrected email is explicit, not accidental |
Implementation checklist for idempotent email sends
Start with the mailstreams where duplicates hurt most: password resets, login codes, receipts, invoices, trial lifecycle messages, and high-volume campaigns. Add idempotency at the boundary where your product asks for an email, not deep inside a provider adapter where business context has already been lost.
Then test the ugly cases on purpose. Simulate a provider timeout after acceptance, kill a worker after the provider call but before local completion, replay a webhook twice, and rerun a partially failed campaign segment. If every test returns the same logical send result or cleanly skips already terminal recipients, the system is ready for real incidents.
- Do not wait for a duplicate-send incident to add the unique constraint
- Make the key visible in logs and support tooling
- Version keys when the content or recipient intent truly changes
- Keep suppression checks inside the same send-intent workflow
- Define the logical-send key shape for each transactional and campaign mailstream.
- Add a database unique constraint on workspace, mailstream, and idempotency key.
- Record send-intent rows before external provider calls.
- Store provider attempts separately from the logical send result.
- Return existing state for duplicate API calls or queue jobs.
- Dedupe webhook events before updating analytics, suppression, or automations.
- Build replay tooling that reuses original keys by default.
Where Mailbase fits in idempotent email operations
Mailbase sits above the sending layer, so the practical value is workflow visibility: durable send jobs, scheduled sends, analytics, suppression/compliance controls, reply context, and webhook ingestion all benefit from stable send identity. If a campaign worker retries or a provider event arrives twice, operators need to know whether the recipient actually received one email, two attempts, or no accepted message at all.
For teams using Mailbase with hosted sending or a BYO useSend setup, idempotency still belongs in the operating model. The workflow layer can make send state, retries, replies, and suppression easier to manage, but the team should still design each transactional or campaign trigger so the same logical send has one stable identity.
- Use durable send jobs and analytics to separate attempts from accepted sends
- Connect suppression and unsubscribe checks before retrying optional mail
- Keep reply context tied to the original campaign or transactional trigger
- Use MCP or internal tooling to inspect send state before replaying a failed batch
Common Mistakes
- Choosing a tool before deciding who owns deliverability.
- Treating DNS authentication as a one-time checkbox instead of an operating baseline.
- Mixing product-critical transactional email with experimental marketing sends, with no clear boundary.
- Trusting headline metrics (like open rate) that privacy proxies now inflate.
Sources & Further Reading
Official docs for current setup details, pricing, and API behavior — verify specifics there, since they change.
Related guides
More on email idempotency keys and the surrounding email infrastructure workflow:
FAQ
What is an email idempotency key?
An email idempotency key is a stable identifier for one logical email send. If the same request is retried, the system uses the key to return the original send state instead of sending another copy.
Should an idempotency key be random?
It can include opaque IDs, but it should not be a new random value for every retry. The key must stay the same for the same logical email, such as a receipt for one order or one campaign variant for one recipient.
Do idempotency keys replace email retry logic?
No. They make retry logic safe. You still need retry policies for temporary failures, but the idempotency key prevents those retries from creating duplicate external sends.
Where should email idempotency be enforced?
Enforce it at the send-intent boundary with a database unique constraint, before the external provider call. Application checks are useful, but the database constraint is what protects you during concurrency, crashes, and replay tools.