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

  1. A retry after a timeout. Your request timed out, but the email was sent. You retried.
  2. A redelivered webhook. Payment providers send the same event again if you answered slowly.
  3. Double submits. The user clicked twice, or a mobile client resent a request.
  4. Two workers. A queue handed the same job to two processes.
  5. 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 delivered webhook) 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 delivered or bounced webhook carrying the message_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.

Related