Preventing Duplicate Transactional Sends With Idempotency Keys
Stop duplicate receipts and double password resets: derive stable idempotency keys from business events, understand provider key windows, and add a local guard.
Duplicate receipts are one of the few email defects a customer will report immediately, and they almost never come from a bug in your send code. They come from a retry after a response that was lost in transit. This guide covers deriving a key that makes the retry harmless, and the local guard that covers the cases the provider's key cannot.
Root Cause: The Response Is Lost, Not the Request
A send call has three possible outcomes, and only two of them are visible to the caller. The request may fail before the provider receives it, in which case nothing was sent and a retry is correct. It may succeed and return, in which case everything is clear. Or it may succeed at the provider and fail on the way back — a timeout, a dropped connection, a load balancer terminating an idle socket.
From your side, the third case is indistinguishable from the first. Both surface as an exception with no response body. A retry policy that cannot tell them apart must retry, because the alternative is silently dropping messages that genuinely failed — and each retry of the third case produces one more copy in the recipient's inbox.
An idempotency key resolves the ambiguity by moving it to the party who knows the answer. The provider recorded the first request under that key; when the same key arrives again it returns the original result rather than performing the work a second time.
The Exact Fix: Derive the Key From the Business Event
A key generated at send time defeats the mechanism, because each retry generates a different one. The key must be a deterministic function of what the message is, computable identically on every attempt.
# keys.py — a key that is identical on every attempt for the same logical message.
import hashlib
def send_key(event_type: str, entity_id: str, recipient: str, version: str = "v1") -> str:
"""Stable across retries, distinct across genuinely different sends.
event_type the reason for the message: "order.receipt", "password.reset"
entity_id the specific thing it is about: the order id, the reset token id
recipient the address, so a re-send to a corrected address is a new message
version bumped deliberately when a resend of the SAME event is intended
"""
material = f"{version}:{event_type}:{entity_id}:{recipient}".lower()
return hashlib.sha256(material.encode()).hexdigest()[:48]
# Correct: derived from the order, so every retry produces the same key.
key = send_key("order.receipt", order.id, customer.email)
# Wrong: a fresh value per attempt means the provider sees each retry as new work.
# key = str(uuid.uuid4())
Including the recipient in the material matters more than it first appears. If a receipt is initially sent to a mistyped address and then re-sent to a corrected one, those are genuinely different messages and must not collapse to one key. Including a version component gives you a deliberate escape hatch: a support agent re-sending a receipt on request bumps the version, producing a new key and an intentional second copy.
Provider Key Windows
Providers remember keys for a bounded period, and the window is shorter than most retry budgets assume.
| Provider | Mechanism | Retention |
|---|---|---|
| Amazon SES | x-ses-idempotency-key on v2 send |
Minutes, best-effort |
| SendGrid | Custom args plus your own guard | No native key |
| Postmark | Message stream deduplication | Short, per stream |
| Mailgun | v:idempotency variable plus your own guard |
No native key |
The gap this leaves is real: a message that dead-letters, is triaged and is replayed an hour later will be outside every provider's window. A local guard closes it.
-- One row per logical message. The unique constraint is the guarantee; the
-- application-level check is only an optimisation in front of it.
CREATE TABLE sent_message (
idempotency_key text PRIMARY KEY,
provider_message_id text,
recipient text NOT NULL,
event_type text NOT NULL,
sent_at timestamptz NOT NULL DEFAULT now()
);
def send_once(db, esp, key, **message):
# Claim the key first. If another worker already holds it, this raises and
# we know the message is already sent or in flight.
try:
db.execute(
"INSERT INTO sent_message (idempotency_key, recipient, event_type) "
"VALUES (%s, %s, %s)",
(key, message["to"], message["event_type"]),
)
except UniqueViolation:
return {"deduplicated": True}
result = esp.send(idempotency_key=key, **message)
db.execute(
"UPDATE sent_message SET provider_message_id = %s WHERE idempotency_key = %s",
(result.message_id, key),
)
return result
Claiming the key before the send rather than after is what makes this correct under concurrency. Recording it afterwards leaves a window in which two workers both check, both find nothing, and both send.
Variant: When a Duplicate Is Actually Wanted
Some messages are legitimately sent more than once with the same content: a reminder, a re-sent verification code the user requested again, a monthly statement. These are not duplicates — they are distinct events that happen to look alike.
Model them as such. A re-requested verification code has a new token, so the token id in the key material differs and the key is naturally distinct. A monthly statement includes the period. The rule is that if the key material contains everything that makes the message a specific occurrence, wanted repeats produce distinct keys automatically and unwanted ones do not.
Pipeline Integration
Compute the key at enqueue time, not in the worker, and carry it in the job payload. A key computed inside the worker is recomputed on each attempt, and any drift in the inputs — a lazily-loaded field, a changed timestamp — produces a different key on the retry, which is the exact failure the mechanism exists to prevent.
Pass the same key to both the local guard and the provider. The two protect different windows: the local table covers replays hours or days later, the provider's key covers the tight retry loop where your own insert may not yet have committed.
Validation and Deployment Checklist
Debugging: Named Symptoms
Symptom: duplicates persist despite a key being passed. Cause: the key is being generated per attempt rather than carried in the job. Fix: log the key on every attempt and confirm it is identical across retries of the same message — if it differs, it is being computed rather than carried.
Symptom: a legitimate re-send is silently swallowed. Cause: the key material does not include anything that distinguishes the occurrences, so a genuinely new message collides with an old one. Fix: include the specific token, order revision or period in the material, or bump the version component for a deliberate re-send.
Symptom: duplicates only appear during deploys. Cause: a rolling restart interrupting in-flight sends after the provider accepted them, with the replacement worker retrying under a freshly generated key. Fix: carry the key in the payload so the replacement worker inherits it.
Frequently Asked Questions
How long should the local guard table retain rows?
Longer than your longest replay window — thirty days is a reasonable default for transactional mail. The table grows by one row per message sent, so a retention job is required at volume, but pruning too aggressively re-opens the window that the provider's short key retention already leaves open.
Does this protect against a user clicking "send" twice?
Only if the two clicks produce the same key material. A user requesting two password resets legitimately generates two tokens, so the keys differ and both messages send — which is correct, since the first token may already have been invalidated. Debouncing user-initiated actions is a separate concern, handled at the application layer rather than in the send path.
What if the insert succeeds but the send then fails permanently?
The key is claimed for a message that was never delivered, so a later retry will be deduplicated away. Delete the row when a send dead-letters permanently, or record the outcome on the row and treat a failed status as un-claimed on replay. Claiming without releasing is the one way this pattern can lose a message.
Is a hash necessary, or can the key be readable?
Readable keys are fine and easier to debug — order.receipt:12345:a@example.com works. Hashing helps only where the key length is constrained by the provider or where the material contains something you would rather not send to a third party, such as an address in a system where that is sensitive.
Related
- Email Queueing and Retry Architecture — the retry loop these keys make safe
- Designing a Dead-Letter Queue — where a preserved key matters most during replay
- ESP Selection and Integration — how the provider abstraction should surface key support
- Building an Idempotent Webhook Consumer — the same reasoning applied to inbound events
← Back to Email Queueing and Retry Architecture