Skip to main content
Intelliger
Agentic Commerce Engineering

Accounts Payable Automation Without Duplicate Payments

Build accounts payable automation that prevents duplicate and wrong-invoice payments with stable identity, idempotency, durable state and reconciliation.

Accounts Payable Automation Without Duplicate Payments
Intelliger
14 minute read

Accounts payable automation cannot rely on an honest payExactlyOnce() API because no such cross-system guarantee exists.

Your accounts-payable agent can validate an invoice, obtain approval and send a payment instruction. Then the network times out. The provider may have accepted the payment, rejected it, or accepted it while delaying the response. Retrying could pay twice. Refusing to retry could leave a supplier unpaid.

Exactly-once external payment is not literally guaranteed across systems that do not share one atomic transaction. What you can build is effectively-once orchestration: stable business identity, deterministic authorization, provider idempotency, durable state, and reconciliation against an authoritative payment record.

The language model has a useful role in document extraction and exception explanation. It should not decide whether a timed-out payment happened.

Accounts payable automation needs one invoice identity

Assume a supplier sends invoice INV-8841 for EUR 18,750. The agent extracts fields, matches the purchase order and goods receipt, then proposes payment to the supplier's approved bank destination.

The first control is an internal invoice identity that does not change when a PDF is re-uploaded or a workflow retries:

const invoiceIdentity = canonicalise({
  version: 1,
  buyerLegalEntityId,
  supplierLegalEntityId,
  supplierInvoiceNumber,
  invoiceCurrency,
  invoiceGrossAmount
})

const invoiceKey = sha256(invoiceIdentity)

This is an example, not a universal accounting rule. Credit notes, reused invoice numbers and country-specific practices may require more fields. The point is to define the identity with finance, then enforce it in code.

Keep the source-document digest as a separate value. Two files can claim to be the same invoice while containing different bank details or line items. That should create a conflict for review, not a silent update.

{
  "invoice_key": "sha256:4f1b...",
  "source_digest": "sha256:be72...",
  "supplier_id": "supplier:de:4815",
  "invoice_number": "INV-8841",
  "purchase_order": "PO-3928",
  "amount": "18750.00",
  "currency": "EUR",
  "destination_id": "bank-destination:supplier-4815:primary",
  "due_date": "2026-08-21"
}

Do not let the agent turn free-form bank data from the invoice into a payment destination. Resolve destination_id from a controlled supplier registry. A bank-detail change should follow a separate verification workflow with its own authority and approvals.

Separate preparation, authorization and execution

An AP agent often performs three different jobs that deserve different trust boundaries.

Preparation gathers the invoice, purchase order, receipt and supplier record. It can extract and compare facts, but uncertainty must remain visible.

Authorization decides whether this payment is permitted. Use deterministic rules for supplier status, amount, currency, destination, duplicate state, approval threshold and separation of duties.

Execution exchanges an authorized instruction for a short-lived provider credential and calls the payment API. The agent should not retain a reusable treasury secret.

Document worker
  -> normalized invoice proposal
Policy and approval service
  -> signed payment authorization
Credential broker
  -> one-operation capability
Payment adapter
  -> provider instruction
Reconciler
  -> authoritative outcome and receipt

A model can explain why the three-way match failed. It cannot waive the mismatch because the supplier email sounds urgent.

Make authority specific enough to be useful

Broad scopes such as payments:write move the problem into application code. A payment mandate should bind the business object and limits:

{
  "id": "oati:mandate:buyer:invoice-8841",
  "agent_id": "oati:agent:buyer:ap-worker-3",
  "purpose": "settle_approved_supplier_invoice",
  "actions": ["supplier_payment.create"],
  "resources": ["invoice:buyer:INV-8841"],
  "counterparties": ["supplier:de:4815"],
  "destinations": ["bank-destination:supplier-4815:primary"],
  "limits": {
    "max_amount": "18750.00",
    "currency": "EUR",
    "max_calls": 1
  },
  "one_time": true,
  "expires_at": "2026-08-10T17:00:00Z"
}

