Idempotent email sends: how one key stops duplicate emails on retry
Published Updated 15 min readBy the Rasket team
Written for AI automation builders and developers

An idempotent email send goes out once, however many times your code retries it. To get one, send an Idempotency-Key header, a label you choose that names the email, such as order-1042-receipt, and reuse it on every retry. The API sends the first successful request and answers each repeat with that response.
Here is the situation it prevents. Your checkout handler calls the send endpoint. The call times out after 10 seconds. Your job queue retries it twice, and the customer gets three receipts for one order.
Not writing the code yourself? Two common causes can be fixed without code: your store platform sending its own copy (see duplicates a key cannot catch), and a Zap with no order id mapped into its Idempotency Key field (see the FAQ below).
Why does a timeout turn into a duplicate email?
A timeout tells you nothing about the send. The request may never have arrived, or it may have been refused. Or it was accepted and only the response was lost, so the email is on its way and your code has no record of it. Retrying covers the first case and duplicates the last. Not retrying drops the first.
An operation is idempotent when doing it twice has the same effect as doing it once. RFC 9110 §9.2.2 says a client “SHOULD NOT automatically retry a request with a non-idempotent method unless it has some means to know that the request semantics are actually idempotent, regardless of the method, or some means to detect that the original request was never applied”. A send is a POST, and two POSTs are two emails. An idempotency key gives the server that knowledge: a string that says “this request and any other with this key are one piece of work”.
A duplicate costs more than a confused customer. Someone who gets three receipts may mark one as spam, and on Rasket a complaint suppresses that address for all your mail (who is suppressed for what).
How to choose a key
The key names the work, not the attempt. The common bug is calling crypto.randomUUID() inside the retry loop. Each attempt then has its own key, so the retry you added for safety is what sends the duplicate.
- Derive it from the record that caused the email, plus a purpose when one record causes several:
order-1042-receipt,order-1042-shipped. Two workers handling one order then derive the same key without talking to each other. A UUID works too if you generate it once and store it with the job. The guide to order confirmation emails applies this to a shop. - Use the business id, not the queue’s. A queue that delivers at least once can hand one job to two workers, each with its own delivery id. The order id is the same for both.
- Build the payload once. The API compares repeats by a fingerprint, a hash of the request. A retry that rebuilds the body, even with only a new timestamp, has a different one, so it is refused with
invalid_idempotent_requestinstead of getting a replay of the first answer. - Prefix it with its owner once a second sender exists. Keys are scoped to your team, not to an endpoint, so two services that both send
order-1042-receiptcollide. Writebilling-order-1042-receipt. The agent keys below start withsupport-agent-for that reason. - Keep personal data out. The key is stored with the request, so use the order id, not the email address.
A resend the customer asks for is new work, so it gets a new key: order-1042-receipt-resend-1. A batch of up to 100 emails on POST /emails/batch takes one key for the whole batch, so name the run (digest-2026-10-17) and retry the batch as a unit. The FAQ below covers a batch with one bad address.
A retry loop that keeps the key
Save it as send.mjs. It needs Node 18 or later and nothing else. postEmail holds the key steady across attempts; for pacing a busy sender, see email API rate limits.
const API = process.env.RASKET_API_URL ?? "https://api.rasket.com";
const WAIT = new Set(["rate_limit_exceeded", "concurrent_idempotent_requests"]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const backoff = (attempt) => Math.min(30_000, 500 * 2 ** attempt) * (0.5 + Math.random() / 2);
export async function postEmail(key, body) {
let wait = 0;
let lastError;
for (let attempt = 1; attempt <= 5; attempt += 1) {
await sleep(wait);
let res;
try {
res = await fetch(`${API}/emails`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
"Content-Type": "application/json",
"User-Agent": "acme-shop/1.0", // required: a request without one gets a 403
"Idempotency-Key": key,
},
body,
signal: AbortSignal.timeout(10_000),
});
} catch (error) {
// Retry a lost answer: a network failure or a timeout. Anything else is thrown.
if (error.name !== "TypeError" && error.name !== "TimeoutError") throw error;
[lastError, wait] = [error, backoff(attempt)];
continue;
}
const data = await res.json().catch(() => null);
if (data === null) {
// A body cut off mid-read is a lost answer too, so retry under the same key.
[lastError, wait] = [new Error(`${res.status} with an unreadable body`), backoff(attempt)];
continue;
}
if (res.ok) {
return { id: data.id, replayed: res.headers.get("idempotent-replayed") === "true" };
}
if (res.status >= 500 || WAIT.has(data.name)) {
lastError = new Error(`${res.status} ${data.name ?? "server error"}`);
wait = Number(res.headers.get("retry-after")) * 1000 || backoff(attempt);
continue;
}
// Anything else gets the same answer again. invalid_idempotent_request means this
// key already sent a different email: a bug to fix, not a retry to make.
throw Object.assign(new Error(`${res.status} ${data.name}: ${data.message}`), {
status: res.status,
});
}
throw new Error(`Gave up on ${key}; it may already have been sent.`, { cause: lastError });
}
export function sendReceipt(order) {
const body = JSON.stringify({
from: process.env.RASKET_FROM,
to: [order.email],
subject: `Your receipt for order ${order.id}`,
text: `Thanks for your order. Total: ${order.total}.`,
tags: [{ name: "order_id", value: String(order.id) }],
}); // built once, so every attempt sends the same payload
return postEmail(`order-${order.id}-receipt`, body); // the key names the work
}It retries a lost answer, a 5xx, and the two errors that mean “wait”. It throws everything else, including a quota 429 such as daily_quota_exceeded and any error of your own, and attaches the last failure as cause when it gives up. A send takes tags as an array of { name, value } objects. Reads such as GET /emails return them as an object keyed by name, "tags": { "order_id": "1042" }, and GET /emails?tags=order_id:1042 finds the email later. The same loop in Python, written for a batch, is in Email API rate limits; for a single send, change the path to /emails, post one email object, and read id from the answer instead of data.
To test the case that matters, make the first attempt reach the API and then lose the response:
// test.mjs. Accepted, but the response is lost: the first call reaches the API, then throws.
import { sendReceipt } from "./send.mjs";
const realFetch = globalThis.fetch;
let dropped = false;
globalThis.fetch = async (...args) => {
const res = await realFetch(...args);
if (!dropped) {
dropped = true;
throw new TypeError("fetch failed");
}
return res;
};
console.log(await sendReceipt({ id: "1042", email: "ana@example.com", total: "$42.00" }));{ id: '01a14b8b-db38-7961-85eb-9b0e73cb7817', replayed: true }The first attempt sent the email. The retry got the same id back as a replay, and no second email went out.
How do you stop an AI agent sending twice?
An agent breaks the payload rule more often than code does. If a tool call fails and the model writes the email again, the subject or body usually changes, and the retry gets 409 invalid_idempotent_request instead of a replay. So take both the key and the payload out of the model’s hands. Where you do that depends on who makes the call.
Your own send tool: pin the first draft in your database
When the agent calls a tool you wrote, store the key and the payload the first time it decides to send, and send exactly those on every retry. A table survives the restart that usually causes the retry; an in-memory map does not. This uses postEmail from above and the pg client for PostgreSQL:
// CREATE TABLE agent_sends (
// key text PRIMARY KEY, -- support-agent-<task id>-<purpose>
// body text NOT NULL, -- the payload the first decision fixed
// email_id text -- set once the API accepts it
// );
import pg from "pg"; // npm i pg
import { postEmail } from "./send.mjs";
const db = new pg.Pool(); // connects with the PG* environment variables
export async function agentSendEmail(taskId, purpose, draft) {
const key = `support-agent-${taskId}-${purpose}`; // owner, then the work
// Get or create in one statement: the first draft is stored, a retry reads it back.
// The no-op DO UPDATE is what makes RETURNING give back an existing row; DO NOTHING would not.
const { rows: [pin] } = await db.query(
`INSERT INTO agent_sends (key, body) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET key = EXCLUDED.key RETURNING body, email_id`,
[key, JSON.stringify(draft)],
);
if (pin.email_id) return { id: pin.email_id, replayed: true }; // sent earlier, maybe days ago
try {
const sent = await postEmail(key, pin.body); // the stored body, not the new draft
await db.query("UPDATE agent_sends SET email_id = $2 WHERE key = $1", [key, sent.id]);
return sent;
} catch (error) {
// A refusal keeps no key on the server, so free the pin for a corrected draft.
// A 409 means the key is taken: keep the pin.
if (error.status && error.status !== 409) {
await db.query("DELETE FROM agent_sends WHERE key = $1", [key]);
}
throw error;
}
}To test it, kill the worker after the send and let the model redraft:
// The worker dies after the API accepted the send, before it saved the id.
const realFetch = globalThis.fetch;
globalThis.fetch = async (...args) => {
await realFetch(...args);
globalThis.fetch = realFetch;
throw new Error("worker killed");
};
const draft = {
from: process.env.RASKET_FROM,
to: ["ana@example.com"],
subject: "Your refund for order 1050",
text: "We refunded $42.00.",
};
await agentSendEmail("ticket-8820", "refund", draft).catch((error) => console.log(error.message));
// After the restart the model writes the email again, differently.
const redraft = { ...draft, subject: "Refund issued for order 1050" };
console.log(await agentSendEmail("ticket-8820", "refund", redraft));worker killed
{ id: '01a14b85-b6f1-7971-8eb4-c67f058dcb7c', replayed: true }The retry sent the stored payload under the same key, so the API replayed the first email and ignored the new subject. One email went out. The stored email_id also answers a retry that comes after the key has expired. The gap left is a worker that dies between the send and the UPDATE and is retried after the key has expired, so keep agent retries well inside a day.
Take the task id from the ticket, thread or order the agent is working on. When there is none, as in a plain chat turn, generate a UUID at the moment of the decision and store it with the payload.
Rasket's send_email over MCP: set the key in the gate
Through MCP, Rasket’s send_email tool takes the key as an idempotency_key argument. If the model leaves it out, the send has no key, and a prompt that tells it to include one is a request, not a guarantee. Set it in the code that forwards the model’s tool calls instead. Here it is added to the approval gate from the guide to sending email from an AI agent:
// An excerpt of gate.mjs from the MCP guide, where client and refuse() are defined.
// Three changes: callTool takes the task id, send_email gets its key from it,
// and a rewritten repeat is turned into a stop.
export async function callTool(name, args, taskId) {
if (name === "send_email") {
if (!taskId) return refuse("No task id, so send_email was not run.");
// The task names the send, one email per task. Any key the model wrote is replaced.
args = { ...args, idempotency_key: `support-agent-${taskId}` };
}
// ... the allowlist and approval checks, unchanged ...
try {
const result = await client.callTool({ name, arguments: args });
if (result.isError && result.structuredContent?.error?.name === "invalid_idempotent_request") {
return refuse("This task's email was already accepted. Do not send it again.");
}
return result;
} catch (error) {
return refuse(`${name} was not run: ${error.message}`);
}
}// 1. The model's call, with no idempotency_key
{ "id": "01a14b85-c534-79bb-881f-2bb6172f690b", "replayed": false }
// 2. The same call again
{ "id": "01a14b85-c534-79bb-881f-2bb6172f690b", "replayed": true }
// 3. The model rewrites the subject and calls again
This task's email was already accepted. Do not send it again.The tool fingerprints its arguments without the key, so an identical repeat replays. A rewritten repeat gets invalid_idempotent_request, which here means “already accepted”, and the gate tells the model to stop. Accepted is not delivered: a send to a suppressed address is accepted too, and keeps the key. A call the person declines, or one the API refuses, claims no key, so the corrected draft still goes out. The limit: this allows one email per task while the key is kept. A task that sends a reply and then a follow-up needs a purpose in each key, which means your own tool, as above. The gate cannot take the purpose from the model, because a model that rewrites the email on a retry can rename the purpose too.
What does a repeated key return?
This curl script runs four cases. Swap in your API key and a from address on your verified domain. The answers below came from a local server over plain HTTP, so the status lines read HTTP/1.1; over https, curl usually prints HTTP/2 200.
API="${RASKET_API_URL:-https://api.rasket.com}"
send() {
curl -s -i "$API/emails" \
-H "Authorization: Bearer $RASKET_API_KEY" \
-H "Content-Type: application/json" \
-H "User-Agent: acme-shop/1.0" \
-H "Idempotency-Key: $1" \
-d "$2"
}
RECEIPT='{"from":"Shop <orders@example.com>","to":["ana@example.com"],
"subject":"Receipt for order 1043","text":"Thanks for your order."}'
CHANGED='{"from":"Shop <orders@example.com>","to":["ana@example.com"],
"subject":"Receipt for order 1044","text":"Thanks for your order."}'
LONG_KEY=$(printf 'k%.0s' $(seq 1 257))
send order-1043-receipt "$RECEIPT" # first send
send order-1043-receipt "$RECEIPT" # the same request again
send order-1043-receipt "$CHANGED" # same key, different payload
send "$LONG_KEY" "$RECEIPT" # a key one character too longHTTP/1.1 200 OK
{"id":"01a14b8c-2133-7a2d-aa03-58470b674371"}
HTTP/1.1 200 OK
idempotent-replayed: true
{"id":"01a14b8c-2133-7a2d-aa03-58470b674371"}
HTTP/1.1 409 Conflict
{"statusCode":409,"name":"invalid_idempotent_request", ...}
HTTP/1.1 400 Bad Request
{"statusCode":400,"name":"invalid_idempotency_key",
"message":"Idempotency-Key must be between 1 and 256 characters."}The idempotency reference states these outcomes, the 24 hours the key is kept, and the team scope. Requests are compared by fingerprint, so the same fields in a different order still count as a repeat. The path is part of it: send the same key to /emails/batch and you get the same 409. Scope decides whose keys can collide; the fingerprint, path included, decides whether a repeat matches. Three behaviours are easy to miss:
- Refusals leave no key. The key is claimed in the transaction that writes the email and kept only if it commits. Send a body with no subject and you get
422 missing_required_field; fix it and resend under the same key, and it goes out as a new send. Stripe’s v1 API differs: it stores the first response once its endpoint starts running, and replays it,500s included. - Simultaneous repeats wait today. The reference documents
409 concurrent_idempotent_requestsfor a repeat that arrives while the first is still running, and the reference is the contract. In practice, fire two identical requests atPOST /emailsat once and both come back200with the same id, one marked as a replay: the second waits on a database lock until the first finishes. Code for the documented409anyway, by waiting and retrying with the same key, aspostEmaildoes. - Expiry is not exact. An hourly job deletes keys older than 24 hours, so a key can replay for up to an hour longer. Cap retries at minutes, and have a job that resumes the next day check its own record of the email id.
Which endpoints accept the key?
The Idempotency-Key header, 1 to 256 characters, is declared on POST /emails, POST /emails/batch, POST /templates/{id}/test, POST /broadcasts/{id}/test, and POST /inbox/conversations with its /reply and /notes. Three other routes take a key their own way:
- Store events (
store:order.placedand the rest, from a connected shop or your own code) takeidempotency_keyin the JSON body, 1 to 255 characters. A repeat is accepted with202and records nothing new. From a connected shop, Rasket sets the key itself, from the store, the event and the order id. - The SMTP relay reads an
X-Rasket-Idempotency-Keyheader, kept for the same window. Without one, it hashes theMessage-ID, the envelope sender and the recipients into a key, which only catches a client that resends the same message. The SMTP relay guide has the details. - MCP takes it as the
send_emailtool’sidempotency_key, as above.
POST /broadcasts/{id}/send takes no key, and one sent there is ignored. It moves a draft to queued once, and a repeat is 400 validation_error, “Broadcast is not a draft.” After a timeout, read the broadcast’s status instead. A custom event on POST /events/send takes none either, and two calls start two automation runs. To retry one safely, record it in your own table under a unique id before sending, and check the table after a lost response.
Where the Idempotency-Key header comes from
It is a convention, not a standard. Stripe popularised it, and the IETF HTTP API working group wrote it up as draft-ietf-httpapi-idempotency-key-header. Its last revision (-07) expired on 18 April 2026 without becoming an RFC, so each API sets its own rules. Rasket matches the draft on the header name, and its reference documents the draft’s 409 for a concurrent repeat, though today the repeat waits instead, as above. The draft leaves retention to the server. It differs in four places:
| Case | The draft (-07) suggests | Rasket answers |
|---|---|---|
| Same key, different payload | 422 | 409 invalid_idempotent_request |
| No key on an endpoint that requires one | 400 | Never: the key is optional everywhere |
| A replayed response | No header defined | Idempotent-Replayed: true |
| The header value | A quoted string, "order-1042-receipt" | The raw value: quotes would become part of the key |
Two different 409s need opposite handling, so branch on the error’s name, never on the status code alone.
Duplicates a key cannot catch
- Two systems. If your store platform also sends its own order confirmation, your key never sees that copy. Search the Emails page of your Rasket dashboard for the customer’s address: one receipt there and two in their inbox means the second came from elsewhere. Keep the one whose template and tracking you maintain, and turn the other off where you can. On Shopify you usually can’t: its help says most customer notifications “are sent automatically and can’t be deactivated”, and order confirmation is one of them. There, drop your own copy and edit Shopify’s template instead.
- Two code paths. A payment webhook that sends
order-1042-receiptand a nightly job that sendsreceipt-1042send two emails. Derive the key in one shared function. - Two campaigns. Within one campaign, each contact gets one email: each is sent under a key built from the campaign and the contact. Nothing dedupes across campaigns, so a contact in both audiences gets both.
For an email that must never go twice, ever, the key is not enough. Write a row under a unique constraint (one per order and email type) before you send, and save the returned id on it. The key covers retries within minutes; your table covers the rest. Start with your receipt or password-reset call, and check the emails API reference for the full request shape.
Frequently asked questions
Can I reuse a key after cancelling a scheduled email?
Not within the window. The key still points at the cancelled email, so a send under it replays that email's id with idempotent-replayed: true and nothing goes out. To schedule the message again, send it under a new key, such as the old one with -2 added.
Is it safe to retry a request that schedules an email?
Yes, if it carries a key. A scheduled send uses the same POST /emails endpoint, so a retry with the same key and body replays the first answer and schedules nothing new. The returned id is the one email you can later reschedule or cancel.
Should I limit how often a customer can ask for a resend?
Yes, and the limit is yours to set. Rasket's rate limit and sending quotas apply to your whole team, not to one order. Keep a resend counter on the order, refuse a new resend for a few minutes after the last one, and give each one its own key.
What happens to a batch with one bad address under one key?
The whole batch is refused with a 422 that names the element, such as emails[3], and nothing is stored, so the key stays free: fix the address and resend under the same key. A suppressed address is different. It is valid, so the batch is accepted, only that recipient is skipped, and a retry replays the whole batch.
Does Rasket's Zapier app set an idempotency key for me?
Only if you map one. Its send actions have an optional Idempotency Key field, which is empty by default. Map something unique from the trigger, such as the order or record id, so that a replayed Zap run returns the first email instead of sending a second one.
What should I tell a customer who got two receipts?
First check your payment records, then tell them plainly whether they were charged once. If they were, say the second email was a copy and they need do nothing. Then find the cause: two systems sending confirmations, or two code paths using different keys for one order.
Sources
- RFC 9110: HTTP Semantics, §9.2.2 Idempotent Methods — IETF, read 2026-10-17
- draft-ietf-httpapi-idempotency-key-header (status: Expired) — IETF Datatracker, read 2026-10-17
- The Idempotency-Key HTTP Header Field, draft -07 (15 October 2025) — IETF HTTP API Working Group, read 2026-10-17
- Customer notifications: which ones can be deactivated — Shopify Help Center, read 2026-10-17
- Idempotent requests — Stripe, read 2026-10-17
Related
- Idempotency — Retry a send without sending it twice.
- Emails — Send, batch, retrieve, list, reschedule, cancel, attachments.
- Email API rate limits: headers and backoff — Email API rate limits, measured: the ratelimit headers Rasket sends, what a 429 looks like, and Node and Python backoff that retries without sending twice.
- Let an AI agent send email with MCP — Connect an agent to an MCP email server: the OAuth sign-in for your own loop, the send_email call, prompt injection, and a person approving every send.
- Email suppression list: what it is, how to use it — What goes on an email suppression list, which entries block receipts and password resets, what a suppressed send returns, and how to manage it by API.
- SMTP relay: what it is and how to set one up — What an SMTP relay is, when you need one, and how to set one up: ports 465 and 587, the login, limits, reply codes, and settings for WordPress and Laravel.
- How to lower your email spam complaint rate — What a good email spam complaint rate is, how Gmail and Yahoo measure it, how to read it on a small list, and the steps that bring it down.