The short answer
An agent replies twice when it retries a send whose answer it never saw, or when it handles the same webhook delivery twice. The fix is a key: send the same Idempotency-Key on every attempt of one send, and dedupe incoming events by their id. Pidgeon honours Idempotency-Key on its writes, replays the first answer for 24 hours instead of sending again, and refuses the key with a 409 when it is reused for a different request.
Where the second email comes from
A send is a request over a network, and a network can lose the answer after the work is done. The agent's HTTP client times out, or the connection drops, and the agent cannot tell "never arrived" from "sent, and the reply was lost". Retrying is the only reasonable thing to do. Without a key, that retry is a second email to a real person.
Agents add two more ways to get there:
- The model retries. A tool call that errored, or seemed to, is called again with the same arguments. To the API it is a new request.
- The trigger repeats. Webhooks are delivered at least once. If your handler finished and the
2xxnever reached the sender, the event arrives again, and an agent that answers everymessage.receivedanswers it twice.
A person who receives the same reply twice learns that a machine is writing to them, and one that does not check its work. A payment confirmation or a refund sent twice costs more than that.
One key per send, the same key on every retry
Choose the key before the first attempt, from something that names the job: the message you are answering and the step you are on, or an id from your own database. Keep it across retries.
curl https://pidgeon.ai/api/v1/messages/$MESSAGE_ID/reply \
-H "Authorization: Bearer $PIDGEON_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: reply-$MESSAGE_ID-1" \
-d '{ "text": "Your refund is on its way." }'
The same call from the TypeScript SDK:
import { messagesReply } from '@pidgeon-ai/sdk';
await messagesReply({
path: { id: messageId },
headers: { 'Idempotency-Key': `reply-${messageId}-1` },
body: { text: 'Your refund is on its way.' },
throwOnError: true,
});
In Python it is idempotency_key= on the call, and in the CLI --idempotency-key.
A random key made inside the retry loop protects nothing, because each attempt gets a new one. A key made from the text alone is wrong the other way: it would swallow a reply you meant to send twice, such as the same answer to two different customers. The key belongs to the job, so your code has to choose it.
What Pidgeon does with it
The contract is the one Stripe made familiar:
- The first request runs, and its answer is stored for 24 hours.
- A repeat with the same key and the same request sends nothing. It gets the stored answer back, with the header
Idempotent-Replay: true. - The same key with a different request is
409 idempotency_mismatch. That is a bug in the caller, and quietly replaying the old answer would hide it. Use a new key. - The same key while the first request is still running is
409 idempotency_in_progress, withRetry-After. Wait and send the identical request again. - A refusal is not stored. A
400or a429leaves the key free, so a corrected retry with the same key goes through.
The key is tied to the route with its ids, so one key used to reply to two different messages is a mismatch, not a replay. It is also tied to the API key that sent it, so another key reusing the string gets a 409 too. Keys are 1 to 255 characters.
It covers writes, not only sends: replies, forwards, drafts, scheduled sends, invitation answers, calendar events that mail their attendees, new addresses, webhooks and labels. A handful of writes are exceptions, such as rotating a key, whose answer is a new secret a replay could not carry.
When the model holds the key
A model does not invent a key before it retries. So Pidgeon's MCP server and the AI SDK tools derive one when the model passes none: a SHA-256 of the credential, the endpoint with its ids, the arguments and the current clock hour. A model that retries a timed-out reply_to_message with the same words in the same hour gets the first answer back, led by a sentence saying it was not sent again.
The cost is real and small. A message meant to go twice, word for word, in the same hour needs its own idempotency_key. A retry that straddles the hour is a new key, so one late retry in sixty is not caught. If your agent's sends matter, pass the key yourself.
The await_message tool's description tells the model the other half: a wait that timed out is answered by waiting again with the cursor, never by sending the message again.
Deduping the trigger
A key stops a repeated send. It does not stop your agent from writing a second, different reply to a repeated event, because the model will not produce the same words twice. So dedupe events where they come in.
Every event has a stable id, in the body and in the X-Pidgeon-Event-Id header. Record it before you do the work, inside the same transaction if you can, and return early when you have seen it:
const event = await verifyWebhook({ rawBody, headers, secret });
const first = await db.processedEvents.insertIfAbsent(event.id);
if (!first) return new Response(null, { status: 204 });
await queue.add('answer', { messageId: event.data.message_id });
return new Response(null, { status: 204 });
Then derive the send's key from the event: reply-${event.data.message_id}. A repeated delivery is caught at the door, and if two workers raced past it anyway, the second send replays the first.
A manual replay from Developers → Events sends the same event id again, so a handler that dedupes will ignore it. That is usually what you want after an outage. If you replay to have the work done again, clear the record first.
A second net for loops
Idempotency stops one send from going twice. It does not stop an agent stuck in a loop from writing new messages. For that, an address sends the same person at most three new messages in any 10 minutes by default, and the fourth is refused 429 recipient_cooldown. Replies are exempt, so the cooldown is not what stops a doubled reply. The key is.
Testing the retry
The retry path is the code least likely to have run before production. With a test key, the Pidgeon-Test-Fail header makes it run on purpose: drop does the send and answers 504, and delay=20s does it and answers late. Retry with the same Idempotency-Key and you get the first answer, with Idempotent-Replay: true, and one message in the thread.
Start
Sign up and send with a key from the first call. The errors reference lists both 409 codes, test mode has Pidgeon-Test-Fail, and retries covers at-least-once delivery.