Skip to content
6 min read

Resuming an Agent After Approval Without a Second Payment

Resume an agent after approval without duplicates: operation logs, idempotency, payment reconciliation, and safe CRM writes.

Resuming an Agent After Approval Without a Second Payment

An agent should not repeat an action just because the process has started again. After approval, the danger usually comes not from the model but from a gap between three events: the request has already been sent to an external service, the response was not saved, and after resume the orchestrator sees an unfinished step and calls the tool again.

Payments, refunds, contract delivery, CRM deal creation, and order status changes all follow the same rule: before pausing, save an immutable intent, and after resuming, first establish whether execution already happened. Only after that check should a tool call be allowed. You cannot assume that «approval already happened, so repeating the action is safe».

A pause does not remove uncertainty from an external call

Approval divides the process into two parts, but it does not make the second part atomic. A person approved a payment. The agent sent a request to the bank. The bank accepted the request and created a transfer. At that moment, the network failed or the worker stopped before saving the response. On the next run, you have no right either to declare the operation failed or to send it again.

Call this state what it is: unknown. It means the system does not know whether the external change happened. Many implementations simplify it to failed because that makes the interface and retries easier to build. Later, accounting looks for a second transfer, while the team tries to determine which of the two workers was «actually» responsible.

A side-effecting action has four separate moments:

  1. The agent formed an intent.
  2. A person approved that exact intent.
  3. The external service accepted or rejected the call.
  4. Your application reliably recorded the result.

A failure is always possible between the third and fourth moments. That is why the operation log must exist separately from the conversation history, LLM tracing, and task queue. The conversation explains why the agent proposed an action. The log answers a different question: what change has the system already attempted, and what was the outcome?

OpenAI's practical guide to agents treats payments, large refunds, and other irreversible actions as cases that require human oversight. That is a sensible boundary, but approval itself does not eliminate duplication during a restart.

Approve an intent snapshot, not the model's response text

After someone clicks «Approve», the agent should not read the conversation again and work out what exactly needs to be paid. During the pause, the customer may have changed their details, a manager may have edited the deal record, and the model may interpret the wording differently in a new context. Approval of the text «pay the customer's invoice» is not approval of a specific transfer.

Before pausing, create a canonical intent object. It should contain every field that affects the external effect: action type, recipient, amount and currency, invoice identifier, policy version, tenant identifier, and the version of the data used by the agent to make its decision. Serialize the object in a stable order and calculate a hash.

{
  "operation_id": "op_01JQ8R7D9M4K",
  "action": "create_payment",
  "tenant_id": "org_482",
  "payload": {
    "invoice_id": "inv_9182",
    "beneficiary_id": "vendor_77",
    "amount_minor": 1250000,
    "currency": "KZT"
  },
  "policy_version": "payments-v3",
  "payload_hash": "sha256:7c4f...",
  "approval": {
    "status": "pending",
    "expires_at": "2026-07-23T16:00:00Z"
  },
  "tool": {
    "name": "payments.create",
    "idempotency_key": "op_01JQ8R7D9M4K"
  },
  "execution_status": "not_started"
}

In the interface, the person sees an understandable version of this object: who will receive what amount, for what, from which legal entity, and what action will be taken. The database stores the canonical object itself and its hash. After approval, only the approval status changes. Payment fields must not be edited silently.

If at least the amount, currency, recipient, CRM field set, or policy version changes after the pause, the agent creates a new intent and requests new approval. Do not try to solve this by comparing a set of «important fields» in model code. In financial and customer data, a supposedly «unimportant» change is often exactly why the approver would have rejected the action.

Tool idempotency and process idempotency are not the same

An idempotent API can recognize a repeated request and avoid creating a second object. That is useful, but it is only a property of one external call. An idempotent process must survive duplicate message delivery, a worker crash, two concurrent resume calls, a delayed webhook, and a manual task restart.

Stripe's documentation illustrates the boundary well. For POST requests, the service accepts an Idempotency-Key, saves the result of the first execution, and returns the saved result when the same key is used again. But this does not help if the application generates a new key on every run, changes the parameters under an old key, or does not know whether the call managed to start. Stripe also warns that results are saved only after request execution begins, while validation errors and concurrent execution conflicts may require separate handling.

The practical rule is simple: your application creates operation_id before the first external call. The idempotency key is derived from it deterministically. One business effect uses one key across all retries. A new business decision, such as a corrected payment after an account-detail error, receives a new operation_id and new approval.

A bad version looks like this:

# Each retry creates a new operation. Do not do this.
key = uuid4()
payments.create(invoice_id=invoice_id, amount=amount, idempotency_key=str(key))

A working version stores the identifier before the call:

operation = db.get(operation_id)
key = operation.tool_idempotency_key
result = payments.create(
    invoice_id=operation.payload["invoice_id"],
    amount=operation.payload["amount_minor"],
    idempotency_key=key,
)

Even this code must not be the first thing called during resume. It must first inspect the saved status and reconcile with the provider if the previous attempt may have reached the external system.

The operation log must store the attempt, result, and evidence

