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

SituationResponseWhat to do
Same key, same canonical payloadHTTP 202, the original response replayed — same message_id, same thread_idNothing. One email exists.
Same key, different payloadHTTP 409 idempotency_conflict — “Idempotency-Key was reused with different content.”Fix your key derivation. Do not retry.
Same key, first attempt still in flightHTTP 503 temporarily_unavailable — “A matching send is already in progress.”Back off, then re-read the existing message.
Same key, more than 30 days laterHTTP 409 idempotency_expiredThe 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”.

StateWhat it tells youUseful next action
queuedStored; not yet at a provider.Wait and inspect the existing message.
approval_requiredNo email has been submitted. A human must act.Surface it. Never retry as a send.
acceptedThe provider acknowledged the submission.Track per-recipient delivery.
submission_unknownAcceptance could not be confirmed.Investigate. Do not create a new send.
failedA specific rejection was recorded.Read the reason, then decide.
canceledCancelled 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

All guides · Documentation · Developers