Idempotent email sending: how to avoid sending the same email twice
Updated
A user who gets two password resets is mildly annoyed. A customer who gets the same invoice three times starts doubting your system. Duplicate emails have a few predictable causes, and most are preventable with one database pattern.
Where duplicates come from
- A retry after a timeout. Your request timed out, but the email was sent. You retried.
- A redelivered webhook. Payment providers send the same event again if you answered slowly.
- Double submits. The user clicked twice, or a mobile client resent a request.
- Two workers. A queue handed the same job to two processes.
- A deploy mid-job. A worker restarted after the call but before it recorded success.
All five share a root: your code cannot tell "not sent" from "sent, but I did not hear back".
Why the API cannot save you
Some email APIs accept an idempotency key, so a repeat of the same request is ignored. The Inboxili API does not have one today. A response of sent means the provider accepted the message. A timeout means you do not know. Only your own records can bridge that gap.
The pattern: claim, then send
Record your intention before calling the API, in a way that only one process can win.
CREATE TABLE email_sends (
dedupe_key text PRIMARY KEY, -- e.g. 'invoice:INV-1042:paid'
claimed_at timestamptz NOT NULL DEFAULT now(),
message_id text,
completed_at timestamptz,
needs_review boolean NOT NULL DEFAULT false
);
import os
from inboxili import Inboxili, InboxiliConnectionError, InboxiliError
client = Inboxili(os.environ["INBOXILI_API_KEY"], max_retries=2)
def send_once(db, dedupe_key: str, **message) -> None:
# 1. Claim. The primary key makes this atomic: only one caller inserts the row.
inserted = db.execute(
"INSERT INTO email_sends (dedupe_key) VALUES (%s) ON CONFLICT DO NOTHING RETURNING 1", (dedupe_key,)
).fetchone()
if not inserted:
return # someone else already sent, or is sending
try:
result = client.emails.send(**message)
except InboxiliError:
# 2a. Rejected by the API (bad sender, 4xx, final 429): nothing was sent, release the claim.
db.execute("DELETE FROM email_sends WHERE dedupe_key = %s", (dedupe_key,))
raise
except InboxiliConnectionError:
# 2b. Outcome unknown. Keep the claim so nothing resends automatically, and flag it.
db.execute("UPDATE email_sends SET needs_review = true WHERE dedupe_key = %s", (dedupe_key,))
raise
# 3. Record success.
db.execute(
"UPDATE email_sends SET message_id = %s, completed_at = now() WHERE dedupe_key = %s",
(result.message_id, dedupe_key),
)
The key decisions:
- The dedupe key describes the email, not the attempt.
invoice:INV-1042:paid,reset:user-17:request-9. Choose it so that "the same email" always produces the same key. - Claim first. If you send first and record after, a crash between the two loses the record.
- Two kinds of failure. An API error means nothing was sent, so release the claim. A connection error means unknown, so keep it and let a person (or the
deliveredwebhook) settle it.
Settling the unknown case
For rows marked needs_review, you have two sources of truth:
- The Inboxili dashboard's sending log, which lists each API send with its status
- A
deliveredorbouncedwebhook carrying themessage_id, if the send did happen
Neither is instant, so decide your policy up front. For receipts, "resend after review" is safer than automatic. For a login code, simply allow the user to request a new one.
Webhook handlers
Apply the same idea to the events you receive. Store the provider's event ID (or message_id plus event type) with a unique constraint, and skip repeats. See verifying webhook signatures.
Choosing how strict to be
| Email | Duplicate cost | Policy |
|---|---|---|
| OTP, magic link | Low | No guard needed, rate-limit requests instead |
| Welcome | Low to medium | Guard with a welcome_sent_at column |
| Receipt, invoice, refund | High | Full claim-before-send |
| Security alert | Medium | Guard, but prefer a duplicate to a miss |
| Payment failed | Medium | Guard per billing period |
Spend the effort where a duplicate hurts. For everything else, a single timestamp column is enough.
Frequently asked questions
- Does the Inboxili API support idempotency keys?
- Not today. Prevent duplicates in your own code with a claim recorded in your database before you call the API.
- Is a duplicate OTP email a problem?
- Usually not: the user just uses the latest code. A duplicate receipt or invoice is a real problem, so guard those.
Send reliable email with Inboxili
Create a workspace, verify a domain, and make your first API call.