Why Transactional Email Needs Idempotency Keys
Retries and duplicate triggers cause double sends unless your transactional email is idempotent. Here is how idempotency keys stop the duplicate receipt problem.
Transactional email needs idempotency keys because networks fail halfway and code retries, and without a key each retry sends another copy. The user gets three password resets, two receipts, five "your order shipped" emails, and now they trust you less and your reputation drops. An idempotency key makes "send this email" safe to call more than once: the first call sends, every duplicate call is a no-op. If you send email off the back of anything unreliable (and everything is unreliable), you need this.
Why do duplicate emails happen in the first place?
Because the send is rarely a single clean action. Your code triggers a send, the request to the provider times out, and your code does not know whether the email went. So it retries, which is the correct thing to do for reliability, and now it might have sent twice. Multiply that across queue redelivery, webhook retries, and at-least-once message delivery, and duplicates are not an edge case; they are the default behavior of distributed systems.
Message queues promise at-least-once delivery on purpose, because at-most-once means you sometimes lose the message entirely. At-least-once means you sometimes process it twice. That is a good tradeoff for reliability and a bad one for email, unless the send itself is idempotent. This is the same reliability thinking behind putting transactional email through a queue at all: you accept redelivery and design the send to tolerate it.
What is an idempotency key for email?
A stable, unique identifier for the specific email you intend to send, derived from the event, not from the moment of sending. "Order 8842 shipped confirmation" has a key like order-8842-shipped. "Password reset for token abc" has a key tied to that token. The key names the intent, so any retry of that same intent carries the same key.
You pass the key with the send request. The provider (or your own send layer) records it. The first send with that key goes out and the key is marked used. Any later send with the same key is recognized as a duplicate and dropped. The retry still happens, your code still calls send, but the email only goes once. Good application-mail providers accept an idempotency key on the API for exactly this reason, which is one more thing I look for in developer-first application email.
How do I choose a good idempotency key?
Make it deterministic and specific. Deterministic means the same logical event always produces the same key, so a retry reconstructs the identical string rather than a fresh random one. If you generate a new UUID at send time, every retry has a new key and idempotency does nothing.
Specific means it identifies the exact email, not just the user. user-123 is too broad; it would suppress a legitimate second, different email to that user. user-123-welcome or reset-<token-hash> names one intended message. Get the scope right: too broad and you drop emails you meant to send, too narrow and duplicates slip through.
Watch the boundary between "same intent" and "new intent." A user requesting a second, fresh password reset is a new intent and needs a new key, tied to the new token. The idempotency key protects against accidental resends of the same event, not against the user deliberately asking again.
Do I need this if I already dedupe in my database?
Sometimes your own state gives you idempotency for free. If you set welcome_sent = true and check it before sending, you have hand-rolled the same guarantee. That works, but it only covers the path you remembered to guard, and it breaks if the flag write and the send are not atomic (you send, then crash before setting the flag, then retry and send again).
An idempotency key at the send boundary is a backstop that covers every path, including the ones you forgot. Belt and suspenders: guard with your own state where it is natural, and pass an idempotency key so the send layer catches what your state missed. Duplicate emails are exactly the kind of avoidable mistake that shows up in a list of transactional email mistakes to avoid, and this is the clean fix.
Make idempotency the default, not the exception
The failure mode is treating idempotency as something you add after the duplicate-email incident. Build it in from the first send. Every transactional email your app produces should carry a deterministic key, so the answer to "did this send twice" is always no, by construction.
Use a sender that supports idempotency keys natively so you are not reinventing the ledger. Usermails accepts an idempotency key on the send API, which means safe retries without duplicate inboxing. Key every send by intent, keep the key deterministic and correctly scoped, and retries stop turning into three copies of the same receipt.