Tag: REST API

  • Idempotency Keys in 2026: Stop Duplicate Payments and Double Submits in Your API

    Idempotency Keys in 2026: Stop Duplicate Payments and Double Submits in Your API

    Flat vector illustration of a checkout request passing through an idempotency key gate so only one order is created despite repeated retries

    A few years ago a client called me on a Monday morning, not angry, just confused. Three customers had been charged twice for the same order over the weekend. Nothing was “broken”. The checkout worked, the payment provider worked, the database worked. What happened was boring: a slow mobile network, an impatient double tap on the Pay button, and a frontend retry that fired after a timeout while the first request was still quietly succeeding on the server.

    That was the week I stopped treating retries as a frontend detail. Today, every API I build for a client that creates money, orders, bookings, or emails gets idempotency keys from day one. It is one of the cheapest reliability upgrades you can make, and in 2026 it matters more than ever, because our clients are wrapped in retries: fetch wrappers, service workers, queue consumers, serverless platforms, and payment SDKs all retry on your behalf.

    In this guide I will walk through how I design idempotency keys for real projects: what the Idempotency-Key header is, how to store keys in Postgres or Redis, how to handle concurrent retries and mismatched payloads, how to wire it into an Express or Next.js API, and how the same idea protects webhook handlers. Real code, real status codes, and the mistakes I have actually made.

    What Idempotency Actually Means for an API

    An operation is idempotent when doing it once or doing it ten times leaves the server in the same state. HTTP already defines some methods this way. In RFC 9110, GET, HEAD, PUT, DELETE, OPTIONS, and TRACE are idempotent. Sending the same PUT /users/42 twice should not create two users; it just sets the same data again.

    POST and PATCH are not idempotent by definition, and that is exactly where the damage happens. POST /orders, POST /payments, POST /bookings, POST /contact all create something new each time they run. If the network drops the response after the server has done the work, the client has no way to know whether it is safe to try again.

    That gap is what an idempotency key closes. The client generates a unique key for one logical action, sends it with the request, and reuses the exact same key on every retry of that action. The server remembers the key and the result. The first request does the work. Every repeat with the same key gets the stored result back instead of running the side effect again.

    Note the important word: logical action. A key is not per HTTP request. It is per user intent. “Place this order” is one intent, even if it takes four network attempts to get a response.

    The Idempotency-Key Header and Who Already Uses It

    You do not have to invent a convention. Stripe made the Idempotency-Key request header popular years ago, and the IETF HTTPAPI working group has been standardizing it as “The Idempotency-Key HTTP Header Field”. Other providers use their own names for the same idea, for example PayPal accepts a PayPal-Request-Id header on many of its REST calls.

    The pattern is the same everywhere:

    • The client sends Idempotency-Key: <unique value> on a non-idempotent request such as POST.
    • The server stores the key together with a fingerprint of the request and, once done, the response.
    • A retry with the same key and the same payload returns the original response.
    • A retry with the same key but a different payload is rejected, because that is almost always a client bug.

    The IETF draft also suggests clear status codes, and I follow them because they make debugging much easier:

    • 400 Bad Request when a key is required for that endpoint but missing.
    • 409 Conflict when a retry arrives while the original request is still being processed.
    • 422 Unprocessable Content when the same key is reused with a different request payload.

    Stripe also sends an Idempotent-Replayed: true response header when it returns a stored result. I copy that idea in my own APIs. It costs nothing and makes logs and support tickets far easier to read.

    How I Generate Idempotency Keys on the Client

    The key needs to be unique per intent and unguessable enough that one user cannot collide with another. A UUID v4 is perfect, and in 2026 you do not need a library for it. crypto.randomUUID() is available in all modern browsers (in secure contexts) and in Node.js.

    The rule that matters most: create the key when the user starts the action, not inside the retry loop. If you generate a fresh key on every attempt, you have built a very expensive way to do nothing.

    // One key per checkout attempt, created when the user clicks Pay.
    async function placeOrder(cart) {
      const idempotencyKey = crypto.randomUUID();
      return postWithRetry('/api/orders', cart, idempotencyKey);
    }
    
    async function postWithRetry(url, body, idempotencyKey, attempts = 3) {
      let lastError;
      for (let i = 0; i < attempts; i++) {
        try {
          const res = await fetch(url, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              'Idempotency-Key': idempotencyKey,
            },
            body: JSON.stringify(body),
            signal: AbortSignal.timeout(10000),
          });
          if (res.status === 409) {
            // Original request still running on the server: wait, then retry same key.
            await new Promise((r) => setTimeout(r, 1000 * (i + 1)));
            continue;
          }
          return res;
        } catch (err) {
          lastError = err; // timeout or network drop: safe to retry with the SAME key
          await new Promise((r) => setTimeout(r, 500 * 2 ** i));
        }
      }
      throw lastError;
    }

    A few details I care about here. AbortSignal.timeout() gives every attempt a hard deadline, so a hanging connection does not freeze the UI. The backoff grows between attempts, so a struggling server is not hammered. And a 409 is treated as “please wait”, not as a failure. (If you want the full story on timeouts and cancellation, I wrote a separate guide on AbortController in 2026. Cancelling a request on the client never guarantees the server did not process it, which is exactly why idempotency keys matter.)

    If the user can refresh the page mid-checkout, I also keep the key in sessionStorage until the order succeeds. That way a reload followed by another click still reuses the same key instead of creating a second order.

    Infographic showing a client creating one idempotency key per user action and reusing it across timed-out retries
    Infographic 1: One key per user intent, reused on every retry with backoff.

    Designing the Server Side: Store, Fingerprint, Replay

    On the server, an idempotency layer needs to answer four questions for every request that carries a key:

    1. Have I seen this key before for this user?
    2. If yes, is the request payload the same as last time?
    3. Is the original request finished, or still running?
    4. If finished, what response should I replay?

    Scope keys to the caller

    Never treat keys as global. Store them per account, per API client, or per user. Otherwise two different customers who happen to send the same key (or one malicious client guessing keys) could receive each other’s stored responses. In my tables the primary key is always the pair of user and key.

    Fingerprint the request

    Store a hash of the parts of the request that define the intent: method, path, and the normalized body. If a retry arrives with the same key but a different hash, return 422. This catches real bugs, like a frontend that reuses a key across two different carts.

    import { createHash } from 'node:crypto';
    
    function fingerprint(req) {
      const payload = JSON.stringify({ method: req.method, path: req.path, body: req.body });
      return createHash('sha256').update(payload).digest('hex');
    }

    If your clients send JSON with keys in different orders, sort the object keys before hashing, or hash only the specific fields that matter for the operation.

    A Postgres table for idempotency keys

    For most client projects Postgres is already there, so I start with a table. The unique constraint does the heavy lifting under concurrency, which is far safer than a “check, then insert” in application code.

    CREATE TABLE idempotency_keys (
      user_id        bigint      NOT NULL,
      idem_key       text        NOT NULL,
      request_hash   text        NOT NULL,
      status         text        NOT NULL DEFAULT 'processing',
      response_code  integer,
      response_body  jsonb,
      created_at     timestamptz NOT NULL DEFAULT now(),
      PRIMARY KEY (user_id, idem_key)
    );
    
    CREATE INDEX idempotency_keys_created_at_idx ON idempotency_keys (created_at);

    The created_at index is there for cleanup. Stripe documents that keys can be pruned once they are at least 24 hours old, and I use a similar window: anything older than 24 to 72 hours gets deleted by a scheduled job. Pick a retention that is longer than your longest realistic retry window.

    Express Middleware for Idempotent POST Requests

    Here is a trimmed version of the middleware I drop into Express APIs. It uses the pg library and the table above. The core trick is INSERT ... ON CONFLICT DO NOTHING: only one request can win the insert, so only one request ever runs the real handler.

    import express from 'express';
    import pg from 'pg';
    
    const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
    const app = express();
    app.use(express.json());
    
    async function idempotency(req, res, next) {
      const key = req.get('Idempotency-Key');
      if (!key) return res.status(400).json({ error: 'Idempotency-Key header is required' });
      if (key.length > 255) return res.status(400).json({ error: 'Idempotency-Key is too long' });
    
      const userId = req.user.id; // set by your auth middleware
      const hash = fingerprint(req);
    
      const inserted = await pool.query(
        `INSERT INTO idempotency_keys (user_id, idem_key, request_hash)
         VALUES ($1, $2, $3)
         ON CONFLICT (user_id, idem_key) DO NOTHING
         RETURNING idem_key`,
        [userId, key, hash]
      );
    
      if (inserted.rowCount === 0) {
        const { rows } = await pool.query(
          'SELECT request_hash, status, response_code, response_body FROM idempotency_keys WHERE user_id = $1 AND idem_key = $2',
          [userId, key]
        );
        const saved = rows[0];
        if (saved.request_hash !== hash) {
          return res.status(422).json({ error: 'Idempotency-Key reused with a different payload' });
        }
        if (saved.status === 'processing') {
          res.set('Retry-After', '1');
          return res.status(409).json({ error: 'Original request is still in progress' });
        }
        res.set('Idempotent-Replayed', 'true');
        return res.status(saved.response_code).json(saved.response_body);
      }
    
      // First time we see this key: capture the response so we can replay it later.
      const originalJson = res.json.bind(res);
      res.json = (body) => {
        const done = res.statusCode < 500
          ? pool.query(
              `UPDATE idempotency_keys SET status = 'completed', response_code = $3, response_body = $4
               WHERE user_id = $1 AND idem_key = $2`,
              [userId, key, res.statusCode, JSON.stringify(body)]
            )
          : pool.query('DELETE FROM idempotency_keys WHERE user_id = $1 AND idem_key = $2', [userId, key]);
        done.catch((err) => console.error('idempotency store failed', err));
        return originalJson(body);
      };
      next();
    }
    
    app.post('/api/orders', idempotency, async (req, res) => {
      const order = await createOrderAndCharge(req.user.id, req.body);
      res.status(201).json(order);
    });

    Two decisions in there deserve a sentence each.

    Which results do I store? Successful responses and client errors (4xx) get stored and replayed, because retrying a validation error with the same payload will fail the same way. Server errors (5xx) delete the key, so the client can retry and the work can actually be attempted again. That only works if your handler is careful about partial side effects, which brings us to transactions.

    Where does the side effect happen? If you can, write the order row and the idempotency record in the same database transaction. If the side effect is external, like a card charge, pass your idempotency key (or a key derived from it) to the payment provider as well. Stripe’s API accepts the Idempotency-Key header on its own POST requests, so a retry from your server will not double charge either. Idempotency should be end to end, not just at your front door.

    Infographic of the server idempotency flow: insert key, compare request fingerprint, return 409 while processing, 422 on mismatch, or replay the stored response
    Infographic 2: Store, fingerprint, replay. The 400, 409 and 422 decision flow.

    Handling Concurrency: The Part Everyone Gets Wrong

    The most common broken implementation I see in code reviews looks like this: SELECT the key, if it is not there run the handler, then INSERT the key. Under a double tap, both requests run the SELECT at nearly the same time, both see nothing, and both create the order. You have built idempotency that only works when nobody needs it.

    The fix is to make the database decide. A primary key or unique constraint plus ON CONFLICT DO NOTHING (Postgres) or INSERT IGNORE (MySQL) means exactly one request wins the row. The loser immediately learns the key is taken and returns 409 until the winner finishes.

    If you prefer Redis for the lock and short-term storage, the equivalent atomic primitive is SET with the NX and EX options:

    // Only one request can create the lock; it expires after 24 hours.
    const acquired = await redis.set(`idem:${userId}:${key}`, JSON.stringify({ status: 'processing', hash }), {
      NX: true,
      EX: 60 * 60 * 24,
    });
    if (!acquired) {
      // Read the stored record and return 409, 422, or the replayed response.
    }

    That snippet uses the node-redis v4+ option style. With ioredis the same call is written as redis.set(name, value, 'EX', 86400, 'NX'). Either way, the important part is that the existence check and the write are one atomic command.

    One more edge case: a request that crashes halfway leaves a key stuck in processing. I add a locked_at timestamp (or rely on the Redis expiry) and let a key be taken over if it has been processing for longer than the maximum handler time, for example five minutes. Without that, a single crash can block a user’s retry for the entire retention window.

    Idempotency in Next.js Route Handlers and Server Actions

    On Next.js App Router projects the same logic lives inside a Route Handler. The Request object gives you headers directly:

    // app/api/orders/route.ts
    export async function POST(request: Request) {
      const key = request.headers.get('Idempotency-Key');
      if (!key) {
        return Response.json({ error: 'Idempotency-Key header is required' }, { status: 400 });
      }
      const body = await request.json();
      const userId = await getUserIdFromSession();
    
      const result = await withIdempotency(userId, key, body, () => createOrder(userId, body));
      return Response.json(result.body, {
        status: result.status,
        headers: result.replayed ? { 'Idempotent-Replayed': 'true' } : {},
      });
    }

    withIdempotency is just the Express logic above pulled into a plain function that returns { status, body, replayed }, which makes it easy to unit test and reuse across endpoints.

    For Server Actions, there is no custom header you control from a plain form. My approach is a hidden input: the Server Component generates a key with randomUUID() from node:crypto and renders it as <input type="hidden" name="idempotencyKey" value={key} /> inside the form, and the action reads it with formData.get('idempotencyKey'). A double submit of the same rendered form sends the same key, so the second call gets the stored result. Pair that with a disabled submit button while the form is pending and double submits stop being a support ticket.

    Webhooks: Idempotency on the Receiving Side

    Idempotency is not only for APIs you expose to your own frontend. It is just as important for webhooks you receive. Payment providers, CRMs, and form tools deliver webhooks “at least once”, which means you will sometimes get the same event twice, especially after a timeout on your side.

    Stripe events, for example, carry a unique event id such as evt_.... I store processed event IDs in a table with a unique constraint and skip any event I have already handled:

    CREATE TABLE processed_webhook_events (
      provider     text        NOT NULL,
      event_id     text        NOT NULL,
      processed_at timestamptz NOT NULL DEFAULT now(),
      PRIMARY KEY (provider, event_id)
    );
    const { rowCount } = await pool.query(
      'INSERT INTO processed_webhook_events (provider, event_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
      ['stripe', event.id]
    );
    if (rowCount === 0) return res.sendStatus(200); // duplicate delivery, already handled
    await fulfillOrder(event);
    res.sendStatus(200);

    Always verify the webhook signature first (Stripe uses the Stripe-Signature header and its SDK’s constructEvent helper), then dedupe, then do the work. And respond quickly. If fulfillment is slow, push it to a queue and acknowledge the webhook, because a slow response is exactly what triggers the provider’s retries.

    Infographic of webhook deduplication: duplicate deliveries of the same event ID are acknowledged once and skipped by a unique constraint
    Infographic 3: Webhooks arrive at least once. Verify, dedupe by event ID, then process.

    Common Idempotency Mistakes I Still See in 2026

    After a few years of adding this to client APIs, these are the mistakes that come up again and again:

    • Generating the key inside the retry loop. Every attempt gets a new key, so nothing is deduplicated.
    • Global keys instead of scoped keys. One customer can replay another customer’s response. Always scope by account or user.
    • Check-then-insert logic. It races under concurrency. Let a unique constraint or an atomic SET NX decide.
    • Caching 5xx responses forever. A temporary outage turns into a permanent failure for that key. Release the key on server errors.
    • No payload fingerprint. Reusing a key with a different body silently returns the wrong result instead of a clear 422.
    • Keys that never expire. The table grows forever. Prune keys after your retention window.
    • Forgetting downstream calls. Your API is idempotent, but the call to the payment provider is not. Pass a key there too.
    • Requiring keys on GET. Reads are already safe to repeat. Keep the header for POST and PATCH where it earns its keep.

    Where Idempotency Keys Pay Off on Client Projects

    You do not need this on every endpoint. On freelance and agency projects, these are the places where I add it without debate:

    • Payments and checkout, including subscription upgrades and refunds.
    • Order, booking, and reservation creation, where a duplicate means a real-world problem like a double-booked table.
    • Outbound emails and SMS triggered by forms, so a retry does not send the welcome email three times.
    • Lead forms that sync to a CRM, because duplicate leads make sales teams distrust the whole system.
    • Background jobs and queue consumers, which retry on failure by design.

    For a simple brochure contact form, a disabled button and server-side duplicate detection are usually enough. For anything that touches money or inventory, idempotency keys are non-negotiable for me now.

    My Idempotency Checklist Before Launch

    When I hand an API over to a client, I run through this list:

    1. Every non-idempotent endpoint that creates money, orders, bookings, or messages accepts Idempotency-Key.
    2. The client generates one key per user intent with crypto.randomUUID() and reuses it on every retry.
    3. Keys are scoped per user or API client and stored with a request fingerprint.
    4. A unique constraint or atomic SET NX guarantees one winner under concurrency.
    5. Responses return 400 for a missing key, 409 while processing, 422 for payload mismatch, and replay stored results with Idempotent-Replayed: true.
    6. Server errors release the key; stuck processing keys can be taken over after a timeout.
    7. Downstream payment calls carry their own idempotency keys.
    8. Webhook handlers verify signatures and dedupe by event ID.
    9. A scheduled job prunes keys older than the retention window.
    10. There is a test that fires the same request twice in parallel and asserts exactly one order exists.

    That last test is my favorite. It takes ten minutes to write with Promise.all and two identical requests, and it catches the check-then-insert race before a customer does.

    Final Thoughts

    Idempotency keys are not glamorous. Nobody will ever compliment your checkout for not charging them twice. But that Monday morning phone call taught me that reliability is mostly about the unglamorous paths: slow networks, impatient taps, retries you did not write yourself, and webhooks that arrive twice.

    A small table, a unique constraint, one header, and a few clear status codes are enough to turn “it worked, it just happened twice” into “it worked exactly once”. If you are building APIs for clients in 2026, add idempotency keys before launch, not after the first duplicate refund.

    If you want a second pair of eyes on your API design or checkout flow, that is exactly the kind of work I do for clients. Get in touch through darshanpanchasara.com and tell me what you are building.