Skip to content

Idempotency Keys: The Pattern That Prevents Duplicate Orders, Payments, and Headaches

Posted on:June 15, 2026

Welcome, Developer đź‘‹

I want to be honest about something: I learned this pattern the wrong way.

Not from a book. Not from a senior dev pointing at my code. I learned it from a support ticket. A user had placed an order, the network dropped mid-request, they tapped retry, and ended up charged twice. The request had landed the first time. We just had no way of knowing that.

That was the day I started taking idempotency seriously.

If you build mobile apps and call APIs over a network you don’t control, this post is for you. The pattern is simple, but the consequences of skipping it, not much.

What “idempotent” means

An operation is idempotent if you can run it multiple times and get the same result as running it once.

HTTP GET is idempotent. Fetch a list of orders ten times, you get the same list ten times. No side effects, no problem.

HTTP POST is not idempotent by default. POST “create order” ten times, you get ten orders.

An idempotency key is a token your client generates and sends with a non-idempotent request. The server uses that token to recognise a duplicate and return the original response instead of processing the request again.

Same key, same outcome. Every time.

Why mobile makes this worse

Every distributed system has to deal with network unreliability. But mobile apps live on the worst possible networks.

Users switch between WiFi and mobile data constantly. They walk into lifts. They lose signal mid-request. The OS kills background processes. A request goes out and the response comes back, but the app crashes before it reads it.

From the user’s perspective: nothing happened. From the server’s perspective: everything happened.

The retry button on your error screen is a landmine if your API isn’t idempotent. The user taps it, your server gets a second request, and it has no way of knowing this isn’t a new one.

I’ve been building software for over fifteen years and this is one of the gaps I see most often, even in well-maintained codebases. It’s invisible until it isn’t, you know?

How it works

The flow has three parts.

The client generates a unique key before sending the request.

// CLIENT - Mobile App
import * as Crypto from "expo-crypto";
 
const idempotencyKey = Crypto.randomUUID();
 
await fetch("https://api.myapp.com/orders", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Idempotency-Key": idempotencyKey,
  },
  body: JSON.stringify({ productId: "123", quantity: 1 }),
});

The server checks whether it has seen this key before.

If the key is new: process the request, store the result alongside the key, return the result.

If the key exists: return the stored result. Do not process again.

// SERVER - Express example (Node.js)
app.post("/orders", async (req, res) => {
  const key = req.headers["idempotency-key"];
 
  if (!key) {
    return res.status(400).json({ error: "Idempotency-Key header required" });
  }
 
  const hashBody = (body: object) => JSON.stringify(body);
 
  // Check the cache
  const cached = await cache.get(`idempotency:${key}`);
  if (cached) {
    return res.status(cached.status).json(cached.body);
  }
 
  // Process the request
  const order = await createOrder(req.body);
 
  // Store the result alongside a hash of the original body
  const result = { status: 201, body: order, bodyHash: hashBody(req.body) };
  await cache.set(`idempotency:${key}`, result, { ttl: 86400 }); // 24h
 
  return res.status(201).json(order);
});

The client retries safely, reusing the same key.

// CLIENT - Mobile App
import * as Crypto from "expo-crypto";
 
async function placeOrderWithRetry(productId: string, quantity: number) {
  // Generate the key once, before any attempt
  const idempotencyKey = Crypto.randomUUID();
 
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const response = await fetch("https://api.myapp.com/orders", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Idempotency-Key": idempotencyKey,
        },
        body: JSON.stringify({ productId, quantity }),
      });
 
      if (response.ok) {
        return await response.json();
      }
    } catch (error) {
      if (attempt === 2) throw error;
      await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
    }
  }
}

The key is generated once, before the loop starts. Every retry sends the same key. The server processes the request on the first successful attempt and returns the cached result on every subsequent one.

Where to store the key on the server

The right choice depends on your stack, but the principles are the same everywhere.

Redis is what most teams reach for. It’s fast, supports TTL natively, and works across multiple server instances without any coordination overhead. Set the TTL to 24 hours or longer depending on how long your retry window needs to be.

The database works too, especially if you need durability across restarts. Add an idempotency_key column to your requests or transactions table with a unique constraint. The constraint does the deduplication at the database level, which is actually a cleaner guarantee than application-level logic.

Don’t store in memory only. Multiple server instances means each has its own memory. One instance stores the key, the next request hits a different instance, no cache hit, duplicate processed. This one is easy to miss in development and painful in production.

What to do when the request body doesn’t match

This is a subtle one. What happens if a client sends the same idempotency key with a different request body?

Reject it. Return a 422 Unprocessable Entity. The key is tied to the original request and the body is part of that contract. If the body is different, that’s a client bug, not a retry.

// SERVER
const cached = await cache.get(`idempotency:${key}`);
if (cached) {
  const incomingHash = hashBody(req.body);
  if (cached.bodyHash !== incomingHash) {
    return res.status(422).json({
      error: "Idempotency key already used with different request body",
    });
  }
  return res.status(cached.status).json(cached.body);
}

You’ll notice the server block above stores bodyHash in the cached result alongside status and body. That’s what makes this comparison possible. Store it when you first process the request, compare on every subsequent one.

