Idempotency for AI Agents: Stop Double-Sending Emails

Developer reviewing terminal logs on laptop while debugging duplicate AI agent email sends in production

Your agent sent the same follow-up email three times to the same lead last Tuesday. You found out because the prospect replied, politely, asking if everything was okay. The logs show three successful runs, three "200 OK" responses from your email provider, and three identical entries in your CRM. Nothing crashed. That's the problem.

This is the failure mode nobody warns you about when you start wiring up AI agents. Models retry on timeouts, schedulers overlap, queues redeliver, and humans click "run" twice because the UI didn't update fast. Without idempotency, every one of those becomes a duplicate side effect on the outside world — a duplicate email, a duplicate invoice, a duplicate Stripe charge.

Below is the playbook I use when building agents that touch real systems. It works whether you're orchestrating with LangGraph, a cron job, n8n, or a homegrown Python loop.

Why agents duplicate actions in the first place

Traditional scripts run once and crash if something goes wrong. Agents are different. They're built to recover, which means they're built to retry. That's the whole point — but it's also where the duplicates come from.

The common sources, in order of how often I see them in production:

  • Tool-call retries inside the model loop. The LLM calls send_email, the HTTP request times out at 30s, the framework retries. The email was actually sent the first time; the second call sends it again.
  • Worker crashes between "did the work" and "marked it done." Classic at-least-once delivery problem. The job processed, the email went out, the worker died before updating the queue. Next worker picks it up.
  • Overlapping schedulers. A cron runs every 5 minutes. One run takes 7 minutes. Now two workers are processing the same batch of leads.
  • Human re-triggers. Someone clicks "resend the daily digest" because the dashboard hasn't refreshed yet.
  • Multi-agent handoffs. Agent A passes a task to Agent B, but also retries because it didn't see the handoff acknowledged.

The fix is not "make retries impossible." Retries are good. The fix is making every side-effecting action safe to repeat — so even if it runs ten times, only one email actually goes out.

The mental model: idempotency keys as the contract

An idempotency key is a string that uniquely identifies an intent, not an execution. If you submit the same intent twice with the same key, the system promises to perform the work at most once.

Stripe popularized this pattern for payments. The same logic applies to any agent action: send email, create invoice, post to Slack, update CRM field, file a ticket.

The key design rule: the idempotency key must be derivable from the inputs, not generated at call time. If your agent generates a fresh UUID every time it decides to send a follow-up, you have no protection — the retry will generate a different UUID.

A good idempotency key for an outbound email might be:

import hashlib

def email_idempotency_key(lead_id: str, template: str, day: str) -> str:
    raw = f"{lead_id}:{template}:{day}"
    return hashlib.sha256(raw.encode()).hexdigest()[:32]

# Same lead, same template, same day → same key, no matter how many retries
key = email_idempotency_key("lead_8821", "followup_v2", "2025-01-15")

The day is in there because you probably do want to send the same follow-up template to the same lead on a different day. The key encodes "the thing I am trying to do," not "this specific attempt."

Pattern 1: Dedupe table at the boundary

The simplest pattern. Before performing any side-effecting action, write the idempotency key to a database table with a unique constraint. If the insert succeeds, you're the first caller — do the work. If it fails on the unique constraint, someone else already did it.

CREATE TABLE agent_action_dedupe (
    idempotency_key TEXT PRIMARY KEY,
    action_type     TEXT NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    result          JSONB
);
import psycopg

def send_email_once(conn, key: str, to: str, subject: str, body: str):
    try:
        with conn.transaction():
            conn.execute(
                "INSERT INTO agent_action_dedupe (idempotency_key, action_type) "
                "VALUES (%s, %s)",
                (key, "send_email"),
            )
            result = email_provider.send(to=to, subject=subject, body=body)
            conn.execute(
                "UPDATE agent_action_dedupe SET result = %s WHERE idempotency_key = %s",
                (json.dumps(result), key),
            )
            return result
    except psycopg.errors.UniqueViolation:
        # Someone else already sent this. Return the cached result.
        row = conn.execute(
            "SELECT result FROM agent_action_dedupe WHERE idempotency_key = %s",
            (key,),
        ).fetchone()
        return row[0]

This works. It has one subtle bug, though: if the worker crashes between the INSERT and the email_provider.send, the next retry will hit the unique violation and skip the send entirely. You've now lost the email instead of duplicating it.

