Insights & Blog

Idempotency in Distributed Systems: Preventing Duplicate API and Payment Requests

Design replay-safe APIs using scoped idempotency keys, atomic persistence and reconciliation. Includes a tested SQLite example, failure matrix and payment-provider contract checks.

By Hetal KIT Solution

Replay-safe API workflow: stable scoped operation identity, atomic local persistence and reconciliation of uncertain external effects.

Idempotency in Distributed Systems: Preventing Duplicate API and Payment Requests

An idempotency key identifies one logical operation across retries. The receiving service must persist that identity and enforce the corresponding result or state transition. Adding a header alone does not prevent duplicate orders, repeated payments or webhook side effects. Correctness depends on atomic persistence, concurrent-duplicate handling and the downstream provider’s actual contract.

Key takeaways

  • Reuse the same operation key when retrying the same intent; use a new key for genuinely new work.
  • Scope the key to the authenticated tenant and operation.
  • Reject conflicting input rather than silently replaying the wrong command.
  • A local database transaction cannot atomically include an unrelated external payment API.

What does an idempotency key do?

A transport retry repeats an attempt, not necessarily a new business intention. If a client creates an order and loses the response, it needs a way to ask about that same creation rather than unintentionally create another order. A stable operation key lets the service distinguish those cases.

Idempotency concerns the intended effect. It does not require every response byte or status to be identical for every protocol and product. Define whether a repeat returns a stored response, current operation status or another documented result.

Do not use a shared trace identifier as the only operation key. One customer journey can contain several legitimate commands. Likewise, a newly generated retry identifier does not link repeated attempts to their original intent.

The Azure idempotent-consumer pattern explains duplicate processing and stable operation identity. For work confined to one transactional store, commit the business change and completion marker together. For external effects, preserve uncertainty and reconcile it instead of claiming that a local marker makes the entire workflow atomic.

Design the key and persistent record

Authenticated request + stable operation key + validated input
        ↓
Scoped operation record: tenant + operation type + key
        ├─ completed, matching input → return documented result
        ├─ same key, different input → conflict
        ├─ in progress / uncertain → wait, status or reconciliation
        └─ new → atomically claim → perform bounded work → record outcome

For a fictional order API, the scope might be approved tenant, order-creation operation and random command key. Tenant identity must come from authorization, not an untrusted request field. Do not put personal data, credentials or full business payloads into a key.

Store a fingerprint of normalized, validated business input. Decide which fields define the command and which are incidental request metadata. Equivalent input serialized with different property order should not accidentally become a different operation if the contract treats it as equivalent.

Normalize carefully: changing rounding, currency interpretation or defaults can alter business meaning. Record the request schema version and the normalization rule. A fingerprint is a comparison aid, not permission to execute the command.

Enforce uniqueness at the persistence boundary. A check followed by an unrelated insert is vulnerable when two workers check at the same time. A unique key or conditional write must arbitrate ownership, and the losing worker needs an explicit response path.

Separate completion from an unfinished claim. A row marked in progress can represent an active worker, an interrupted attempt or an external action whose response was lost. A timeout on that row is not proof that the external action never happened.

A tested local SQLite worked example

The following Python example uses only the standard library and one in-memory SQLite connection. It demonstrates same-key replay, conflicting input rejection, tenant scope and rollback of a local order plus marker. The tenant argument stands in for an already-authorized context; this demonstration does not authenticate callers.

"""Local SQLite teaching example. No network or payment calls."""
import hashlib,json,sqlite3
db=sqlite3.connect(':memory:')
db.executescript('''CREATE TABLE commands(tenant TEXT, op_key TEXT, fingerprint TEXT, result TEXT,
 PRIMARY KEY(tenant,op_key));
 CREATE TABLE orders(order_id INTEGER PRIMARY KEY, tenant TEXT, sku TEXT, quantity INTEGER);''')
def create_order(tenant,key,sku,quantity,fail=False):
    if not tenant or not key or quantity<1: raise ValueError('Invalid command')
    fingerprint=hashlib.sha256(json.dumps([sku,quantity],separators=(',',':')).encode()).hexdigest()
    with db:
        row=db.execute('SELECT fingerprint,result FROM commands WHERE tenant=? AND op_key=?',(tenant,key)).fetchone()
        if row:
            if row[0]!=fingerprint: raise ValueError('Key reused for different input')
            return json.loads(row[1])
        order=db.execute('INSERT INTO orders(tenant,sku,quantity) VALUES(?,?,?)',(tenant,sku,quantity)).lastrowid
        if fail: raise RuntimeError('Simulated failure before commit')
        result={'order_id':order}
        db.execute('INSERT INTO commands VALUES(?,?,?,?)',(tenant,key,fingerprint,json.dumps(result)))
        return result