A single approved = true field is not enough. You need a record that describes the life cycle of one specific side effect and does not change the original intent snapshot after the fact.

A minimal model usually includes these fields:

FieldWhy it matters
operation_idConnects the approval, attempts, logs, and external object.
payload_hashProves that execution matches the approved intent.
execution_statusDistinguishes not_started, in_progress, unknown, succeeded, failed, and cancelled.
attempt_noHelps investigate retries and concurrency.
idempotency_keyLets the external API recognize a repeat.
external_idLets you read the provider's state without searching by amount.
request_fingerprintRecords the method, route, and body hash without secrets.
evidenceStores the response code, provider request ID, time, webhook reference, or response snapshot.

The in_progress status is needed before the network call. It tells the next worker that «someone has already received permission to execute this operation». If the process ends after the request is sent, the record may remain in_progress or move to unknown when the lease times out. This is not a reason to retry automatically. It is a reason to start reconciliation.

Do not leave external_id empty if the provider returned it before the full response arrived. Save it in a separate short transaction immediately after receiving it. For many APIs, both the request ID and the idempotency key are useful. Stripe, for example, publishes a request ID in the response header and uses it to find a specific call in its logs.

Do not write full card numbers, access tokens, original customer documents, or the entire prompt to this log. Investigations need correlation identifiers, hashes, and a normalized result. Logs containing personal data turn a protective mechanism into another leak channel.

Resume checks the state first, then decides whether it can write

Separate the model from execution
Route model requests through a single OpenAI-compatible endpoint while keeping the operation log in your application.

Resume logic should not depend on how convincingly the model explained its previous step. It is an ordinary deterministic handler that receives operation_id and selects one of a limited set of transitions.

Here is the order worth implementing for a payment, CRM record, or order status change.

  1. Lock the operation record through compare-and-set or a short lease. Two workers must not execute the same task at the same time.
  2. Read the approval, expiration time, payload_hash, and policy version. If they do not match, move the operation to cancelled or create a new approval request.
  3. If the status is succeeded, return the saved result. Do not call the tool.
  4. If the status is unknown or the in_progress lease has expired, call the provider's lookup using external_id, the idempotency key, or a unique external field.
  5. If reconciliation proves that the operation is absent, return the status to not_started and only then make the call with the previous key. If reconciliation gives no unambiguous answer, leave the status as unknown and send the case to an operator.

The last step frustrates teams that want a «fully automatic» flow. But guessing after an unclear result is not automation. For a payment with an uncertain outcome, the safe action is often to pause execution and show the operator the operation identifier, parameters, attempt time, and everything found at the provider.

For critical actions, separate the commands into prepare, execute, and reconcile. prepare validates the data and forms the intent. execute makes one external call. reconcile creates nothing. It only reads the state and brings your log into line with reality. When all three roles are combined in one pay_invoice() function, a restart will almost inevitably begin creating side effects during diagnosis.

Build an approved payment around reconciliation, not retry

Imagine invoice inv_9182 for 12,500 KZT. The agent checked the rules, prepared intent op_01JQ8R7D9M4K, and received approval. It changed the status to in_progress, sent a POST with key op_01JQ8R7D9M4K, and the connection failed before the response arrived.

An incorrect implementation sees an unfinished step during resume and sends the same POST, sometimes with a new key. If the bank does not support idempotency or the key has changed, a second transfer appears. If the API supports the key, the team may feel safe until it encounters an expired key-retention window, a body mismatch, or a proxy that failed to pass the header.

A correct implementation does this:

operation_id: op_01JQ8R7D9M4K
status: unknown
provider_lookup:
  reference: op_01JQ8R7D9M4K
  result: payment_78431, status=accepted
local_update:
  status: succeeded
  external_id: payment_78431
  evidence: provider lookup at 2026-07-23T14:18:09Z

The agent then tells the user that the payment was accepted and does not make a second POST. If lookup returns «not found», the system may repeat the request with the same key, but only if the provider's contract explicitly defines that behavior. If the provider offers neither lookup by reference nor idempotent creation, do not use its API for autonomous payments. You will need either manual reconciliation or a different integration.

Do not confuse a technical error with cancellation of the business decision. A 400 caused by an invalid taxpayer number or a closed account means that the approved intent cannot be executed. You cannot simply correct the details and repeat the call under the old approval. Corrected details change the action object.

CRM also creates irreversible consequences

Choose a model without risk
Route intent preparation across 500+ models without changing the boundaries of external actions.

Teams often treat the CRM as a safe zone: «It will just duplicate a contact, it is not money». In practice, a duplicate lead triggers two email sequences, distorts reporting, assigns the customer to different managers, and creates manual work for sales. Repeating a deal-stage change may also send a webhook to billing, support, or an access-control system.

For entity creation, use a unique external identifier that the CRM stores with the object. For example, agent_operation_id = op_01JQ8R7D9M4K. During resume, first search for a record using this field. If you find one record, save its external_id and complete the operation. If you find several, do not choose «the most recent». This is a data incident that requires a separate resolution rule.

For updates to an existing record, add an expected version or modification time. The agent may have based its proposal on old data, paused for approval, and then found that a manager changed the deal owner and amount. An unconditional PATCH will overwrite someone else's work.

