It's 2:14 a.m. A user can't log in, so they hit "Forgot password". Your API calls the email provider. The provider queues the email. And then, somewhere between their data center and yours, the response quietly dies. Timeout. Dropped connection. A deploy that picked exactly this moment to restart the worker.
Your code does the responsible thing and retries. The provider, having no idea it has met this request before, does the other responsible thing and sends it again.
The user now has two reset emails with two different links, and a growing suspicion that one of them is a phishing attempt. They click the older one. It's expired. They open a support ticket with the subject line "your app is broken???" (three question marks, which is how you know it's serious).
You can't fix this by removing retries; that just trades duplicate emails for lost ones, which is worse. The real fix is to make the retry recognisable. That is exactly what an idempotency key for transactional email does, and it's one of those tiny changes that saves you from a whole category of 2 a.m. bugs.
This post covers how to pick a key that actually works, what your code should do with each response, and the sneaky mistakes that break it without telling you.
What an idempotency key does
An idempotency key is a string you send with the request that names the intent: "the reset email for reset request 8412". The provider remembers keys it has seen:
- New key: the request is processed and the email is sent.
- Key seen before, same payload: nothing new is sent; you get the original result back.
- Key seen before, different payload: the request should be refused, because that is almost always a bug in the caller.
The whole pattern rests on one rule: every retry of the same logical email must carry the same key. Break that rule and you've built a very elaborate way of sending duplicates.
Choosing a key: derive it from your data, not the HTTP call
The key has to be decided before the first attempt and never regenerated. That means it should come from the event that caused the email.
| Key | Safe? | Why |
|---|---|---|
pwreset-8412 (reset request id) |
Yes | Every retry of that reset carries the same key |
receipt-order-5531 |
Yes | One receipt per order, however many times the job runs |
crypto.randomUUID() at call time |
No | A retry generates a new UUID, so it looks like a new email |
| The recipient's address | No | Their legitimate second reset next month looks like a repeat |
A random UUID is fine if you generate it once, store it with the event, and read it back on retry. The failure mode is generating it inside the function that makes the HTTP call. That code looks perfectly reasonable in review, passes every test, and then sends two receipts the first time production has a bad day.
A simple convention that scales: <email-type>-<source-entity-id>, for example welcome-user-1207, invoice-inv-2026-0042, shipping-order-5531-parcel-2.
A retry loop that is safe to run twice
Here is the shape in JavaScript. The important part is that idempotencyKey is computed once, outside the loop.
async function sendResetEmail(resetRequest) {
const idempotencyKey = `pwreset-${resetRequest.id}`; // decided once
for (let attempt = 1; attempt <= 5; attempt++) {
try {
const res = await fetch(EMAIL_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.EMAIL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
trigger_key: "password_reset",
to: resetRequest.email,
variables: { action_url: resetRequest.url },
idempotency_key: idempotencyKey,
}),
signal: AbortSignal.timeout(10_000),
});
if (res.status >= 500) throw new Error(`retryable ${res.status}`);
return await handleResponse(res); // 2xx and 4xx are final
} catch (err) {
if (attempt === 5) throw err;
await sleep(2 ** attempt * 250); // backoff, same key next time
}
}
}
If the first attempt actually succeeded and only the response was lost, attempt two returns the original result instead of a second email.
Handle the answers, not just the happy path
Retrying is only correct when the outcome is unknown. Most non-2xx answers mean "fix something", not "try again". This is the table Email Digit publishes for its transactional endpoint, and it is a good template for any provider:
| Response | Meaning | Retry? |
|---|---|---|
202, status duplicate
|
Same key, same payload: original message ids returned | No, record it as success |
202, status suppressed
|
Address hard bounced or complained before; nothing sent | No |
| 409 | Key reused with a different payload, or the journey isn't active | No, fix the cause |
| 404 | No journey has that trigger key | No, fix the key |
| 422 | Missing recipient, or sending domain not verified | No |
| 403 | API key lacks the send scope | No |
| Timeout or 5xx | Outcome unknown | Yes, same key |
That 409 on "same key, different payload" is worth wiring to an alert. It usually means someone built the key from the user id instead of the event id, so two different emails collided. It's the API equivalent of a coworker saying "hey, quick question": never as quick as advertised, always worth answering.
Mistakes that break idempotency quietly
- Regenerating the key per attempt. The most common one, and the most humbling. If your logs show a different key on each retry, you have no protection, just extra steps.
- Keying on the recipient. Two genuine resets a minute apart should send two emails. They need two keys.
- Reusing a key for a different message. Treat keys as permanent. Don't assume a provider forgets them after 24 hours unless it documents that.
- Retrying 4xx errors. A 422 for an unverified sending domain will fail the same way forever. Retrying just burns queue time.
- Sharing keys between test and live. Check your provider keeps them separate, or prefix keys with the environment.
Idempotency is not deduplication of events
An idempotency key stops the same request from sending twice. It does not decide whether two different events should both send. If your app emits two order.paid events for one order, the email API will dutifully send two receipts, because from its point of view you asked twice. You need dedupe upstream, at the event or job level, before you ever reach the email API.
Idempotency keys won't fix your event bus. They will, however, let you sleep through the next network blip, and your support inbox will be noticeably less dramatic.
Further reading
- The full walkthrough, with the exact request and response shapes: retry a transactional email without sending it twice
- Marketing vs transactional email streams, and why they shouldn't share a reputation
- Verify, retry and replay email webhooks safely, the same problem in the other direction
I work on Email Digit, an email platform for marketing and transactional sends with managed SPF, DKIM and DMARC.
Top comments (0)