All stories

How I Made My API Safe to Retry With Idempotency Keys

A dropped connection created duplicate orders in my app. Here is how idempotency keys, Prisma and a Postgres unique constraint made retries safe.

Admin

8 min read·

6views

A few months ago I was looking at an orders table and noticed something that made my stomach drop: two identical orders from the same user, created 1.8 seconds apart. Same cart, same total, same shipping address. The user had not clicked twice. What actually happened was much more boring: their phone switched from Wi-Fi to mobile data in the middle of the request, the response never arrived, and my frontend's retry logic did exactly what I had told it to do. It sent the request again.

The server had processed both. From the server's point of view, there was no way to tell a retry apart from a genuinely new order. That is the problem idempotency keys solve, and in this post I will walk through how I added them to a Next.js API route backed by Prisma and Postgres.

Why retries are dangerous for POST requests

HTTP already gives some methods a safety guarantee. GET, PUT and DELETE are defined as idempotent: doing them twice leaves the server in the same state as doing them once. POST is not. Every POST /orders is allowed to create a new order.

The tricky part is that a client cannot always know whether its request succeeded. If the connection drops after the server commits the transaction but before the response arrives, the client sees a network error. It has two bad options:

  • Don't retry and risk telling the user their order failed when it actually went through.
  • Retry and risk charging them twice.

An idempotency key gives you a third option: retry safely, and let the server recognise that it has already seen this exact operation.

How idempotency keys work

The idea is simple. The client generates a unique value (usually a UUID) for each logical operation and sends it in an Idempotency-Key header. The server stores the key together with the result of the first request. If the same key arrives again, the server skips the work and replays the stored response.

This pattern was popularised by payment APIs like Stripe, which accept an Idempotency-Key header on POST requests and prune stored keys after 24 hours. There is also an IETF HTTPAPI working group Internet-Draft, "The Idempotency-Key HTTP Header Field", that standardises the header name and the error cases. It is still a draft rather than a finished RFC, but it is a good reference for which status codes to return:

  • 400 when a key is required but missing.
  • 409 Conflict when a request with the same key is still being processed.
  • 422 Unprocessable Content when the key is reused with a different request payload.

That last rule matters more than it looks. Without it, a buggy client that reuses a key for a different cart would silently get back the response for the old cart.

Step 1: a table with a unique constraint

The whole design rests on one database guarantee: two concurrent inserts with the same key cannot both succeed. In Postgres, a unique constraint gives you that for free, with no application-level locking. Here is the Prisma model I used:

model IdempotencyKey {
  id           String   @id @default(cuid())
  key          String
  userId       String
  requestHash  String
  status       String   @default("processing") // "processing" | "completed"
  responseCode Int?
  responseBody Json?
  createdAt    DateTime @default(now())

  @@unique([userId, key])
  @@index([createdAt])
}

A few deliberate choices here:

  • The key is unique per user, not globally. One user should never be able to collide with, or replay, another user's response.
  • requestHash stores a hash of the body so I can detect a reused key with a different payload.
  • status lets me tell "finished" apart from "another request is working on this right now".
  • The index on createdAt keeps the cleanup job cheap.

Step 2: claim the key, then do the work

The route handler follows a claim-then-execute flow. It first tries to insert the key. If the insert succeeds, this request owns the operation and runs it. If the insert fails with Prisma's unique constraint error (P2002), some earlier request already claimed the key, so we look it up and decide what to return.

// app/api/orders/route.ts
import { createHash } from "node:crypto";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { getUserId } from "@/lib/auth";
import { createOrder } from "@/lib/orders";

export async function POST(req: Request) {
  const userId = await getUserId(req);
  const key = req.headers.get("Idempotency-Key");

  if (!key || key.length > 255) {
    return Response.json({ error: "Missing or invalid Idempotency-Key" }, { status: 400 });
  }

  const body = await req.json();
  const requestHash = createHash("sha256").update(JSON.stringify(body)).digest("hex");

  // 1. Try to claim the key. The unique constraint makes this atomic.
  try {
    await prisma.idempotencyKey.create({
      data: { key, userId, requestHash },
    });
  } catch (err) {
    if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
      return replayOrReject(userId, key, requestHash);
    }
    throw err;
  }

  // 2. We own the key: do the real work exactly once.
  try {
    const order = await createOrder(userId, body);
    const responseBody = { id: order.id, status: order.status };

    await prisma.idempotencyKey.update({
      where: { userId_key: { userId, key } },
      data: { status: "completed", responseCode: 201, responseBody },
    });

    return Response.json(responseBody, { status: 201 });
  } catch (err) {
    // Release the key so the client can retry a failed attempt.
    await prisma.idempotencyKey.delete({ where: { userId_key: { userId, key } } });
    throw err;
  }
}