first=create_order('A','one','SKU1',2)
assert create_order('A','one','SKU1',2)==first
assert db.execute('SELECT count(*) FROM orders').fetchone()[0]==1
try: create_order('A','one','SKU1',3)
except ValueError: pass
else: raise AssertionError('Conflicting input accepted')
assert create_order('B','one','SKU1',2)!=first
before=db.execute('SELECT count(*) FROM orders').fetchone()[0]
try: create_order('A','failure','SKU2',1,fail=True)
except RuntimeError: pass
else: raise AssertionError('Failure not injected')
assert db.execute('SELECT count(*) FROM orders').fetchone()[0]==before
assert db.execute("SELECT count(*) FROM commands WHERE op_key='failure'").fetchone()[0]==0
assert create_order('A','failure','SKU2',1)['order_id']>0
print('7 checks passed; one SQLite connection, no concurrent workers or external effects.')

Local result: seven checks passed, covering replay, one stored order, conflicting payload rejection, tenant separation, rollback of the order, rollback of its marker and a successful retry after rollback.

Run the file with Python to reproduce the checks. There are no payment or network calls. It is not a concurrent-worker test, a production API or a distributed transaction implementation. Production work needs explicit database transaction/isolation behavior, a race-handling path, authentication, persistence and operational controls.

The local rollback is useful precisely because the order and marker share one transaction. Replacing the order insert with an external request would invalidate that atomicity claim. Keep the boundary visible in design reviews and tests.

Payment requests and other external effects

A payment request can succeed at the provider while the caller receives a timeout. Retrying with a fresh key may create a second logical request. Retrying indefinitely with the old key can also be wrong if the provider’s retention window has expired. Persist the business operation and provider reference so unresolved work can be investigated.

Stripe’s idempotency documentation distinguishes API v1 and API v2 behavior. API v1 saves the first executed response, including server errors, and permits pruning keys after at least 24 hours. Do not apply that behavior to every provider or version: verify the precise endpoint, failure and retention contract.

Design an operation state machine around what your application actually knows. For example: accepted, submitted, outcome unknown, confirmed or rejected. Those are illustrative internal states, not a provider’s status enumeration. A user-facing screen should not present an uncertain outcome as a confirmed failure that invites a new payment.

A transactional outbox can durably record the intention to send work with the local business update. It does not, by itself, make the external receiver deduplicate. Carry a stable downstream operation identity where supported and reconcile provider outcomes before issuing replacement work.

For webhook processing, distinguish receiving an event from completing its side effects. Record the provider’s stable event identity and the intended consumer action. Different subscribers may each need to process the event; one global marker should not suppress all their legitimate work.

Deduplication also does not establish ordering. A late unique event can still move local state backward if version checks are absent. Evaluate the business transition or source version before applying an update.

A failure and replay decision table

Observed stateSafe design responseDo not assume
Completed key, same inputReturn the documented replay result after authorizationAnyone knowing a key may read its result
Same key, conflicting inputReject with a clear conflict contractThe new input is an ordinary retry
Two new concurrent attemptsLet atomic uniqueness arbitrate; handle the loserA prior read reserves ownership
Local transaction rolled backRetry under the original logical operationEvery external effect also rolled back
Provider outcome unknownQuery or reconcile through supported provider mechanismsTimeout means no action happened
Provider key may have expiredReconcile before issuing replacement workA late replay still has duplicate protection

Choose status codes and error bodies as a documented API contract. Include a safe operation reference and retry or support guidance. Do not expose another tenant’s result through a globally guessable key lookup.

Retention, replay and operational ownership

Retain records for the real replay horizon, including delayed jobs, client retries and operator recovery. Consider backups and disaster recovery: restoring business rows without their idempotency records can reopen a duplicate window.

Storage cleanup is a business decision as well as a technical one. Identify which records can be summarized or archived, what audit evidence remains and which late operations must be routed for review. Do not pick a short expiry simply to reduce table size.

Test failure boundaries deliberately. Interrupt execution before the business write, before commit, after commit and after an external provider action but before recording its response. Add a concurrent-duplicate test using separate workers and real persistence before production rollout.

Observe duplicate hits, input conflicts, oldest in-progress operations and reconciliation backlog. A rising duplicate count can reflect normal client retries or a degraded dependency; use context before treating it as an incident.

Give support operators a controlled recovery workflow. Replaying a message, creating a new business operation and changing a recorded outcome are different actions. Audit who did what, retain the original operation identity and require an explanation for exceptional recovery.

Use the rate-limiting and retry guide to bound attempts, and the webhook and streaming comparison to choose the delivery contract.

Common questions

How do idempotency keys prevent duplicate transactions?

They let the receiver identify the same logical request across retries. The receiver must enforce that identity with durable state and a documented replay contract.

Should a retry create a new idempotency key?

Reuse the original key for the same intent within the supported contract. A genuinely new operation needs its own key.

Can we reuse a key with a different amount or payload?

Reject conflicting input or follow the provider’s documented behavior. Silently treating different work as the same command can produce the wrong result.

Does a database transaction cover an external payment API?

Not automatically. Preserve operation state, use supported provider idempotency and reconcile uncertain outcomes.

Is duplicate detection the same as ordering?

No. A unique late event can still be stale. Enforce business-state or version rules separately.

Make retries safe for the business operation

Bring your API contracts, retry paths and external side effects. Request an API integration architecture review to define operation identity, persistence and recovery.

From insight to implementation

Turn these ideas into a practical plan

Discuss how the ideas in this article apply to your business, systems and data. We’ll help you identify a focused first step.