Idempotency key
An idempotency key is a caller-supplied string naming one intended message rather than one attempt at sending it, so a retried request replays the original response instead of producing a second email.
An HTTP request times out. The agent does not know whether the server accepted it.
Sending again may produce two emails to a customer; doing nothing may lose the
message. The Idempotency-Key header removes the guess, but only if the key is
derived correctly, because a carelessly chosen key reintroduces the exact duplicate
it was supposed to prevent.
The rule in one 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 it can be reconstructed from the invoice ID. A
fresh crypto.randomUUID() generated inside the retry loop is the worst available
key: it makes every attempt a distinct message and turns the header into decoration.
Write the intended payload and the key to durable storage before the first attempt. An in-memory value is not enough when a worker dies between issuing the request and recording the response, because on restart it has no way to learn which attempt was already in flight.
What the key is bound to
Not the raw request body. The request is normalised first into a canonical payload:
{ inbox_id, action, reply_to_message_id, to[], cc[], bcc[], subject, text, html, attachment_ids[] }
Recipient domains are lower-cased, local parts are left alone, and a missing text
or html becomes an empty string. The object is serialised with sorted keys, 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 follow directly. The record is per project, so the same key
string in a test project and a live project are different records, which is what
lets one deterministic key scheme run in both. And only the canonical fields count:
changing metadata does not change the hash, while changing a recipient’s display
name does, because the name is part of the recipient object.
The four outcomes of reusing a key
| Situation | Response |
|---|---|
| Same key, same canonical payload | HTTP 202, the original response replayed |
| Same key, different payload | HTTP 409 idempotency_conflict |
| Same key, first attempt still in flight | HTTP 503 temporarily_unavailable |
| Same key, more than 30 days later | HTTP 409 idempotency_expired |
The replay window is 30 days. The key itself is then held as a tombstone for a further 365 days, so it cannot be silently recycled into a different message a year later.
The conflict case is the useful one. It fires exactly when your code believes it is retrying and is in fact sending something new, which makes it a bug detector rather than an obstacle. 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. Retrying an agent’s email safely covers the key derivation patterns that avoid getting there.
What an idempotency key is not
It is not event deduplication. Inbound event delivery is at-least-once and dedupes on event ID, in your own storage, with your own record of what you have already processed. The two mechanisms protect different halves of the loop and neither substitutes for the other, which is the distinction handling webhooks without processing events twice exists to make.
It is not a lock. A 503 on a concurrent attempt means a matching send is in progress, not that your request has been queued behind it. Back off and re-read the message.
And it does not extend to approvals. Approving a held message requires its own
Idempotency-Key, keyed in a separate namespace from the send it releases, so a
double-clicked approve button is safe for a reason unrelated to the send key. The
full request and response shapes are on
the developer overview.