Idempotent email sends: how one key stops a retry becoming a second message
Published Updated 7 min readBy the Rasket team

The problem a timeout creates
Your code calls the send endpoint. Ten seconds pass and nothing comes back. What happened?
You do not know, and that is the entire difficulty. The request may never have arrived. It may have arrived and been rejected. It may have arrived, been accepted, and had its response lost on the way back to you — in which case the message is already on its way to the recipient and your database has no record of it.
Retrying blindly is how one order becomes three receipts. Not retrying is how a customer who paid never hears from you. Both are bad, and picking between them by guessing at the network is not engineering.
A duplicate email is not merely untidy, either. A second receipt makes a customer wonder whether they were charged twice; a second password reset invalidates the link in the first; a second shipping notice sends somebody looking for a parcel that does not exist. Each of those is a support conversation, and the volume scales with whatever transient network problem caused it — which means the worst day for duplicates is also the worst day to be answering questions about them.
The fix is not a better retry policy. No amount of back-off tells you whether the first request arrived. The fix is to make the question unnecessary by letting the server recognise the second request as the same piece of work.
What idempotency means here
An operation is idempotent when performing it more than once has the same effect as performing it once. RFC 9110 already classifies several HTTP methods that way: a GET or a DELETE repeated changes nothing that the first one did not already change.
A send is not naturally idempotent. Two POSTs are two emails, and that is correct behaviour — you may genuinely want to send the same message twice. So idempotent email sends are something you opt into, by telling the server which requests are the same piece of work.
Idempotency-Key: receipt-1042That is the whole interface. An idempotency key is a string you choose, sent on the send endpoints, saying “this request and any other request with this key are one piece of work”. The IETF has a draft specification for the header, which is why it looks the same across several APIs you may already use.
How the key behaves
Four outcomes, and knowing all four is what makes a client correct.
| Situation | Status | What you get |
|---|---|---|
| Same key, same payload, the first request has finished | 200 | The original response, with Idempotent-Replayed: true |
| Same key, same payload, the first request is still running | 409 | concurrent_idempotent_requests |
| Same key, a different payload | 409 | invalid_idempotent_request |
| A key shorter than 1 or longer than 256 characters | 400 | invalid_idempotency_key |
HTTP/1.1 200 OKIdempotent-Replayed: true
{ "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c" }The window is 24 hours, counted from the first request that used the key, and the key is scoped to your team — two teams may use the same string without colliding. The payload is compared by fingerprint rather than byte for byte, so a reordered JSON object is still the same payload.
A different payload is refused, not resolved
Two different messages under one key is a bug in the calling code. The API would rather surface it than pick one of the two and send it, so the second request is rejected and nothing goes out.
Choosing a key
The key identifies the work, not the attempt. Everything else follows from that one sentence, and nearly every mistake is a violation of it.
// Wrong. A fresh key on every attempt means no two attempts ever match,// which is the same as having no key at all.for (let attempt = 0; attempt < 3; attempt += 1) { try { return await rasket.emails.send(message, { idempotencyKey: crypto.randomUUID(), }); } catch (error) { await backOff(attempt); }}That loop has an idempotency key and no idempotency. A fresh identifier on every attempt means no two attempts ever share one, so the second request is a new send and the retry you added for safety is the thing producing duplicates.
// Right. The key identifies the work, not the attempt.const idempotencyKey = `receipt-${order.id}`;
for (let attempt = 0; attempt < 3; attempt += 1) { try { return await rasket.emails.send(message, { idempotencyKey }); } catch (error) { if (!isTransient(error)) throw error; await backOff(attempt); }}Good keys come from your own data: an order id, an invoice number, a password-reset request id, a job id from your queue. Add a purpose when one record causes more than one message — order-1042-receipt and order-1042-shipped — so that two different messages about the same thing never collide.
- Stable across attempts. Compute it before the first call, not inside the retry.
- Unique across distinct sends. If two different messages could produce the same key, one of them will silently never be sent.
- Not secret. It is an identifier, not a credential, so an order id is fine and a token is not.
Why 24 hours is the right window
Long enough that every retry of one piece of work falls inside it, short enough that a genuinely new send a month later under the same derived key is treated as new. A key derived from a long-lived identifier is safe precisely because of that shape: the duplicates you are guarding against happen within seconds, not seasons.
Batches take one key
One key covers a whole batch rather than each message inside it, and retrying the batch replays the whole batch. There is no partial replay in which some messages are sent and others recognised as repeats.
That makes the key easy to choose: it should identify the batch — the campaign run, the nightly digest job, the import — rather than anything inside it. It also means a batch is the unit you retry. Splitting a failed batch into individual sends after the fact is how you end up with a key that covered a hundred messages and ninety-nine new ones that did not.
What a client should do
The Rasket clients retry a write automatically only when it carries an idempotency key. That is the correct default rather than a limitation: without a key, a repeat is a second message, and at-most-once delivery matters more than a saved round trip.
If you are writing the calls by hand, the policy worth copying is short. Retry only on a connection error or a 5xx, never on a 4xx other than the rate limit. Back off between attempts, and cap the number of them. Keep the same key throughout. And treat a concurrent_idempotent_requests answer as “wait and ask again” rather than as a failure — it means the first request is still running, and the original response is on its way.
Log Idempotent-Replayed when it comes back true. It is a quiet, accurate count of how often your retries are doing their job, and a sudden rise in it is usually the first visible sign of a network problem somewhere between you and the API. The rate limits guide covers the one 4xx that is worth waiting on.
Why the other endpoints need no key
Only the two send endpoints take the header, and that is not an oversight. Reads and deletes are naturally idempotent — repeating them changes nothing the first one did not. The remaining writes either name the resource they affect, so repeating them sets the same value again, or are safe to repeat by construction.
Sending is the one operation whose side effect is visible to somebody else and cannot be taken back. A duplicated database row can be deleted; a duplicated receipt is in a customer’s inbox. That asymmetry is the whole reason for the header, and it is a useful test for your own API design too. The idempotency reference has the exact semantics, and the emails reference documents both endpoints that accept it.
Frequently asked questions
How long is a key remembered?
24 hours, counted from the first request that used it. After that the key is forgotten and reusing it starts a new send, which is why a key derived from a long-lived identifier is safe: two attempts at the same work land inside the window, and a genuinely new send a month later is a genuinely new message.
What happens if I reuse a key with a different body?
The request is refused rather than sent. The payload is compared by fingerprint, so a reordered JSON object still counts as the same payload, but a different recipient or a different subject under a key you have already used is a bug we would rather surface than resolve by guessing which of the two you meant.
Should the key be random?
No — that is the one mistake worth guarding against. A fresh UUID on every attempt defeats the mechanism entirely, because no two attempts ever share a key. Derive it from the thing that caused the send: an order id, an invoice number, a password-reset request id, optionally with a purpose suffix.
What if two requests with the same key arrive at once?
The second one is refused while the first is still running, with an error naming exactly that. It is not a failure you retry immediately in a tight loop; wait, then ask again, and you will get the original response once the first request has finished.
Does one key cover every message in a batch?
Yes. A batch takes a single key and replays as a whole: there is no partial replay in which some messages are sent and others are recognised as repeats. That makes a batch retry easy to reason about, and it means the key should identify the batch rather than any message inside it.
Why do the other write endpoints not take a key?
Because they do not need one. Reads and deletes are naturally idempotent — repeating them changes nothing — and the remaining writes either name the resource they affect or are safe to repeat. Sending is the one operation whose side effect is visible to somebody else and cannot be taken back.
Sources
- RFC 9110: HTTP Semantics — IETF, read 2026-09-16
- The Idempotency-Key HTTP Header Field — IETF HTTP API Working Group, read 2026-09-16
Related
- Idempotency — Retry a send without sending it twice.
- Rate limits — Ten a second per team, and the headers that tell you where you are.
- Emails — Send, batch, retrieve, list, reschedule, cancel, attachments.
- Send email from Node.js: API vs SMTP vs Nodemailer — Three ways to send email from Node.js — an HTTP API, SMTP and Nodemailer — what each one costs you, and a working send with retries and webhooks.
- Email API vs SMTP: which should you use? — SMTP is a conversation; an email API is one request. What each gives you on retries, idempotency, events and firewalls, and how to move from one to the other.