# Idempotent email sending for AI agents

> Why do AI agents send the same email twice, and how do you make every send happen exactly once?

## 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 `2xx` never reached the sender, the event arrives again, and an agent that answers every `message.received` answers 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.

```sh
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:

```ts
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`, with `Retry-After`. Wait and send the identical request again.
- **A refusal is not stored.** A `400` or a `429` leaves 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](https://pidgeon.ai/docs/mcp.md) 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:

```ts
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](https://pidgeon.ai/signup) and send with a key from the first call. The [errors reference](https://pidgeon.ai/docs/errors.md) lists both `409` codes, [test mode](https://pidgeon.ai/docs/test-mode.md) has `Pidgeon-Test-Fail`, and [retries](https://pidgeon.ai/docs/webhooks/retries.md) covers at-least-once delivery.

## Questions

### Why does my agent send the same email twice?

Usually a retry. The send went out, the answer was lost, and the retry was a new request; or a webhook was delivered twice and the agent answered both.

### How long does Pidgeon remember an Idempotency-Key?

24 hours. A repeat with the same key and the same request gets the stored answer with Idempotent-Replay: true and sends nothing.

### What does a 409 on a send mean?

idempotency_mismatch means the key was already used for a different request, so use a new key. idempotency_in_progress means the first request has not answered yet, so wait for Retry-After and send the same request again.

### Do webhooks arrive more than once?

They can. Delivery is at least once, so record each event's id, also sent as X-Pidgeon-Event-Id, and return early on a repeat.
