Idempotency keys and retries for transactional email
Every network call fails eventually, and the failure that matters most is the one where you never learn the outcome. Your request timed out. The message may have been sent, or it may not. Retry and you might mail someone a second password reset; do not retry and they might get nothing at all.
5 min read
The failure that has no safe answer
There are three ways a send can end, and only two of them are actionable.
| Outcome | What you know | What to do |
|---|---|---|
| 2xx response | It was accepted | Nothing |
| 4xx response | It was rejected and not queued | Fix the request. Retrying unchanged will fail again |
| 5xx, timeout, or connection reset | Nothing at all | This is the problem |
That third row is the whole subject. A timeout is not evidence of failure, it is the absence of evidence. The request may have been fully processed a millisecond before your client gave up.
An idempotency key resolves it by moving the deduplication to the server. You attach a key, the server records the outcome against it, and any later request with the same key returns the original result instead of doing the work again.
curl -X POST https://emails.sh/v1/emails \
-H "Authorization: Bearer $EMAILSSH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <hello@acme.com>",
"to": ["someone@example.com"],
"subject": "Reset your password",
"text": "Choose a new password: https://acme.com/reset?token=...",
"idempotency_key": "reset:usr_8b2c:tok_5f91"
}'Send that twice and one email arrives. Both calls return the same id.
Choosing the key
The key has to be stable across retries of the same logical send, and different for any send that is genuinely new. That rules out the obvious choices.
| Key | Verdict |
|---|---|
crypto.randomUUID() at the call site | Useless. A retry generates a new key and sends again |
| The recipient address | Too broad. They can never receive a second message |
| Hash of the whole request body | Works, until you add a timestamp to the body |
<purpose>:<entity id>:<version> | Correct |
That last shape is the one to use, built from data that already exists.
const key = `reset:${user.id}:${token.id}`; // one per issued token
const key = `invoice:${invoice.id}:v1`; // one per invoice, ever
const key = `digest:${user.id}:${weekStartISO}`; // one per user per week
const key = `trial-3d:${sub.id}:${today}`; // one per subscription per dayThe version suffix is worth including from the start. When you genuinely need to re-send something, such as a corrected invoice, bumping v1 to v2 is a one-line change rather than an argument about how to bypass deduplication.
Derive the key where the intent lives, not inside the retry loop. A key computed inside the code that retries is a fresh key on every attempt, which is the same as having none.
export async function sendWithRetry(payload: Record<string, unknown>, key: string) {
const body = JSON.stringify({ ...payload, idempotency_key: key });
for (let attempt = 0; attempt < 5; attempt++) {
try {
const res = await fetch('https://emails.sh/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.EMAILSSH_API_KEY}`,
'Content-Type': 'application/json'
},
body,
signal: AbortSignal.timeout(10_000)
});
if (res.ok) return (await res.json()) as { id: string; status: string };
// A 4xx is a bad request. Retrying it changes nothing and wastes the budget.
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
throw new Error(`rejected: ${res.status} ${await res.text()}`);
}
} catch (err) {
if (attempt === 4) throw err;
}
const backoff = 2 ** attempt * 250;
const jitter = Math.random() * backoff;
await new Promise((r) => setTimeout(r, backoff + jitter));
}
throw new Error('send failed after 5 attempts');
}Note that body is built once, outside the loop. Rebuilding it per attempt is how a timestamp or a regenerated key sneaks in and quietly defeats the whole mechanism.
Retry policy
Retry on: 429, 500, 502, 503, 504, connection resets, and timeouts.
Do not retry on: 400, 401, 403, 404, 422. These describe your request. An unverified sending domain returns 422 and will return 422 forever.
Respect `Retry-After`. When a 429 carries the header, use it instead of your own backoff. Ignoring it turns a brief rate limit into a longer one.
Use exponential backoff with jitter. Without jitter, everything that failed together retries together, and the recovering service is hit by a synchronised wave. The jitter is not optional politeness, it is what stops the retry storm.
Cap the attempts. Five attempts over roughly thirty seconds is reasonable in a request path. Beyond that, hand the message to a queue whose lifetime you control rather than holding a user's request open.
Where retries come from that you did not write
Most duplicate emails are not caused by an explicit retry loop.
- Job runners. Sidekiq, Celery, BullMQ, and Cloudflare Queues all retry on unhandled exceptions. If your job sends mail and then writes to the database, and the write fails, the retry sends again.
- Webhook senders. Stripe, Clerk, and everyone else retry on a non-2xx or a timeout. A handler that sends a receipt and then does slow work will be called twice.
- Users. A double-clicked "resend" button, a refreshed form, an impatient tap on mobile.
- Deploys. A rolling deploy that kills a pod mid-job leaves work that will be picked up again.
The defensive pattern for all four: derive a deterministic key from the event id you were given. stripe:${event.id} and clerk:${data.id} are both perfect keys, stable across every retry the sender makes.
// In a webhook handler, the sender's event id is the natural key.
await sendWithRetry(receiptPayload(event), `stripe:${event.id}`);Ordering is a separate problem
Idempotency prevents duplicates. It does not guarantee order. If you send "your order shipped" and "your order was cancelled" from two concurrent workers, they can arrive in either order, and no key fixes that.
If ordering matters, serialise per entity: process all messages for one order on one queue partition, or take a per-entity lock. Do not try to solve ordering with retry logic.
Scheduling instead of retrying
Some sends do not need to happen now. send_at hands the schedule to the provider, which removes a whole category of retry from your own infrastructure.
{
"from": "Acme <hello@acme.com>",
"to": ["someone@example.com"],
"subject": "Your trial ends tomorrow",
"text": "Your Acme trial ends tomorrow.",
"send_at": "2026-04-25T09:00:00Z",
"idempotency_key": "trial-1d:sub_4a7b"
}Combined with an idempotency key, a nightly job that runs twice cannot produce two scheduled messages.
What to build first
If you are adding this to an existing codebase, do it in this order: put every send behind one function, add the key parameter as required rather than optional, then add the retry loop. Making the key non-optional at the type level is what stops the next feature from forgetting it.
Then make sure a permanent failure is recorded rather than retried forever, which is the subject of bounces, complaints, and suppression lists. The flows where duplicates hurt most are covered in building an email verification flow that actually works.
Questions
- What should I use as an idempotency key?
- Something derived from the reason you are sending: a purpose, an entity id, and a version.
reset:usr_123:tok_456is a good key. A random UUID generated at the call site is not, because a retry produces a different one. - How long is an idempotency key remembered?
- Long enough to cover a realistic retry window rather than forever. Design on the basis that a key protects against retries over hours, not months, and use a versioned key when you deliberately want to send something again.
- Should I retry a 4xx response?
- No, apart from 429. A 400 or 422 describes something wrong with your request, so the identical request will be rejected identically. Fix the payload, or record the failure and move on.
- Do webhook retries need idempotency keys too?
- Yes, and they are the easiest case: use the sender's event id as the key. Stripe, Clerk, and most webhook senders retry on timeouts, so a handler that sends mail without a key will eventually send twice.
Give your agent an address it can answer from.
Create an inbox