Developers · September 20, 2026
Idempotency Keys: Stop an Agent Sending Duplicate Emails
How send idempotency works: canonical payload hashing, the 30-day replay window, conflict versus expiry, and why retrying an unknown outcome is unsafe.
Your agent submits an email. The HTTP request times out. Should it send again?
A timeout tells you nothing about whether the server accepted the request. Generating a
fresh send immediately can produce two emails to a customer. Doing nothing can lose the
message. The Idempotency-Key header exists to remove the guess — but only if you
understand precisely what it binds to, because a key used carelessly reintroduces the
exact duplicate it was supposed to prevent.
This is the send side. The receiving side has its own duplicate problem with different rules — see handling email webhooks without processing events twice — and the broader picture of how agent mail moves is in email for AI agents.
One intended message, one stable key
The rule in a sentence: the key names the message you meant to send, not the attempt you are making.
invoice-4471-receipt-v1 is a good key. It identifies one intended message and it
survives a process restart, because you can reconstruct it from the invoice ID. A fresh
crypto.randomUUID() generated inside the retry loop is the worst possible key — it
makes every attempt a distinct message and turns the header into decoration.
Record the intended payload and the key in durable storage before the first attempt. An in-memory UUID is not enough when a worker crashes between sending the request and recording the response: on restart it has no way to recover which attempt was already in flight.
Store, alongside the key: the sender inbox, To/Cc/Bcc, subject, body, attachment IDs, and the reply target. Retry that same request with that same key. Editing the body under the old key is a conflict, not a corrected retry. A genuinely new intended message gets a new key.
What the key is actually bound to
This is the part that determines the behaviour you will see, so it is worth being exact.
The request is normalised into a canonical payload before anything is stored:
{ inbox_id, action, reply_to_message_id, to[], cc[], bcc[], subject, text, html, attachment_ids[] }
Addresses are normalised — the domain is lower-cased, the local part is left alone —
and missing text or html becomes an empty string. That object is serialised with
its keys sorted, so field order in your JSON is irrelevant, then hashed with SHA-256.
The idempotency record is keyed on the tuple
(workspace, project, "send", sha256("send:" + your key)) and stores that payload hash
alongside the eventual response. Two consequences fall straight out of that:
The record is per project. The same key string in a different project is a different record, which is what lets you reuse a deterministic key scheme across test and live without collisions.
Only the canonical fields count. Changing metadata does not change the hash;
changing a recipient’s display name does, because name is part of the recipient
object.
The four outcomes of reusing a key
| Situation | Response | What to do |
|---|---|---|
| Same key, same canonical payload | HTTP 202, the original response replayed — same message_id, same thread_id | Nothing. One email exists. |
| Same key, different payload | HTTP 409 idempotency_conflict — “Idempotency-Key was reused with different content.” | Fix your key derivation. Do not retry. |
| Same key, first attempt still in flight | HTTP 503 temporarily_unavailable — “A matching send is already in progress.” | Back off, then re-read the existing message. |
| Same key, more than 30 days later | HTTP 409 idempotency_expired | The replay window has closed. Decide deliberately. |
The replay window is 30 days. The key itself stays reserved as a tombstone for 365 days after that, so it cannot be silently recycled into a different message a year later.
The conflict case is a feature, not an obstacle. It is a bug detector: it fires exactly when your code thinks it is retrying and is in fact sending something new. A client library that catches the 409 and retries with a fresh key has converted a caught bug into a duplicate email. Log it and stop.
Note that an approval has its own idempotency namespace —
sha256("approve:" + approval_id + ":" + your key) — so approving a message and
sending one cannot collide even if you use the same key string for both.
Read the state before deciding what to do
When a send’s outcome is unclear, the answer is almost never “send again”. It is “look at what already exists”.
| State | What it tells you | Useful next action |
|---|---|---|
queued | Stored; not yet at a provider. | Wait and inspect the existing message. |
approval_required | No email has been submitted. A human must act. | Surface it. Never retry as a send. |
accepted | The provider acknowledged the submission. | Track per-recipient delivery. |
submission_unknown | Acceptance could not be confirmed. | Investigate. Do not create a new send. |
failed | A specific rejection was recorded. | Read the reason, then decide. |
canceled | Cancelled before submission — for example, sending paused. | Nothing was sent. |
submission_unknown is the state that most email APIs do not expose, and it is the
whole reason this article exists. When a submission is interrupted after the request
leaves but before an acknowledgement comes back, the system records
submission_unknown with the error code interrupted_submission and deliberately
stops. It does not retry, because retrying an unknown outcome is exactly how one
intended email becomes two. The live sending quota reserved for that message is held
rather than released, and any daily-limit reservation moves to a held state, for the
same reason.
If your dashboard collapses submission_unknown into “failed”, someone on call will
press resend on a message that already went out. Show it as its own state, with its own
instruction.
Accepted is also not delivered, and delivered does not prove a human read it. For
multiple recipients, inspect each recipient’s state — recipients start at pending and
change independently through message.delivery_updated — rather than treating the
message as one outcome.
Retryable and not retryable
The error envelope answers this directly, in a field:
{
"error": {
"code": "provider_unavailable",
"message": "...",
"request_id": "req_...",
"retryable": true,
"details": {}
}
}
retryable is true only for rate_limited, provider_unavailable and
temporarily_unavailable. Everything else is false, and a false means the
identical request will produce the identical error.
Retry those three, with exponential backoff and the same idempotency key. HTTP 429
carries a retry-after: 60 header; honour it. Never mint a new key as part of a retry.
Do not retry 400 invalid_request, 401 unauthenticated, 402 payment_required,
403 permission_denied, 403 policy_denied, 403 daily_limit_exceeded,
409 idempotency_conflict, 412 version_conflict or 422 attachment_not_ready.
Each of those names a specific thing to fix.
Behind the API, the provider submission has its own retry discipline you do not have to implement: exponential backoff capped at five minutes per attempt, bounded by a 23-hour provider idempotency deadline, and applied only to proven pre-submission rejections. Uncertain outcomes never take that path.
Keep webhook retries separate
Webhook deduplication and send idempotency solve different problems, and conflating them is the second-most-common duplicate-email bug.
A webhook callback can arrive more than once. Delivery is retried up to eight attempts with a fixed backoff of 10s, 60s, 5m, 30m, 2h, 6h and 12h, and a 4xx response is terminal except for 408, 409, 425 and 429. Duplicates are normal operation, not an incident.
So: verify the signature against the exact raw body — v1, plus the base64
HMAC-SHA256 of id.timestamp.rawBody, with a five-minute timestamp window — then
deduplicate on the event ID before doing any work. Receiving a delivery notification
twice must never cause your agent to send the original email again.
If a workflow replies to inbound mail, derive the outbound idempotency key from the inbound message or event ID that triggered it. That ties the send to the business intent rather than to how many times your handler ran, and it makes the duplicate impossible rather than merely unlikely. The signature reference is in the events and webhooks docs.
People and files are part of the same decision
Two more things that interact with retries and surprise people.
Human approval is bound to the version a person reviewed. The approval record
carries the SHA-256 of the exact canonical payload. Change a recipient, the body or an
attachment after review and the approval no longer matches — the send is refused with
version_conflict. A changed message needs a fresh review, by construction. Approvals
expire after seven days, and the approve call requires its own Idempotency-Key, so a
double-clicked approve button cannot send twice.
A pending attachment scan blocks the send. Sending with an attachment that has not
cleared returns HTTP 422 attachment_not_ready. That is deliberate: silently dropping
the file would transmit something other than what the agent intended. Retry after the
scan completes, with the same key — the attachment ID is part of the canonical payload,
so nothing about the retry changes.
What idempotency does not give you
Idempotency removes accidental duplicates inside the supported request lifecycle. It is not a universal exactly-once guarantee across the internet, and nothing can be.
A message can be accepted by a provider and still be duplicated downstream by a forwarding rule, a mailing list, or a recipient’s own infrastructure. A submission can be genuinely ambiguous. What you get is that your retries are safe, that ambiguity is reported rather than papered over, and that the system will tell you when your code thinks it is retrying but is not.
Keep your own record of intent and outcome — the key, the canonical payload, the
message_id, and the request_id from the response headers — so an ambiguous case can
be investigated rather than guessed at.
To exercise all of this safely, replay sends against a test project: same key same body for the replay case, same key different body for the conflict case. Test an agent’s email without sending real mail has the full checklist, and the agent email API loop puts idempotency in the context of the whole integration.
Keep reading
Agent Email API: Send, Receive and Reply in One Loop
Build the full two-way loop over REST: scoped keys, idempotent sends, the states a message moves through, inbound events, and replies that thread correctly.
Handle Email Webhooks Without Processing Events Twice
Verify the signature against the raw body, deduplicate by event ID, enqueue durably, and keep a webhook retry from re-sending the email that produced it.
Accepted vs Delivered: What an Email Status Proves
Queued, accepted, delivered, bounced, unknown — what each email status actually proves, what it does not, and the correct next action for every one of them.