{
  "operation_id": "op_01JQ8R7D9M4K",
  "action": "crm.update_deal",
  "target": "deal_442",
  "expected_version": 19,
  "patch": {
    "stage": "contract_sent"
  }
}

If the CRM returns a version conflict, the agent must not repeat the PATCH with new data from the record. It should show what changed and request a new decision. Approval to «move the deal to contract» may not mean approval to move it after the amount and responsible manager have changed.

The popular advice to «make all tool calls idempotent» is not enough here. Record creation can be made idempotent through an external identifier. Updating a record also requires version control. Deletion, email delivery, and ownership changes have their own conditions. One general idempotent: true flag hides these differences and creates a false sense of security.

A webhook confirms an external fact but does not replace the log

Do not send PII to the model
Apply PII masking to LLM requests without adding personal data to the execution log.

A provider may accept a payment asynchronously. A CRM may return 202 Accepted and create the object later. In these cases, a webhook helps complete reconciliation, but the webhook itself may also arrive twice, late, or out of order.

The event handler should save the event identifier in a deduplication table and link it to operation_id or external_id. It should then update the operation record through an allowed transition. A «payment processed» event must not revive an operation that an operator has already marked as disputed without a separate investigation rule.

A sound design does not wait for a webhook forever. An operation has a deadline after which the worker runs reconcile: it reads the provider's state, accounts for received events, and records a clear outcome. If the API provides a final status only through a statement or a manual dashboard, that limitation must be reflected honestly in the design. For such integrations, automatic resume after an unclear submission is unacceptable.

Testing should break the process in the worst places

The test «the agent approves a payment and receives 200» proves almost nothing. Test the moments when your database and the external service disagree.

A minimum set of automated test scenarios includes:

  • the process ended after sending the HTTP request but before saving the response;
  • two workers received the same resume signal at the same time;
  • approval arrived after its expiration time;
  • a person changed the amount or recipient in a new version of the request;
  • a webhook was delivered twice and after a manual reconciliation.

For each test, check more than the final status. Check the number of tool calls, the immutability of payload_hash, the uniqueness of the external object, and that an unknown record does not turn into another call without lookup.

In production, add metrics for operations in unknown, time to reconciliation, CRM version conflicts, renewed approvals, and the number of manual investigations. A sudden increase in unknown usually points to network problems, timeouts, or a provider API change. An increase in renewed approvals often means that the agent forms an action too early and waits too long for a person.

If your model layer runs through AI Router, this does not change the architecture described above: your service must save the operation log and perform deterministic resume regardless of which model prepared the proposal.

A safe agent does not «continue from where it left off» after approval. It continues only after reconciling the approved intent, the local log, and the external fact. For actions involving money and customer data, this is the only sequence that can withstand a failure between the request and the response.

Frequently asked questions

Should the agent state be saved before approval?

Yes, if the pause occurs before the tool call and you save an immutable snapshot of the intent. After approval, the agent must execute that snapshot rather than rebuild the request from the current conversation or fresh CRM data.

Is an idempotency key enough to prevent a duplicate payment?

No. An idempotency key reduces the risk of duplication in a particular API, but it does not prove that the operation completed and does not protect another tool or a side effect in your database. The agent still needs its own operation log and an execution check.

When should a payment API call be considered successful?

Mark the task as succeeded only after receiving the provider's object ID or confirming it with a separate request. A timeout, network error, or broken connection should move the task to unknown, not failed.

How can you avoid creating a duplicate lead in the CRM after resume?

Yes. If the CRM supports an external identifier, store operation_id there and create the record with an upsert based on that value. If it does not, search for the object by the saved identifier before creating it, rather than by the customer's name or note text.

Is renewed approval needed if the parameters change?

The approval should store the hash of the canonical intent, its expiration time, and the policy version. If the amount, recipient, CRM fields, or permitted tool changes, the old approval is no longer valid and the agent must request a new one.

What should you do if the agent crashes after sending the request?

Start by checking the operation log. If it has no final result, look for the object in the external system using the saved idempotency key, external reference, or unique field. Call the tool again only when the check shows that the operation definitely never started or the provider explicitly permits a safe retry.

Can an already completed action be checked by amount and recipient?

Checking by customer name, amount, and time is unreliable. Two payments can have the same amount, and CRMs can contain people with the same name and parallel requests. Use the operation identifier created before the side effect, and save it with the provider when the API allows this.

Can the agent continue after the approval expires?

No. Technical continuation after a restart may be safe, but a person's decision applies only to the approved intent and its defined validity period. Treat an expired approval as cancelled, even if the task looks harmless.

Are webhooks needed if the agent has already received the API response?

A webhook confirms a change at the provider, but it does not replace the operation log. The webhook handler should match the event to operation_id, save the external status, and safely handle duplicate delivery of the same event.

Does safe resume depend on the LLM provider?

You do not need to change the agent architecture. You can use AI Router as an OpenAI-compatible model gateway while keeping the operation log, approvals, and tool adapters in your application. Resume safety depends on the boundaries of side effects, not on the model provider.