The fix is to make the "did we actually send it" check separate from the "did we claim this key" check. Which leads to the next pattern.

Pattern 2: State-machine actions with explicit phases

Treat every side effect as a small state machine with at least three states: claimed, sent, confirmed. Persist the state. On retry, look at the state and decide what to do.

def send_email_stateful(conn, key, to, subject, body):
    row = conn.execute(
        "SELECT state, provider_message_id FROM agent_action_dedupe "
        "WHERE idempotency_key = %s FOR UPDATE",
        (key,),
    ).fetchone()

    if row is None:
        conn.execute(
            "INSERT INTO agent_action_dedupe (idempotency_key, action_type, state) "
            "VALUES (%s, 'send_email', 'claimed')",
            (key,),
        )
        state = "claimed"
        provider_msg_id = None
    else:
        state, provider_msg_id = row

    if state == "confirmed":
        return {"status": "already_sent", "message_id": provider_msg_id}

    if state == "claimed":
        # Pass our idempotency key to the provider too, if it supports it
        result = email_provider.send(
            to=to, subject=subject, body=body,
            idempotency_key=key,  # belt and suspenders
        )
        conn.execute(
            "UPDATE agent_action_dedupe SET state = 'confirmed', "
            "provider_message_id = %s WHERE idempotency_key = %s",
            (result["message_id"], key),
        )
        return result

Two things make this robust:

  1. FOR UPDATE row lock. Two concurrent workers racing on the same key will serialize. The second one waits, sees state = confirmed, and returns the cached result without sending.
  2. Pass the key downstream. Most modern providers (Stripe, SendGrid via custom headers, Postmark, Resend) accept idempotency keys. Use them. Your local dedupe protects against your own retries; the provider's dedupe protects against network weirdness between you and them.

Pattern 3: Transactional outbox for multi-step actions

When an agent action involves both a database write and an external call, you need the outbox pattern. Without it, you can't atomically "update the CRM and send the email" — one will succeed and the other will fail, and a retry will repeat whichever one succeeded.

The outbox decouples them. The agent writes its intent to a local table inside the same transaction as the business state. A separate worker reads the outbox and performs the external call with an idempotency key.

CREATE TABLE outbox (
    id              BIGSERIAL PRIMARY KEY,
    idempotency_key TEXT UNIQUE NOT NULL,
    action_type     TEXT NOT NULL,
    payload         JSONB NOT NULL,
    status          TEXT NOT NULL DEFAULT 'pending',
    attempts        INT NOT NULL DEFAULT 0,
    next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    completed_at    TIMESTAMPTZ
);

CREATE INDEX ON outbox (status, next_attempt_at) WHERE status = 'pending';
# Inside the agent's tool call — runs in the same transaction as CRM updates
def queue_email(conn, lead_id, template, day, payload):
    key = email_idempotency_key(lead_id, template, day)
    conn.execute(
        "INSERT INTO outbox (idempotency_key, action_type, payload) "
        "VALUES (%s, 'send_email', %s) "
        "ON CONFLICT (idempotency_key) DO NOTHING",
        (key, json.dumps(payload)),
    )

# Separate worker — drains the outbox
def drain_outbox(conn):
    rows = conn.execute(
        "SELECT id, idempotency_key, payload FROM outbox "
        "WHERE status = 'pending' AND next_attempt_at <= now() "
        "ORDER BY id LIMIT 50 FOR UPDATE SKIP LOCKED"
    ).fetchall()

    for row_id, key, payload in rows:
        try:
            email_provider.send(idempotency_key=key, **payload)
            conn.execute(
                "UPDATE outbox SET status = 'sent', completed_at = now() "
                "WHERE id = %s",
                (row_id,),
            )
        except TransientError:
            conn.execute(
                "UPDATE outbox SET attempts = attempts + 1, "
                "next_attempt_at = now() + interval '1 minute' * attempts "
                "WHERE id = %s",
                (row_id,),
            )

FOR UPDATE SKIP LOCKED is the magic for multiple workers — they each grab a different batch without blocking. The ON CONFLICT DO NOTHING means the agent can call queue_email ten times and only one row lands.

This pattern is more code, but it's the only one that gives you atomicity between your business state and your outbound side effects. For anything involving money or contracts, use it.

Pattern 4: Time-windowed natural keys

Sometimes you don't need a database at all. If your action's "intent" is naturally periodic — "send the weekly digest to user X for week 2025-W03" — the idempotency key falls out of the data itself, and a unique constraint on the destination is enough.

