Skip to main content
Blog
Back to Blog
Payment SystemsMay 12, 20258 min read

Designing Idempotent Payment APIs: The Most Important Pattern You're Probably Missing

Mobile networks drop connections. Users double-tap buttons. Clients retry failed requests. If your payment API isn't idempotent, you're one bad network moment away from a double charge. Here's how to build it right.

#payments#api-design#redis#fintech
H

Haylemichael Tsega

Senior Backend Engineer · Distributed Systems · Fintech

Why Idempotency Matters in Payment Systems

When a user taps "Pay" on a mobile app and the request times out, what happens next? In the best case, the client retries and the payment succeeds. In the worst case without idempotency: the payment was already processed by the server before the timeout, and the retry creates a duplicate charge. The user gets billed twice.

This is not a hypothetical. It happens in production every day. Mobile networks drop connections mid-flight. Load balancers restart. Users tap the back button and try again. Any system that processes money must assume requests will be retried, and it must be designed to handle retries correctly.

Idempotency is the property that makes an operation safe to retry. An idempotent endpoint processes a request exactly once, regardless of how many times it is called with the same input.

The Standard Pattern: Idempotency Keys

The most widely used approach is the idempotency key pattern, used by Stripe, Adyen, and most major payment processors:

  • . The client generates a unique key (typically a UUID) before sending the payment request and includes it as a header.
  • . The server checks if it has seen this key before.
  • . If yes: return the stored result immediately without processing.
  • . If no: process the payment, store the result against the key, return the result.

The client always sends the same key for a given logical payment attempt. If the first request times out, the client retries with the same key. The server recognises it and returns the original result — whether that was a success, a failure, or an in-progress state.

Implementation: Redis Distributed Locks

The naive implementation — just storing the key in a database before processing — has a race condition: two concurrent requests with the same key can both pass the "have I seen this?" check before either writes the result.

The correct implementation uses a distributed lock:

async function processPayment(idempotencyKey, paymentData) {
  const lockKey = `lock:payment:${idempotencyKey}`;
  const resultKey = `result:payment:${idempotencyKey}`;

// Check for existing result first (fast path) const existing = await redis.get(resultKey); if (existing) return JSON.parse(existing);

// Acquire distributed lock (SET NX EX) const lock = await redis.set(lockKey, "1", "NX", "EX", 30); if (!lock) { // Another request is processing — wait and return its result await sleep(500); const result = await redis.get(resultKey); return result ? JSON.parse(result) : { status: "processing" }; }

try { const result = await executePayment(paymentData); // Store result with TTL (24h is typical) await redis.set(resultKey, JSON.stringify(result), "EX", 86400); return result; } finally { await redis.del(lockKey); } }

The SET NX (set if not exists) operation is atomic in Redis — only one request can acquire the lock. All others either wait or return an in-progress response.

What to Store as the Result

Store the normalised outcome: success with transaction ID, failure with error code, or a terminal "declined" state. Do not store intermediate states as idempotent results — a request that is "processing" should not be cached as the final result.

Key decisions: - TTL: 24 hours is standard. Short enough to allow eventual reuse, long enough to cover all retry windows. - Scope: Keys should be scoped to the merchant and payment type, not global. A key that worked for a $10 charge should not be reusable for a $100 charge. - Granularity: Key per logical payment attempt, not per API call. A status-check call should not create or consume idempotency keys.

Lessons from Production

At AddisPay, we added the idempotency layer six months after launch, after a batch of duplicate charge complaints from a merchant whose mobile SDK was aggressively retrying. The fix was straightforward once we identified the pattern, but we lost merchant trust in the process.

Build idempotency first. It is not an optimisation — it is a correctness guarantee. Any payment API without it is not production-ready for real-world network conditions.

H

Haylemichael Tsega

Senior backend engineer with 4+ years building production systems in fintech and distributed infrastructure. Backend architect for the Dashen Super App and AddisPay payment gateway.

More Articles

Building a distributed system or payment platform?

I'm available for senior backend roles and technical consulting.