The transaction envelope should bind the exact invoice digest, mandate, destination, payment-provider audience and idempotency key. Any substitution must change a signed digest.

For higher amounts, the mandate can require approvals from named roles. Validate the approval subject and object. An approval for invoice INV-8841 at EUR 18,750 must not authorize a revised invoice at EUR 19,250.

Separation of duties also needs machine-readable rules. The agent or operator that prepared a payment should not satisfy an independent approval requirement under another session.

Pick an idempotency key that survives retries

Generate the payment idempotency key from the approved business intent, or allocate it when that intent becomes immutable. Do not create a new random key on each network attempt.

const paymentIntent = {
  tenantId,
  invoiceKey,
  supplierId,
  amount: '18750.00',
  currency: 'EUR',
  destinationId,
  paymentRail: 'sepa-credit-transfer'
}

const requestFingerprint = sha256(canonicalJson(paymentIntent))
const idempotencyKey = `ap:${tenantId}:${invoiceKey}`

Store both. If the same idempotency key appears with another fingerprint, stop. This may be a software bug, a changed invoice or an attack.

The provider adapter should pass that stable key when the provider supports idempotency. Provider support is necessary but not sufficient. Retention windows vary, semantics vary, and some systems deduplicate only exact API operations. Your orchestration store remains the business-level record.

Use a durable payment state machine

Boolean fields such as paid = true cannot represent uncertainty. Use states with guarded transitions:

PROPOSED
  -> VALIDATED
  -> APPROVAL_REQUIRED -> APPROVED
  -> AUTHORIZED
  -> RESERVED
  -> SUBMITTED
  -> ACCEPTED
  -> SETTLED

SUBMITTED -> REJECTED
SUBMITTED -> UNCERTAIN -> ACCEPTED | REJECTED | MANUAL_REVIEW
ACCEPTED -> FAILED | RETURNED | REVERSED

ACCEPTED and SETTLED are not synonyms. A provider may accept an instruction that later fails, is returned or is reversed. Your accounting entry and supplier communication must use the right state.

Write the SUBMITTED transition and request fingerprint before making the external call. If the process crashes after the call, recovery can find the incomplete instruction.

await db.transaction(async tx => {
  const payment = await tx.lockPayment(idempotencyKey)

  if (payment.requestFingerprint !== requestFingerprint) {
    throw new Conflict('IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_REQUEST')
  }

  if (payment.state !== 'AUTHORIZED') return existingOutcome(payment)

  await tx.reserveMandateUse(payment.mandateId)
  await tx.transition(payment.id, 'SUBMITTED')
})

const response = await provider.createPayment({
  idempotencyKey,
  amount,
  currency,
  destinationToken
})

There is still a gap between the database commit and the provider call. An outbox worker can reliably drive submission from a committed record, but it cannot make your database transaction atomic with an external provider. Idempotency and reconciliation close that gap operationally.

Handle the timeout without guessing

On timeout, mark the operation UNCERTAIN. Keep the one-time mandate and amount reservation consumed or reserved. Then query the provider by idempotency key, client reference or provider instruction ID.

async function reconcile(payment: Payment) {
  const result = await provider.lookup({
    idempotencyKey: payment.idempotencyKey,
    providerReference: payment.providerReference
  })

  if (result.status === 'accepted') return markAccepted(payment, result)
  if (result.status === 'rejected') return markRejected(payment, result)
  if (result.status === 'not_found' && safeResubmitWindow(payment)) {
    return enqueueSameInstruction(payment)
  }
  return keepUncertainAndEscalate(payment)
}

Be careful with not_found. It can mean the provider never received the instruction, that indexing is delayed, or that you queried the wrong scope. Resubmission policy should be provider-specific and tested.

Do not release authority merely because the HTTP client threw an exception. Once dispatch may have occurred, the system no longer knows that no value moved.

