Blog
A metal press above one coin and two translucent echoes of the coin.

September 2, 20264 min readАдминистратор

A timeout after charging: where idempotency stops protecting you

How to persist operation keys, handle uncertain outcomes, and stop repeated requests from quietly becoming new payment instructions.

Sometimes the most expensive API response is no response at all. Your server timed out, but that says little about whether the provider completed the charge. Generating a fresh random key may obtain a fresh HTTP response and create a second payment. Safe retries start with an explicit definition of what remains the same.

Two people issue one refund

Consider a quieter failure: support refunds EUR 20 while a finance operator refunds another EUR 20 for the same customer request. The API keys for those actions are different and both requests are technically valid. Provider idempotency cannot infer that the employees are fulfilling one instruction. Protection needs to start with the internal refund case and a unique authorisation to execute it.

Create the approved refund action before calling the provider. Link it to the customer case, original payment, amount and reason. The second employee should find the existing action rather than create a new one. Two genuinely separate partial refunds may still be legitimate, for example for two returned items. Matching amounts alone cannot distinguish a repeated instruction from a new instruction.

Define one operation before choosing a key

Separate an order identifier from the action performed on its payment. One order can involve an authorization, permitted partial captures, and separate refunds. An order number is therefore too broad for every request, while a fresh random key for each HTTP call is too narrow. Start with a persistent record of the specific action.

For illustration, order Q-731 requires a €48 capture. Its internal business identity represents “Q-731, capture, part 1,” with the amount, currency, beneficiary, provider account, and intent version attached. Generate a separate opaque provider key and save it before sending. A repeated click, a background worker, and a restarted process should all find that same record. Keep personal information out of the key.

Stripe's idempotency documentation illustrates why API version matters: API v1 replays a stored result after execution begins, including some error responses. Key retention and parameter comparison are also part of its contract. Do not treat any key as a permanent global ban on repeating an operation.

Make an unknown outcome visible

  1. 1.Prepared. The platform saves the action, parameters, and provider key. A uniqueness constraint prevents independent handlers from creating the same intent twice.
  2. 2.Submitted. One worker owns the submission; competing workers find the existing operation instead of inventing another one.
  3. 3.Outcome unknown. A response is missing or inconclusive. This is an explicit working state, not an automatic failure.
  4. 4.Outcome established. A response, verified event, or supported reconciliation process establishes the result. The platform records it and performs the appropriate business action.

These state names are illustrative. A real implementation must distinguish authorization, capture, cancellation, and other relevant stages. The important point is that uncertainty remains visible and has an owner. Do not replace an existing key merely to obtain a fresh response.

Decide what a retry is allowed to repeat

Before retrying, establish that this is the same operation with the same meaningful parameters, within the provider's rules. PayPal documents API-specific support and retention for PayPal-Request-Id; a repeat can return the previous request's current status. Check the specific endpoint rather than assuming that every payment SDK shares one replay model.

Suppose the customer changes the basket and the amount becomes €53. That requires a new payment decision. Establish what happened to the €48 action first, then determine whether cancellation, an additional payment, or a new operation fits the product process and customer authorization. Changing the amount inside a retry conceals a meaningful change of intent.

For your own service, consider storing a fingerprint of relevant parameters and rejecting conflicting requests under the same internal identity. Define relevance deliberately: amount and beneficiary matter, while a tracing identifier usually does not change payment intent. Make conflicts understandable to support without exposing secrets or complete payment details.

Know where the protection ends

Adyen specifies the scope of its idempotency keys, including limitations across regional endpoints. Moving a request to another route cannot automatically be treated as the same protected retry. An identical key string at two independent providers also does not establish shared coordination.

Retain internal operation history beyond the lifetime of an individual request, and define a procedure for old unresolved operations. Once the provider's guaranteed retry window ends, reconcile before resubmitting. If the outcome cannot be established, assign an exception owner and escalation path rather than retrying indefinitely.

Make manual corrections visible

An operator may prefer to unlock a stuck action rather than wait for reconciliation. Make that transition explicit: who authorised it, which evidence established the previous outcome, and which new action replaces the old one. Editing a database status without a record destroys the causal link and leaves the next shift with a plausible but false history.

During design review, list every place that can initiate the action. The customer button, scheduled task, SDK retry, support console, queue recovery and manual script must meet in the same model of business intent. Otherwise each component may correctly suppress its own duplicates while the overall system continues producing duplicates across components.

At the next integration review, ask where the authorised instruction to charge or refund actually lives. If the only answer is a key in an HTTP request, protection begins too late.

Sources

  1. 1.docs.stripe.com/api/idempotent_requests
  2. 2.developer.paypal.com/api/rest/reference/idempotency
  3. 3.docs.adyen.com/development-resources/api-idempotency

Comments · 0

No comments yet — be the first.

Log in · log in to leave a comment.