async function replayOrReject(userId: string, key: string, requestHash: string) {
  const existing = await prisma.idempotencyKey.findUnique({
    where: { userId_key: { userId, key } },
  });

  if (!existing) {
    // The first attempt failed and released the key in between. Ask for a retry.
    return Response.json({ error: "Please retry" }, { status: 409 });
  }
  if (existing.requestHash !== requestHash) {
    return Response.json(
      { error: "Idempotency-Key was already used with a different payload" },
      { status: 422 },
    );
  }
  if (existing.status === "processing") {
    return Response.json({ error: "A request with this key is still in progress" }, { status: 409 });
  }

  return Response.json(existing.responseBody, {
    status: existing.responseCode ?? 200,
    headers: { "Idempotent-Replayed": "true" },
  });
}

The important thing is where the race is resolved. Two retries can hit two different serverless instances at the same millisecond. Both call create, but Postgres lets exactly one of them win. The loser gets P2002 and either replays the stored response or returns 409. I never have to check "does this key exist?" and then insert, which would be a classic time-of-check to time-of-use bug.

What about failures in the middle?

If createOrder throws, I delete the key so the client can try again. That is the right behaviour for errors where nothing was committed, such as a validation failure or a database timeout before the write.

The harder case is a process crash between creating the order and marking the key as completed. The key stays stuck in processing forever, and every retry gets a 409. Two ways to handle this:

  1. Put the business write and the key update in one transaction. If both rows live in the same Postgres database, wrap them in prisma.$transaction so they commit together or not at all. This is my preferred option when it is possible.
  2. Add a lock timeout. Treat a processing row older than, say, 60 seconds as abandoned and let a new request take it over. This is necessary when the work includes an external call, such as charging a card, that cannot join your database transaction. In that case, pass the same idempotency key on to the payment provider too, so their side is protected as well.

Step 3: generate keys correctly on the client

This is where I see most implementations go wrong. The key must be generated once per user action, not once per HTTP attempt. If you create a new UUID inside your retry loop, every retry looks like a brand new operation and you are back to duplicates.

async function placeOrder(cart: Cart) {
  // Generate once per user action, NOT once per fetch attempt.
  const idempotencyKey = crypto.randomUUID();

  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const res = await fetch("/api/orders", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Idempotency-Key": idempotencyKey,
        },
        body: JSON.stringify(cart),
      });
      if (res.status !== 409 && res.status < 500) return res;
    } catch {
      // Network error: the request may or may not have reached the server.
    }
    await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
  }
  throw new Error("Order could not be confirmed, please check your orders page");
}

crypto.randomUUID() is available in modern browsers and in Node.js, so there is no need for an extra dependency. For a checkout form, I often generate the key when the form mounts and keep it in a ref, then regenerate it only after a successful submission. That also protects against the classic double-click on the submit button.

Step 4: expire old keys

Idempotency keys only need to live as long as a realistic retry window. I follow Stripe's lead and keep them for 24 hours, then delete them with a scheduled job:

// Run once an hour from a cron job
await prisma.idempotencyKey.deleteMany({
  where: { createdAt: { lt: new Date(Date.now() - 24 * 60 * 60 * 1000) } },
});

Keeping them forever would not break anything, but the table grows with every write request your API ever handles, and you gain almost nothing after the first few minutes.

Things I learned along the way

  • Hash a canonical body. JSON.stringify preserves key order, so two semantically identical objects with different key order produce different hashes. For my API the client always serialises the same object, so this was fine. If your clients vary, sort keys before hashing.
  • Only store responses you want to replay. I store successful results. Storing a 500 response and replaying it for 24 hours is almost never what a user wants.
  • Return a replay signal. The Idempotent-Replayed header in my example is my own convention, not part of the draft, but it made debugging and logging much easier.
  • Make it required on the endpoints that matter. Optional idempotency keys tend to be forgotten by exactly the client that needs them most.
A retry is only safe if the server can tell it apart from a new request. Idempotency keys are how you give it that ability.

Since adding this to my order and payment endpoints, I have stopped seeing duplicate orders entirely, and I can let the frontend retry aggressively on flaky mobile connections without worrying about it. It is about 80 lines of code and one table, which is a very good trade for never having to issue a duplicate refund again.


Key takeaways

  • POST requests are not idempotent, and network failures make it impossible for a client to know whether its request succeeded.
  • An Idempotency-Key header lets the server detect retries and replay the original response instead of repeating the work.
  • Use a database unique constraint on (userId, key) to resolve concurrent retries atomically. Don't check-then-insert.
  • Return 409 for in-progress requests and 422 for a reused key with a different payload, following the IETF draft.
  • Generate the key once per user action, never once per retry attempt.
  • Expire keys after a sensible window, such as 24 hours.
APIsSystem DesignPrismaNode.js
6views

Written by Admin

Published October 4, 2026 · Updated Oct 7, 2026