Developers · September 23, 2026
Your Agent Sent the Same Email Twice. Why
Four reasons an agent sent the same email twice, each with a different fix: a keyless retry, a replayed event, two turns, and a second approval.
A supplier got the same reply from your agent twice, six minutes apart, identical body, two different Message-IDs. Nothing threw. One inbound email arrived, both sends returned HTTP 202, and the recipient is now asking which of the two is the real one. The incident channel proposes adding an idempotency key, someone points out there already is one, and the thread stalls.
Your agent sent the same email twice for one of four reasons. They look identical from the recipient’s mailbox and they are trivially distinguishable from your own logs, but only if you know which three fields to look at. This article is the diagnosis. The mechanics of the send-side guard are covered in depth in idempotency keys: stop an agent sending duplicate emails, and the event-side guard in handle email webhooks without processing events twice. What follows is how to tell which one you are actually looking at, and why fixing the wrong layer leaves the bug in place.
Four causes, four fixes, and why one fix does not cover another
The four guards sit at four different layers, and none of them is a superset of
another. An Idempotency-Key on the send does nothing about a webhook you
processed twice, because the second processing derives a second key. Event-ID
dedup does nothing about two agent turns, because they are two genuinely distinct
events. Neither touches the approval path, which is protected by a state
transition rather than by a key at all.
| Cause | The giveaway in your logs | Where the fix lives |
|---|---|---|
| Retry without a stable key | Two sends, two different Idempotency-Key values, one turn | Your HTTP client |
| Replayed event as new | The same webhook-id header twice, then two sends | Your event consumer |
| Two turns both decide | Two agent traces, two inference calls, two derived keys | Your orchestration and key derivation |
| A second approval | Two approval.requested events, two approval_id values | Whatever composes the draft |
Work down that table in order. The first two are cheap to rule out and the second two are the ones that survive a naive fix.
Cause one: a retry that minted a new key
The send endpoint refuses to run without a key. POST to
/v1/projects/{project_id}/inboxes/{inbox_id}/messages with no Idempotency-Key
header fails with HTTP 400 invalid_request, message “Idempotency-Key is required
for send operations.”, before the body is parsed at all. So “we forgot the header”
is not a cause of duplicates here. It is a cause of nothing being sent.
The cause is a key that changes between attempts. A wrapper that does this:
# Wrong. Every attempt is a new logical send.
for attempt in range(3):
r = session.post(url, headers={"Idempotency-Key": str(uuid.uuid4())}, json=body)
has an idempotency key in the same sense that a lock you re-key on every turn is a lock. Derive the key from the thing you meant to send, once, outside the retry loop:
key = f"reply:{thread_id}:{inbound_message_id}:v1"
for attempt in range(3):
r = session.post(url, headers={"Idempotency-Key": key}, json=body)
if r.status_code != 503:
break
Two responses tell you the guard engaged. A replay of the stored result comes back
as the original 202 with the original message_id, which is what you want. HTTP
503 temporarily_unavailable with the message “A matching send is already in
progress.” means a request with that key claimed the record and has not yet
written an outcome, which is the concurrency case rather than the retry case. Back
off and re-read rather than switching keys.
If instead you get HTTP 409 idempotency_conflict, the key was correct and the
body was not. The key is bound to a canonical hash of the payload, so a
regenerated body that differs by one character of whitespace in the HTML is a
different message wearing the same name. That is a bug in your rendering, not in
your retry policy. The other 409 on this path is idempotency_expired: the replay
window is 30 days, after which the same key and the same payload stop replaying
and start erroring, while a tombstone holds the key against a different payload
for 365 days. A weekly digest job that reuses one key forever will work for a
month and then fail in a way that looks nothing like the bug it is.
Pick a key format that is legal on every surface, because the three of them disagree. The REST send header has no enforced length ceiling. The MCP tools require 1 to 200 characters. The approve endpoint rejects anything over 128. Stay under 128 and one derivation works everywhere.
Also make the key distinct per operation, not per message. Over MCP,
create_draft is implemented as send_message with the messages:send scope
stripped from the acting principal, so both tools hash their key into the same
send: namespace. Calling create_draft with key X and then send_message
with key X and an identical payload replays the draft response and transmits
nothing, which surprises people who expect the second call to promote the first.
That is the mirror image of a duplicate, and it is worth ruling out before you
conclude an agent sent twice when it in fact sent zero times.
Cause two: a replayed event processed as a new one
This platform will not hand you the same inbound email twice. That is worth
stating flatly, because it eliminates an entire branch of the investigation.
Inbound mail is recorded against a receipt keyed on the provider account and the
provider’s message ID, and per inbox against that receipt. A re-posted inbound
delivery finds the existing committed row, returns the message and thread IDs it
already created, and emits no second message.received. The receiving side is
exactly-once.
Event delivery is at-least-once, which is a different property. A webhook
callback carries the event ID in the webhook-id header, and a callback that
timed out on your side is retried even though you processed it. If two sends
appear downstream of one inbound email, grep your ingress log for the event ID
before you touch anything else. The same ID twice means the duplicate was born in
your handler.
The fix is a unique constraint on the event ID in your own store, claimed before any work happens, not after:
INSERT INTO processed_events (event_id, processed_at)
VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING;
-- zero rows affected means: already handled, return 200, do nothing
Claiming after the send is the classic version of this bug, because the window between sending and recording is precisely where the retry lands. The signature verification, the ordering caveats and the durable cursor you use to recover missed events are all in the events and webhooks reference.
Cause three: two agent turns that each decided to send
This is the one that survives both fixes above, and it is the most common cause in production agents. One inbound email, one event, processed once. The orchestrator then runs the agent twice: a job that was requeued, a graph node re-entered after a checkpoint restore, a user hitting “run” while a scheduled tick was already in flight. Both turns read the same thread, both conclude that a reply is warranted, and both write a well-formed send.
The send guard does not help, because the two turns generate two different keys. On the MCP surface the key is a tool argument, which means a language model is choosing it. Ask a model for an idempotency key and you get something plausible and fresh every time, so the field that exists to prevent this failure is being handed the one value that defeats it.
Never let the model choose the idempotency key. Derive it in your code from facts that are stable across turns, and inject it:
def reply_key(thread_id: str, inbound_message_id: str, revision: int = 1) -> str:
return f"reply:{thread_id}:{inbound_message_id}:v{revision}"
Two turns reacting to the same inbound message now produce the same key, and the
second one replays the first one’s 202 rather than sending. Bump revision only
when a human decided a genuinely new message is warranted. If your agent framework
exposes tool arguments to the model, wrap the tool: accept the semantic arguments
from the model, compute the key yourself, and never surface that field.
There is a second, blunter guard worth having alongside it. Before composing, read
the thread and check whether the last message is already outbound from your own
inbox. GET /v1/projects/{project_id}/inboxes/{inbox_id}/threads/{thread_id}/messages
returns each message with a direction and a thread sequence number, so “have I
already answered this” is one call rather than an inference. It will not catch a
true race, which is what the key is for, but it catches the requeue-an-hour-later
case that the key cannot, because an hour later your code may legitimately have
derived a new revision. Read
the MCP versus REST trade-off if you are still
choosing which surface the agent talks to, because the next cause is much worse on
one of them.
Cause four: a second approval holding identical content
The approval path has its own dedup, and it is not the send key. Approving goes
through POST /v1/projects/{project_id}/approvals/{approval_id}/approve, which
requires its own Idempotency-Key of at most 128 characters and returns 202. That
key is hashed into a namespace of its own, approve:{approval_id}:{key}, whereas
an agent send hashes as send:{key}. The two cannot collide, which is why
approving does not replay the agent’s original submission. Verified in
packages/api/src/index.ts at the approve route and in the commit path in
packages/domain/src/services.ts.
What actually makes an approval single-use is a state transition inside the same
transaction as the send. The approval must be pending or approved, unexpired,
still matching the payload hash it was created against, and still bound to the
principal that requested it. It is moved to consumed before the message is
committed. So:
- Approving twice with the same key replays the stored 202 and the original
message_id. - Approving twice with two different keys gets HTTP 403
permission_deniedon the second, message “Approval is expired, consumed, or no longer valid.” - Approving something already rejected gets HTTP 403 with a different message, “This approval cannot be used.”, thrown one layer earlier. The two strings are worth grepping separately, because they tell you whether a human rejected this or a send already consumed it.
None of which produces a duplicate. The duplicate comes from the layer above. A
unique index enforces one active approval per draft version, and nothing enforces
that two different drafts cannot hold identical content. An agent that composes
the same reply on two turns creates two drafts, two approvals and two
approval.requested events. A reviewer working a queue sees two items that look
like a resubmission of the same thing, approves both because approving is the
safe-seeming action, and two emails go out. Both were correctly authorised. The
system behaved exactly as specified.
Why the agent cannot see its own pending approval
Here is the structural reason cause four keeps happening rather than being a
one-off human error. When a send needs review, the response is
result: "approval_required" with a draft_id and an approval_id, and no
message row is created. The draft lives in its own table until someone approves
it. That has two consequences on the next turn.
list_threads and list_messages show committed messages, so the pending reply
is not in the thread. And the MCP surface exposes exactly six tools,
list_inboxes, list_threads, list_messages, get_message, create_draft and
send_message. None of them lists approvals. An agent connected over MCP has no
call it can make to discover that it already has a reply waiting for a human. It
looks at the thread, sees an unanswered inbound message, and composes again,
correctly, from everything it can observe.
So the deduplication has to happen in your code, before the tool call, using
cause three’s derived key. Over MCP that is the only guard available to the agent
side. Over REST you can additionally poll GET /v1/projects/{project_id}/approvals
and match on draft_id, which is the check the agent cannot perform for itself.
One caveat on that listing. It returns at most 100 rows, ordered newest first,
with next_cursor always null and has_more set to data.length === 100. There
is no cursor to follow, so on a queue of more than a hundred pending items the
oldest are simply not in the response, and a duplicate that has been waiting
longest is exactly the one that falls off the end. Keep the queue drained, or
filter it in the console rather than by paging the API.
Staleness you have to poll for
An approval expires seven days after it is requested. That deadline is enforced in
two independent places, and only one of them is visible to you. The commit path
compares expires_at to the clock and refuses a stale approval. The listing
computes the display state, returning expired for any approval whose stored state
is still pending past its deadline. The stored row never changes. If you query
your own mirror of approval state, or filter on the state field you cached at
request time, an expired approval still reads as pending forever.
The approval.invalidated event that would tell you this asynchronously is
declared in the contracts package and is never emitted by this build, so staleness
is something you poll for, not something that arrives. Design the reviewer flow
around the listing, not around a callback that will not come.
How the approval gate works end to end covers
the rest of the surface, and the
human controls page shows what a reviewer sees.
One more thing the approval path does not do, in case you were counting on it:
there is no draft-editing endpoint in this build. The approve path can return HTTP
412 version_conflict, “Draft content changed.”, if the stored version’s payload
hash ever diverges from the hash the approval was created against, but nothing
currently writes a second draft version, so that branch does not fire today. Treat
it as a guard for a future editing surface, not as a thing you will see in your
logs. An agent that wants to revise a pending reply must submit a new draft under
a new key and have someone reject the old one.
Reading your own logs to pick the right cause
Three fields settle it. Pull them for both copies of the message:
- The
Idempotency-Keysent with each request. Same key means the guard engaged and you are looking at one send, not two. Different keys mean the duplicate was decided upstream, and you are in cause one, three or four. - The
webhook-idheader on the callbacks that triggered each. The same ID twice is cause two, and the fix is in your handler. - The count of
approval.requestedevents for the thread. Two is cause four, and no amount of send-side work will help.
If all three come back clean, one key per logical message, distinct event IDs, one
approval, then check whether the two copies actually are duplicates. Two sends
with different message_id values and one message.accepted each are two
messages. One send with a single message_id that the recipient saw twice is a
delivery-side artifact, not an agent bug, and
what accepted actually proves is the right
place to start on that.
The pattern underneath all four is worth stating once. Every hop in an agent email pipeline is at-least-once: the HTTP client retries, the webhook redelivers, the orchestrator re-runs, the human clicks again. Each hop therefore needs its own deduplication key, derived from the identity of the thing at that layer, and the keys are not interchangeable. The surface that gives you the first of those keys, along with the request shapes and error envelopes referenced throughout, is documented on the developer platform page.
Keep reading
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.
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.
Human Approval Before an AI Agent Sends Email
When to require review, why approval binds to one exact draft version, how recipient rules and daily caps work, and what editing a draft does to its approval.