Email sends are side effects. A timeout, queue replay, or user retry can turn one logical send into two delivered messages unless the request carries a stable key. This guide explains how to design idempotency keys for transactional email APIs, where provider support matters, and what fallback dedupe needs to cover.
last updated 2026-05-074 sections
section 01
How idempotency keys work
An idempotency key identifies one logical send attempt. The sender includes the same key every time it retries that send. The provider stores the first result for a limited window, then returns the stored result or rejects the duplicate instead of sending another message.
part
job
failure it prevents
Stable key
Names the logical send, not the HTTP attempt.
Duplicate email after timeout.
Provider cache
Stores the accepted request and response for a retention window.
Duplicate send after retry.
Client retry
Reuses the original key until the send succeeds or is abandoned.
New key per retry.
Local ledger
Records the key and message state in the app database.
Provider cache expiry surprises.
section 02
Generating keys that survive retries
A useful key is deterministic for the action that caused the send. Password reset, magic link, receipt, and invoice emails should derive the key from the business event ID plus the template or stream name. Random UUIDs are fine only when they are generated before the first attempt and stored before the request leaves the app.
okDerive the key from a durable event ID when one exists.
okStore the key before calling the provider.
okReuse the same key for every retry of the same logical send.
okScope keys by message stream when the same event sends multiple emails.
okDo not include mutable template copy or recipient display names in the key.
section 03
Retry windows and provider gaps
Provider idempotency windows are not a substitute for application state. A queue can replay a job after the provider cache expires, a worker can crash between API success and database update, and some providers expose no first-class idempotency key at all. Treat provider idempotency as one layer, then keep a local send ledger for final control.
case
risk
control
Provider supports keys
Cache expires before late replay.
Keep local send state keyed by event ID.
Provider lacks keys
Every HTTP retry can send again.
Lock locally before each provider call.
Worker crash after send
App does not record provider success.
Store pending state first, then reconcile by message ID.
Template fan-out
One event sends several messages.
Include template or stream in the key.
section 04
Testing duplicate-send protection
Idempotency tests should force the failure modes that real systems hit: client timeout, worker retry, queue replay, and webhook delay. The passing result is boring: one provider message ID, one send ledger row, and no second delivery attempt.
okSend once, retry the exact request, confirm only one provider message ID.
okCrash the worker after provider success, then replay the job.
okReplay the queue job after the normal retry window.
okSend two different templates from the same event and confirm both are allowed.