I prefer 422 over 409 here. 409 Conflict implies a state problem. 422 Unprocessable Entity is more precise - we understood the request, we found the key, and the body doesn’t match what was originally sent. That’s a client mistake, not a server conflict.

A note on concurrency and exactly-once delivery

Two things I want to flag before you ship this in production, because both of them have caught me out.

Concurrency. What happens if two requests with the same key arrive simultaneously, before either has been processed? Without a lock, both pass the cache check at the same time, both process, and you get duplicates anyway. The key check becomes a race condition.

The fix is an atomic operation. With Redis, SET key value NX (set if not exists) handles this in a single command. With a database, a unique constraint on idempotency_key makes the insert fail for the second request. Don’t rely on application-level read-then-write logic. The gap between reading and writing is exactly where duplicates sneak in.

At-least-once vs exactly-once. Idempotency keys get you close to exactly-once processing, but only if the cache write and the operation are atomic. If your server processes the order and crashes before writing the key to the cache, the next retry has no record of the first attempt and processes again.

For most APIs, that’s an edge case you can live with. For payment processing, it isn’t. The pattern that solves this properly is the transactional outbox: write the result and the idempotency key in the same database transaction, then publish side effects from the database. That way the key is never stored without the operation having completed, and the operation never completes without the key being stored.

How long should keys live?

Keys don’t need to live forever. Stripe keeps them for 24 hours, which covers any reasonable retry window.

The edge case worth thinking about: a user places an order Monday, it succeeds, and then tries to place the same order Wednesday. If you generated the key from the request content rather than a UUID, Wednesday’s request might reuse Monday’s key and come back with Monday’s order instead of creating a new one.

This is why you generate keys from a UUID, not from the request content. The key identifies a specific attempt, not a specific operation. If the user wants to place a second order, your client generates a fresh UUID. Clean separation.

Stripe, PayPal, Twilio — they all do this

I find it useful to know that the APIs I already depend on use this pattern, because it means I’m not inventing anything here.

Stripe requires an Idempotency-Key header on all POST requests. PayPal uses PayPal-Request-Id. Twilio uses X-Twilio-Idempotency-Token. These aren’t optional headers or nice-to-haves — they’re how these companies protect their users from duplicate charges and duplicate messages at scale.

If you’re building a payment flow without idempotency keys, you’re relying on your users never retrying a failed request. That’s not a strategy. That’s luck.

Putting it together in a React Native flow

Here’s the version I actually reach for in production — persisting the key across app restarts using AsyncStorage.

If you haven’t installed it yet:

npx expo install expo-crypto @react-native-async-storage/async-storage
// CLIENT
import * as Crypto from "expo-crypto";
import AsyncStorage from "@react-native-async-storage/async-storage";
 
async function placeOrder(productId: string, quantity: number) {
  // Check if we already have a pending key for this action.
  // This handles the case where the app was killed mid-request.
  const storageKey = `order_key_${productId}`;
  let idempotencyKey = await AsyncStorage.getItem(storageKey);
 
  if (!idempotencyKey) {
    idempotencyKey = Crypto.randomUUID();
    await AsyncStorage.setItem(storageKey, idempotencyKey);
  }
 
  try {
    const response = await fetch("https://api.myapp.com/orders", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify({ productId, quantity }),
    });
 
    const order = await response.json();
 
    // Success: clean up the stored key
    await AsyncStorage.removeItem(storageKey);
 
    return order;
  } catch (error) {
    // Key stays in storage so the next attempt reuses it
    throw error;
  }
}

If the app is killed before the response arrives and the user relaunches, the same key is ready to go. The server sees it as the same request and returns the original result if it already processed it.

One thing to be careful about: storageKey here is scoped to productId only. That’s fine for a simple “buy this item” flow, but if your cart lets users buy multiple quantities of the same product in separate sessions, you’ll want a more specific key (including a session ID or cart item ID) so separate purchase intents don’t share the same idempotency key.

The one thing that changes how you think about retries

Before I understood this pattern, every retry was a judgment call. Did the request land? Should I try again? The answer was usually “I don’t know” and the options were bad: show an error and risk a confused user, or retry and risk a duplicate.

After idempotency keys, retries are safe by default. Generate the key, retry until you get a response, let the server handle deduplication. The question “did that request land?” stops mattering, because the answer doesn’t change what you do.

That’s the shift. It’s not just an API header. It’s a different way of reasoning about requests on unreliable networks — which, if you build mobile apps, is every network your users are on.

Wrapping up

Three things to take away from this.

Generate the key on the client, before the first attempt. Not inside the retry loop. The key identifies the intent, not the attempt.

Store it somewhere that survives an app restart. AsyncStorage works. If the app is killed before the response arrives, you need the same key ready for the next launch.

Make your server check before it acts. The deduplication logic lives on the server, not the client. The client’s job is to send the same key consistently. The server’s job is to make that mean something.

The pattern is not complicated. What is complicated is the moment you realise you needed it three weeks ago, when a user ended up with two subscriptions and a support ticket.

Stay focused, Developer!