mosaddik.dev
All posts

Idempotency explained: how to make API requests safe to retry

Mosaddik Billah 4 min read

Software Engineering

A person paying online with a credit card and a smartphone
Photo by Michael lima on Unsplash
On this page
  1. Why requests get sent twice
  2. Which HTTP methods are already safe
  3. Idempotency keys
  4. Making it production-ready
  5. The database can do it for you
  6. Prefer “set” over “add”

Pressing a lift button five times doesn’t send five lifts. The first press calls one; the rest change nothing. That property has a name, idempotency: doing something twice has the same effect as doing it once. APIs need it more than lifts do, because on the internet, requests get repeated all the time.

Why requests get sent twice#

A customer clicks “Pay”. The request reaches your server, the card is charged, and then the response is lost: the phone switched from Wi-Fi to mobile data, or a proxy timed out. From the customer’s side, it failed. So the app retries, or the customer clicks again.

Without protection, that’s two charges. The same happens with:

  • Webhooks. Senders such as Stripe and Shopify retry when your server doesn’t answer quickly with a success code, so you can receive the same event more than once.
  • Message queues, which usually deliver “at least once”. See message queues and background jobs.
  • Double clicks, and the browser’s back button.

You can’t stop duplicates from arriving. You can make them harmless.

Which HTTP methods are already safe#

Method Idempotent? Why
GET Yes Only reads
PUT Yes “Set this to X”; setting it again leaves X
DELETE Yes Deleting twice leaves it deleted (the second may return 404, but nothing more changes)
PATCH Depends {"status":"paid"} is; “add 1 to quantity” isn’t
POST No “Create a new order”; twice makes two

So the work is mostly about POST, and any operation written as “add” or “subtract” rather than “set”.

Idempotency keys#

The standard fix, used by Stripe and many payment APIs: the client generates a unique ID for each action and sends it in a header. The server remembers which keys it has processed and, for a repeat, returns the saved response instead of doing the work again.

JavaScript
import express from "express";

const app = express();
app.use(express.json());

const results = new Map(); // key -> saved response. Use a database table in production.

app.post("/payments", async (req, res) => {
  const key = req.get("Idempotency-Key");
  if (!key) return res.status(400).json({ error: "Idempotency-Key header is required" });

  const saved = results.get(key);
  if (saved) return res.status(saved.status).json(saved.body);

  const payment = { id: crypto.randomUUID(), amount: req.body.amount, status: "paid" };
  // ...charge the card, create the order...

  const response = { status: 201, body: payment };
  results.set(key, response);
  res.status(response.status).json(response.body);
});

Sending the same request twice with Idempotency-Key: order-1042-attempt returns 201 both times, with the same payment ID, and the card is charged once.

On the client, create the key once per user action, for example when the checkout page loads or the button is first clicked, and reuse it for every retry of that action. A new key per retry defeats the point.

JavaScript
const key = crypto.randomUUID(); // once, for this checkout

await fetch("/payments", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Idempotency-Key": key },
  body: JSON.stringify({ amount: 2500 }),
});

Making it production-ready#

The Map above shows the idea, but it’s lost on restart and isn’t shared between servers. For real use:

  • Store keys in your database, in a table with the key as primary key or UNIQUE, plus the saved response and a creation time. Delete old ones after a day or so.
  • Handle two requests arriving at the same moment. Insert the key before doing the work, as “in progress”. The unique constraint means only one insert succeeds; the other request gets a conflict and can return 409 or wait and return the stored result.
  • Tie the key to the request. If the same key arrives with a different body, reject it with an error, as it’s probably a bug in the client.

The database can do it for you#

Often you don’t need a separate key table, because the data already has a natural unique ID:

SQL
CREATE TABLE processed_webhooks (
  event_id    TEXT PRIMARY KEY,
  received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

When a webhook arrives, insert its event ID first. If the insert fails on the primary key, you’ve seen this event already: reply 200 and stop. If it succeeds, process it, ideally in the same transaction as the insert.

The same idea works elsewhere: a UNIQUE constraint on order_number, or on (user_id, date) for a one-per-day action, turns a would-be duplicate into a harmless error.

Prefer “set” over “add”#

When you design an operation, ask whether it can be written as setting a value instead of changing one:

  • PATCH /orders/42 {"status": "shipped"} is idempotent. “Advance the status to the next step” isn’t.
  • PUT /carts/9/items/mug {"quantity": 3} is idempotent. “Add one mug” isn’t.

When you can’t, such as charging a card or sending an email, use an idempotency key.

For where this fits in the bigger picture, see REST API design best practices and, for Wix sites, receiving webhooks with Velo HTTP functions. If you need payments or webhooks wired up so nothing is ever doubled, that’s part of my custom API integration work.

Comments