The network drops, the user tries again, the client retries the request: in a payment system, any request may arrive twice. Idempotency guarantees it is only carried out once.
On a mobile payment service, the scene is ordinary: the user confirms a transfer, the connection weakens, the screen spins, nothing shows up. They tap again. On the server, the first transfer may already have gone through. Without care, the customer is charged twice. This isn't a rare bug: at scale, it's a statistical certainty.
The network only guarantees one thing: doubt
When a request times out, the client cannot tell whether it failed before arriving, during processing, or afterwards on the way back. All three look exactly the same from its side. Retrying is therefore essential, and retrying is only safe if the operation is idempotent: running it twice has the same effect as running it once.
The idempotency key
The idea: the client generates a unique identifier for each intent (a UUID created when the user confirms) and sends it with the request and every retry. The server records the key before processing. If it sees the key again, it does nothing new: it returns the response it already computed. The key belongs to the user's intent, not to the HTTP request: if it's regenerated on every attempt, it's useless.
export async function withIdempotency<T>(key: string, payload: unknown, run: () => Promise<T>): Promise<T> {
const hash = sha256(JSON.stringify(payload));
try {
// Unique index on "key": only one request can claim it, even under heavy concurrency.
await Idempotency.create({ key, hash, status: "processing", expiresAt: inHours(24) });
} catch (e) {
if (!isDuplicateKey(e)) throw e;
const seen = await Idempotency.findOne({ key });
if (seen.hash !== hash) throw new Conflict("Key reused with a different payload");
if (seen.status === "processing") throw new RetryLater(); // first attempt still running
return seen.response as T; // replay: same answer, no second charge
}
try {
const response = await run();
await Idempotency.updateOne({ key }, { status: "done", response });
return response;
} catch (e) {
await Idempotency.deleteOne({ key }); // failed before any effect: allow a clean retry
throw e;
}
}The details that matter
- A unique index on the key, not an "if it exists, then…" check: two simultaneous requests would both get through.
- Compare the payload's fingerprint: the same key with a different amount is a client error, not a retry.
- A "processing" state: a retry arriving while the first attempt runs must wait, not duplicate it.
- A lifetime: 24 hours is usually enough; beyond that the key is meaningless and the table must not grow forever.
- Pass the key on to partners: if the payment provider accepts its own idempotency key, hand it yours.
Beyond payments
The same discipline applies wherever a message may be delivered twice: message queues, webhooks, scheduled jobs, AI agents replaying a tool call. Distributed systems deliver "at least once"; idempotency on the consumer side is what turns "at least once" into "exactly once" from the user's point of view. It's one of the few properties that cost little to design in from the start and a lot to add after an incident.
Read next
AI agents in production: the loop, the tools and the guardrails
An agent is a language model that decides its own next action. That's powerful, and it's exactly why it needs boundaries. What I've learned from wiring agents into real systems.

What are some rules to follow for good app architecture in Flutter?
Principles for structuring a maintainable, scalable Flutter app.