{"slug": "ai-agent-idempotency-prevent-duplicate-charges-emails-and-records", "title": "AI Agent Idempotency: Prevent Duplicate Charges, Emails, and Records", "summary": "A developer has published a practical design for enforcing idempotency in AI agent tool layers so that retried writes—such as payment charges, customer emails, and CRM records—happen only once. The approach places a deterministic action runner between the model and state-changing integrations, using stable idempotency keys and a Postgres-backed operation ledger with a unique constraint to prevent duplicate side effects. The guide stresses that a timeout represents an unknown commit state and must be reconciled rather than silently treated as a failure.", "body_md": "A timeout is not a failed action. It is an unknown action.\n\nThat distinction matters the first time an agent calls a payment API, sends a customer email, or creates a CRM record—and loses the response. If the agent retries blindly, it may double-charge a card or send the same message twice. If it does nothing, the user’s request may never finish. A prompt telling the model to “avoid duplicates” cannot settle that ambiguity.\n\nThe fix is **idempotency**: make each logical write happen once, even when workers crash, queues redeliver, SDKs retry, or the model asks to try again. This guide shows a practical design for putting that guarantee in the tool layer, where it can be tested and enforced.\n\nEvery distributed system has ambiguous failures. A request may reach the target, commit its side effect, and then lose its response. Traditional backends solve this with idempotency keys, unique constraints, durable jobs, and reconciliation.\n\nAgents amplify the problem because they are built to keep trying. A model sees `timeout`, changes its plan, and asks the same tool to run again. Meanwhile, a queue may redeliver the job after a lease expires, and an HTTP client may retry underneath both of them. One user intent can become four writes.\n\nTreat these as distinct outcomes:\n\n| Outcome | What the caller knows | Safe next step | \n|---|---|---|\n| Rejected before send | The target did not receive it | Correct input, then retry if appropriate | \n| Explicit failure | The target responded with a permanent error | Stop or request correction | \n| Explicit success | The target returned a durable receipt | Store and return the receipt | \n| Unknown commit state | The request may have succeeded | Reconcile before retrying | \n\nThe last row is the important one. A timeout after send must never be silently mapped to “failed.”\n\nAn idempotency key identifies the business action, not a network attempt.\n\nFor example, “send the approved invoice email for invoice `inv_123` revision 4” is one action. It should keep the same key if a worker restarts five times. A later resend after a user edits the invoice is a new action and needs a new key.\n\nGood keys are stable, scoped, and inspectable:\n\n```\ntenant:acme | run:run_8f2 | step:email_invoice | invoice:inv_123 | revision:4\n```\n\nHash that canonical representation if it contains sensitive identifiers. Do not generate a new UUID on every retry; that turns a dedupe mechanism into a duplicate generator. Also do not use only the user prompt. “Email this invoice” can be a valid request more than once.\n\nAn action should usually include:\n\nThe agent should request a business action, not manipulate retry behavior directly. Place a deterministic action runner between the model and every state-changing integration.\n\n``` php\nagent plan\n   -> tool request (business intent)\n   -> action runner (policy + operation ledger)\n   -> external API / database\n   -> receipt + reconciliation result\n   -> agent\n```\n\nThis boundary is useful even if your tools are ordinary functions. It gives the system one place to validate tenant access, lock the operation, pass provider keys, classify failures, and hide unsafe retry choices from the model.\n\nReads can often be retried. Writes need an operation record first.\n\nPostgres is enough for many teams. Create the operation before calling the external service; keep it until the action’s replay window is over.\n\n```\ncreate table agent_operations (\n  id uuid primary key,\n  tenant_id uuid not null,\n  idempotency_key text not null,\n  action_type text not null,\n  args_hash text not null,\n  status text not null check (status in (\n    'pending', 'running', 'succeeded', 'unknown', 'failed', 'needs_review'\n  )),\n  provider_reference text,\n  result_json jsonb,\n  error_json jsonb,\n  lease_expires_at timestamptz,\n  created_at timestamptz not null default now(),\n  updated_at timestamptz not null default now(),\n  unique (tenant_id, idempotency_key)\n);\n```\n\nThe unique constraint is a hard concurrency boundary. Two workers can receive the same job, but only one can own the logical action. Store an `args_hash` too: if the same key arrives with different arguments, fail closed. Reusing a key with changed data is almost always a caller bug.\n\n`pending` means no worker has started the side effect. `running` means a worker holds a short lease. `succeeded` stores the exact receipt to return on a duplicate call. `unknown` means the downstream result is ambiguous, so a reconciliation job—not an agent—must decide what happened.\n\nAvoid marking an operation `failed` simply because the HTTP request timed out. That destroys the evidence needed to prevent a duplicate.\n\nHere is a simplified pattern. In production, the `claim` query should use a transaction and a lease so a crashed worker can be recovered safely.\n\n```\ntype ActionResult = { receiptId: string; status: \"sent\" | \"already_sent\" };\n\nasync function sendInvoiceEmail(input: {\n  tenantId: string;\n  runId: string;\n  invoiceId: string;\n  revision: number;\n  to: string;\n}): Promise<ActionResult> {\n  const key = `invoice-email:${input.tenantId}:${input.invoiceId}:${input.revision}`;\n  const argsHash = sha256(JSON.stringify({ to: input.to, revision: input.revision }));\n\n  const operation = await operations.claim({\n    tenantId: input.tenantId,\n    key,\n    actionType: \"invoice_email\",\n    argsHash,\n    leaseSeconds: 60,\n  });\n\n  if (operation.status === \"succeeded\") return operation.resultJson;\n  if (operation.status === \"unknown\") return reconcileInvoiceEmail(operation, input);\n  if (operation.status !== \"running\") throw new Error(\"Action is not safe to execute\");\n\n  try {\n    const response = await emailProvider.send({\n      to: input.to,\n      template: \"invoice\",\n      metadata: { operationId: operation.id },\n      idempotencyKey: key,\n    });\n\n    return await operations.succeed(operation.id, {\n      receiptId: response.messageId,\n      status: \"sent\",\n    });\n  } catch (error) {\n    if (isAmbiguousTransportError(error)) {\n      await operations.markUnknown(operation.id, serialize(error));\n      return reconcileInvoiceEmail(operation, input);\n    }\n    await operations.fail(operation.id, serialize(error));\n    throw error;\n  }\n}\n```\n\nNotice what is missing: no model instruction decides whether an unknown write gets replayed. The wrapper returns a receipt, a verified “already completed” result, or an explicit escalation.\n\nMany third-party APIs do not support idempotency keys—or claim to accept them without making them searchable. Your internal ledger still helps, but it cannot prove the external effect occurred.\n\nMake the action observable at the target. Depending on the system, reconciliation can query:\n\nFor an email, attach `operationId` as provider metadata and persist the provider message ID. For a CRM create, send an external ID derived from the operation key. For an internal database mutation, use a unique `operation_id` column or an `INSERT ... ON CONFLICT` pattern.\n\nIf reconciliation cannot prove success or failure, keep the operation in `needs_review`. This is safer than inventing an answer. The agent can tell the user that the action is pending verification rather than claiming it completed.\n\n“Retry three times” is too blunt. A safe policy distinguishes failure modes.\n\n| Failure class | Example | Retry policy | \n|---|---|---|\n| Safe read | Search endpoint returns 503 | Exponential backoff with a limit | \n| Validation error | Missing required field | Do not retry; return correction needed | \n| Rate limit | HTTP 429 | Wait for the provider signal, then retry same operation | \n| Ambiguous write | Socket closes after request body | Mark unknown and reconcile | \n| Known transient write failure | Provider confirms no commit | Retry same operation key | \n| Auth or policy denial | Scope removed | Stop and escalate | \n\nRetry budgets should also be outside the model. Put limits on attempts, elapsed time, spend, and allowed action types. This makes a bad downstream day finite instead of turning it into an overnight retry loop.\n\nUnit tests that expect a 200 response are not enough. Build a small fault-injection suite around every write tool.\n\nTest at least these cases:\n\n`succeeded` is stored.\nAn effective assertion is simple: after any number of retries, the target contains exactly one effect for the operation key. Also test the user-visible result: a duplicate request should return the original receipt, not an opaque error.\n\nTrack these counters by tenant, tool, and provider:\n\n`unknown`\nThese are more useful than raw timeout counts. A rise in unknown commits may reveal a provider regression, an overly short client timeout, or a worker shutdown problem. A rise in duplicate calls can mean your queue is redelivering as designed—or that an upstream client is misbehaving.\n\nAdd an audit event each time an operation moves state. Include the actor, agent run, tool version, key hash, arguments hash, provider reference, and decision made by reconciliation. Do not store sensitive prompt or customer data just for convenience.\n\nStart with the highest-impact tools: payments, email sends, record creation, access changes, and anything that triggers work outside your system. Inventory every write-side tool and give it an action type, stable key recipe, reconciliation strategy, and owner.\n\nThen ship in stages:\n\nThis is a better investment than ever more prompt rules. Prompts can help an agent choose a valid action; they cannot offer exactly-once delivery across a network.\n\nAI agent idempotency means repeating the same logical agent action produces one durable effect, not multiple ones. It protects state-changing tools from retries, worker crashes, duplicate queue messages, and repeated model tool calls.\n\nEvery state-changing tool should have an idempotency strategy. Read-only tools can normally use conventional retry policies. For writes, use provider keys, internal unique constraints, or an operation ledger plus reconciliation.\n\nNo. A prompt influences model behavior but cannot coordinate concurrent workers, recover a lost response, or prove whether a remote API committed an action. Enforce deduplication in the action runner and target system.\n\nTreat it as an unknown commit state. Record the ambiguity, query the system of record using a correlation ID or business key, and retry only if you can establish that no effect occurred.\n\nKeep them at least as long as every possible replay window: queue retention, client retries, scheduled job retries, and provider webhook delays. High-risk actions such as payments often need longer retention and durable audit evidence.\n\nNo. Most infrastructure offers at-least-once delivery. Idempotency makes repeated delivery safe by ensuring the receiver commits a logical action once and returns the original result to later attempts.\n\nReliable agent systems do not pretend a timeout means failure. They preserve the operation, reconcile the target, and only then decide whether a retry is safe. Give every write tool a stable identity and a durable receipt, and an agent can be persistent without becoming destructive.", "url": "https://wpnews.pro/news/ai-agent-idempotency-prevent-duplicate-charges-emails-and-records", "canonical_source": "https://dev.to/jackm-singularity/ai-agent-idempotency-prevent-duplicate-charges-emails-and-records-2emk", "published_at": "2026-09-16 09:34:47+00:00", "updated_at": "2026-09-16 09:41:49.393373+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-infrastructure"], "entities": ["Postgres"], "alternates": {"html": "https://wpnews.pro/news/ai-agent-idempotency-prevent-duplicate-charges-emails-and-records", "markdown": "https://wpnews.pro/news/ai-agent-idempotency-prevent-duplicate-charges-emails-and-records.md", "text": "https://wpnews.pro/news/ai-agent-idempotency-prevent-duplicate-charges-emails-and-records.txt", "jsonld": "https://wpnews.pro/news/ai-agent-idempotency-prevent-duplicate-charges-emails-and-records.jsonld"}}