idempotency_conflict
HTTP 409 retryable: false REST and MCP
This Idempotency-Key has already been used for a different request body, so replaying it cannot return the original result and cannot safely create a new one either.
Why it happens
The idempotency record stores a SHA-256 of the canonical payload alongside the key digest. Claiming a key is an INSERT with ON CONFLICT DO NOTHING, so exactly one request wins the key; a request that loses the race then locks the existing row and compares payload hashes. Matching hashes replay the stored response snapshot. A mismatch is this error. The comparison is over the canonical payload, so a reordered recipient list or a whitespace difference that survives canonicalisation is a different payload as far as the check is concerned.
Every condition below was read in the implementation at the file and line given. They are the branches that raise this code, not a summary of what the name suggests.
- A send reuses an Idempotency-Key whose stored payload_sha256 differs from the hash of the current request.
packages/domain/src/services.ts:1616 - The same mismatch on the commit path, checked under a row lock taken on the existing idempotency record.
packages/domain/src/services.ts:1736
How to fix it
- Generate a fresh key for a genuinely new message. A key identifies one logical send, not one retry loop and not one session.
- Derive the key from the content, not from a counter. A UUID stored alongside the message you are about to send survives a process restart; an incrementing integer does not.
- Send the byte-identical body when retrying. Regenerating a timestamp inside the payload changes the hash and turns a safe retry into this conflict.
- Keep the key at or under 128 characters. A longer one is rejected as invalid_request before it reaches the idempotency record at all.
Why it works this way
Refusing is the safe branch. The alternative, treating a same-key different-payload request as a new send, is exactly how a retry turns into a duplicate email, and the whole reason the header exists is to make that impossible.
Messages you will see
The message field is written for a human reading a log and is not a contract
value. Branch on code instead. These are the exact strings this code is sent
with today, listed so that a search for one of them lands here.
Where it appears
These are the documented surfaces that raise it. The REST base is https://api.emailforagents.ai/v1, and the remote MCP endpoint is https://mcp.emailforagents.ai/mcp.
POST /projects/{project_id}/inboxes/{inbox_id}/messages
The response
Read request_id off the body or the x-request-id header before
doing anything else. It is the only handle on one specific attempt, and for a 503 it is
the only handle at all, because the underlying detail stays server-side.
HTTP/1.1 409
content-type: application/json
x-request-id: req_...
{
"error": {
"code": "idempotency_conflict",
"message": "Idempotency-Key was reused with different content.",
"request_id": "req_...",
"retryable": false
}
}
Verified against packages/domain/src/services.ts:1616. The status comes from ERROR_STATUS in packages/contracts/src/errors.ts, which is the one
place a code is mapped to a status.