Defend against the wrong invoice, not only duplicates

Duplicate prevention gets attention because it is easy to explain. Substitution is just as damaging.

Test these cases:

  • the invoice PDF changes after approval
  • the supplier matches but the destination changes
  • amount and currency are swapped in one adapter
  • an approval is copied from another invoice
  • the mandate names the right invoice but the envelope names another
  • a user retries with a new idempotency key
  • the same supplier invoice number arrives for another buyer entity
  • a credit note is mistaken for a payable invoice
  • the provider reports success for a different request fingerprint

Every boundary should compare stable identifiers and canonical digests. Human-readable invoice numbers are useful references, but they are not globally unique transaction keys.

Receipts should record what is known

Create evidence at authorization and update the transaction history as external facts arrive. A signed action receipt can connect:

  • accountable organization and agent
  • mandate and approval references
  • invoice and request digests
  • deterministic policy version and decision
  • idempotency key
  • provider and provider reference
  • result state and timestamps
  • transformation or redaction digests

If the provider response is missing, the receipt should say uncertain. A later signed reconciliation record can reference the earlier receipt and record accepted, settled, returned or another final state.

A unilateral receipt proves what the deploying enterprise signed about its record and control path. It does not prove that no bypass existed, that the supplier agrees, that the bank settled the funds, or that the underlying invoice was commercially valid.

Run the failures before handling real value

An acceptance suite should include at least:

ScenarioExpected behavior
Exact client retry before submissionreturn current operation state
Retry after provider acceptancereturn existing provider reference
Same key with changed amountconflict and alert
New key for same invoiceduplicate-invoice control denies
Concurrent submissionsone reservation and one logical provider operation; repeated dispatches reuse the same provider idempotency key
Provider timeout before known acceptanceuncertain, then reconcile
Crash after provider acceptsrecovery resolves by stable key
Provider lookup unavailableremain uncertain, do not retry blindly
Mandate revoked before dispatchdeny
Approval expired before dispatchdeny
Destination registry changes after approvalrequire re-authorization
Accepted payment later returnsappend state, do not rewrite history

Track unresolved uncertainty age, duplicate attempts, reconciliation latency, manual-review volume and destination-change exceptions. A clean demo with a cooperative mock provider tells you little about these paths.

Related engineering guides

What OATI supports today

OATI's developer preview contains Passport, Mandate, transaction envelope, deterministic Commerce evaluation, one-time usage, cumulative budget, evaluator-level idempotency checks over supplied usage state, signed receipts, middleware and shared conformance vectors. Durable payment idempotency, execution state and reconciliation remain production integration work. A local sandbox demonstrates paid-API and controlled Commerce transactions.

Agent Spend Control is the commercial target wedge, beginning with one supplier-invoice payment through an existing payment provider. It is not presented as a completed banking, treasury, custody or payment-rail product. The production evidence and dispute workflow remains incomplete, and a real enterprise payment operation is still a production acceptance gap.

Implementation checklist

  • Agree an invoice identity model with finance.
  • Preserve source-document digests and reject conflicting versions.
  • Resolve payment destinations from a verified supplier registry.
  • Separate preparation, authorization, credential brokerage and execution.
  • Bind invoice, amount, currency, supplier, destination and provider audience.
  • Require object-specific approvals above defined thresholds.
  • Use one stable idempotency key across every retry.
  • Store a canonical request fingerprint with the key.
  • Persist SUBMITTED before external dispatch.
  • Distinguish accepted, settled, failed, returned, reversed and uncertain.
  • Keep authority reserved after an ambiguous timeout.
  • Reconcile through an authoritative provider lookup.
  • Append signed outcome records instead of rewriting evidence.
  • Race concurrent requests and inject crashes at every state transition.

An effectively-once design sends retries back to the same payment intent, rejects changed intent under the same key and reconciles uncertain outcomes before another payment can proceed.