Action Natural key
Daily digest digest:user_id:YYYY-MM-DD
Weekly report report:account_id:YYYY-Www
Follow-up #2 after demo followup:lead_id:demo_id:2
Invoice for billing period invoice:customer_id:period_id
Lead enrichment enrich:lead_id:source

For invoices and reports, you can often just add a unique constraint to the destination table and let the database enforce it. Cheaper than a separate dedupe layer, and harder to get wrong.

Choosing the right pattern

Pattern Use when Skip when
Dedupe table Single-step external call, no DB state to update Action involves DB writes that must be atomic with the call
State machine You need to recover from crashes mid-call Action is cheap and rare
Outbox Action involves DB writes + external calls Pure read-only or pure external
Natural key Action is periodic with obvious uniqueness Same intent can legitimately happen N times in the same window

The honest answer for most agent setups: start with the dedupe table, upgrade to outbox the first time you have an action that touches both your DB and an external system.

What about the LLM itself?

Idempotency at the tool layer fixes the side effects. But the LLM can still hallucinate calling the same tool twice in one reasoning trace. Two defenses:

Tool-call deduplication inside the agent loop. If the model emits the same tool call with the same arguments twice in a row, don't execute it twice. Return the cached result from the first call. Most agent frameworks don't do this by default — you have to add it.

def execute_tool_call(call, history):
    sig = (call.name, json.dumps(call.arguments, sort_keys=True))
    for prior in history:
        if prior.signature == sig:
            return prior.result  # don't re-run
    result = TOOLS[call.name](**call.arguments)
    history.append(ToolCall(signature=sig, result=result))
    return result

Make the idempotency key part of the tool signature. Force the model (or the framework wrapping it) to compute the key before calling. If the model retries the call, it must produce the same key, or your dedupe layer rejects it as a new intent.

Testing that your idempotency actually works

The bug you don't want to discover in production: thinking you have idempotency when you don't. The test is brutally simple — run every action twice and assert the side effect happened once.

def test_send_followup_is_idempotent(db, fake_email_provider):
    payload = {"lead_id": "lead_8821", "template": "followup_v2", "day": "2025-01-15"}

    send_followup(db, **payload)
    send_followup(db, **payload)
    send_followup(db, **payload)

    assert len(fake_email_provider.sent) == 1

Run this test for every side-effecting tool your agent has. Include a variant that simulates a crash mid-action:

def test_send_followup_recovers_from_crash(db, flaky_email_provider):
    flaky_email_provider.fail_after_send = True  # send, then raise

    with pytest.raises(NetworkError):
        send_followup(db, lead_id="lead_8821", ...)

    flaky_email_provider.fail_after_send = False
    send_followup(db, lead_id="lead_8821", ...)  # retry

    # The provider received two calls, but only one email went out
    # because the provider's idempotency layer dedupes the second
    assert flaky_email_provider.actually_sent_count == 1

If you can't easily write these tests, your design is wrong — the idempotency concern is too tangled into business logic. Refactor until it's a thin wrapper.

How BizFlowAI approaches this

Every action in an agent we ship is retry-safe by design. Email sends, CRM updates, invoice creation, Slack posts — each one has a deterministic idempotency key derived from inputs, a dedupe layer in our database, and (where the provider supports it) the same key passed downstream. Workers use FOR UPDATE SKIP LOCKED so overlapping schedulers can't double-process a queue. Multi-step flows use the outbox pattern, so a crash mid-flow doesn't leave you with a half-sent campaign.

If you're already running agents and you've had a "why did this go out twice" incident, a discovery call is the fastest way to figure out which patterns apply to your stack and which ones you can skip. We'll look at the actual flows, not give a generic answer.

The short version

Agents retry. That's a feature. Side effects don't get to retry — that's a bug. The fix is:

  1. Derive idempotency keys from inputs, not call time.
  2. Claim the key in your own database before doing the work.
  3. Pass the key downstream to providers that support it.
  4. Use the outbox pattern when DB writes and external calls need to be atomic.
  5. Write a test that runs every action twice and asserts it happened once.

Do this once per tool, and the "why did the agent send three emails" tickets stop showing up.

Frequently asked questions

What is an idempotency key for AI agents?

An idempotency key is a deterministic string that uniquely identifies the intent of an action, not a specific execution attempt. When an AI agent submits the same action twice with the same key, the system performs the work at most once. The key must be derived from inputs (like lead_id, template, and date) rather than generated at call time, so retries produce the same key. This pattern, popularized by Stripe, prevents duplicate emails, invoices, or charges from agent retries.

Why do AI agents send duplicate emails or perform actions twice?

AI agents duplicate actions because they are designed to recover and retry. Common causes include tool-call retries when an HTTP request times out after the work succeeded, worker crashes between doing work and marking it done, overlapping schedulers where one run takes longer than its interval, human re-triggers from stale UIs, and multi-agent handoffs that retry before acknowledgment. The fix is making side effects safe to repeat, not eliminating retries.

How do I implement a dedupe table for agent actions in PostgreSQL?

Create a table with idempotency_key as the primary key, then insert the key inside a transaction before performing the side effect. If the insert succeeds, do the work; if it fails on the unique constraint, the action already ran and you return the cached result. To avoid losing work when a worker crashes between claiming the key and performing the action, track explicit states like 'claimed', 'sent', and 'confirmed' and use SELECT FOR UPDATE to serialize concurrent workers.

What is the transactional outbox pattern and when should agents use it?

The outbox pattern decouples database writes from external API calls by writing the intent to a local outbox table in the same transaction as the business state. A separate worker drains the outbox and performs the external call with an idempotency key. Use it whenever an agent action combines a local DB update (like a CRM change) with an external call (like sending email), so that retries don't repeat whichever step succeeded. It guarantees atomicity between internal state and external side effects.

Should I pass idempotency keys to providers like Stripe, SendGrid, or Resend?

Yes. Your local dedupe table protects against your own application retries, but network issues between your service and the provider can still cause duplicate requests. Most modern providers including Stripe, Postmark, and Resend accept idempotency keys via headers or parameters. Passing the same key downstream is a belt-and-suspenders approach that ensures the provider also dedupes on their side, eliminating duplicates even when the network between you fails mid-request.


Work with BizFlowAI

If you'd rather have this built for you, that's what we do: production AI automation for solo founders and small teams — agents, integrations, and document pipelines that actually ship.

Book a free discovery call — 30 minutes, we map the highest-ROI automation in your workflow. No pitch deck, just engineering.

More guides like this on the BizFlowAI blog.

Frequently asked questions

What is an idempotency key for AI agents?

An idempotency key is a deterministic string that uniquely identifies the intent of an action, not a specific execution attempt. When an AI agent submits the same action twice with the same key, the system performs the work at most once. The key must be derived from inputs (like lead_id, template, and date) rather than generated at call time, so retries produce the same key. This pattern, popularized by Stripe, prevents duplicate emails, invoices, or charges from agent retries.

Why do AI agents send duplicate emails or perform actions twice?

AI agents duplicate actions because they are designed to recover and retry. Common causes include tool-call retries when an HTTP request times out after the work succeeded, worker crashes between doing work and marking it done, overlapping schedulers where one run takes longer than its interval, human re-triggers from stale UIs, and multi-agent handoffs that retry before acknowledgment. The fix is making side effects safe to repeat, not eliminating retries.

How do I implement a dedupe table for agent actions in PostgreSQL?

Create a table with idempotency_key as the primary key, then insert the key inside a transaction before performing the side effect. If the insert succeeds, do the work; if it fails on the unique constraint, the action already ran and you return the cached result. To avoid losing work when a worker crashes between claiming the key and performing the action, track explicit states like 'claimed', 'sent', and 'confirmed' and use SELECT FOR UPDATE to serialize concurrent workers.

What is the transactional outbox pattern and when should agents use it?

The outbox pattern decouples database writes from external API calls by writing the intent to a local outbox table in the same transaction as the business state. A separate worker drains the outbox and performs the external call with an idempotency key. Use it whenever an agent action combines a local DB update (like a CRM change) with an external call (like sending email), so that retries don't repeat whichever step succeeded. It guarantees atomicity between internal state and external side effects.

Should I pass idempotency keys to providers like Stripe, SendGrid, or Resend?

Yes. Your local dedupe table protects against your own application retries, but network issues between your service and the provider can still cause duplicate requests. Most modern providers including Stripe, Postmark, and Resend accept idempotency keys via headers or parameters. Passing the same key downstream is a belt-and-suspenders approach that ensures the provider also dedupes on their side, eliminating duplicates even when the network between you fails mid-request.