Idempotency in APIs Explained

Why it matters, which HTTP methods are idempotent, and how to implement idempotency keys

API DesignJuly 3, 202616 min readBy Keyur Patel

A user clicks "Pay Now" on your checkout page. The network hiccups. The request times out. Their browser retries. Your server processes the payment again. The customer gets charged twice. Their trust in your product? Gone.

This exact scenario happens more than you'd think — especially on mobile networks, during deploy windows, or when webhooks fire multiple times. Idempotency is the pattern that prevents it. It ensures that no matter how many times a request is sent, the result is the same as if it was sent once.

What Is Idempotency?

An operation is idempotent if performing it multiple times produces the same result as performing it once. The system ends up in the same state regardless of how many times you execute the operation.

Real-world analogy: pressing an elevator button. Press it once — the elevator is called. Press it 10 more times — the elevator is still just called once. The button is idempotent. Compare that to a vending machine: insert a coin and you get a snack. Insert another coin and you get another snack. That's not idempotent.

In API terms:

// Idempotent: setting a value
PUT /users/123  { "email": "alice@example.com" }
// Do it 1 time or 100 times — email is "alice@example.com"

// NOT idempotent: creating a resource
POST /orders  { "item": "laptop", "qty": 1 }
// Do it 3 times — you get 3 orders

Which HTTP Methods Are Idempotent?

The HTTP specification defines which methods are idempotent. This isn't a suggestion — clients and intermediaries (proxies, CDNs, browsers) rely on this contract to decide what's safe to retry automatically.

MethodIdempotent?Safe?Why
GET✅ Yes✅ YesRetrieves data without changing state — always returns the same resource
HEAD✅ Yes✅ YesSame as GET but without body — no state change
OPTIONS✅ Yes✅ YesReturns server capabilities — no state change
PUT✅ Yes❌ NoReplaces entire resource — doing it twice leaves the same state
DELETE✅ Yes❌ NoRemoves resource — deleting twice still results in 'resource gone'
POST❌ No❌ NoCreates new resources — each call may create a new entry
PATCH❌ No❌ NoPartial update — 'increment counter' is not idempotent

Key distinction: "safe" means no side effects at all. "Idempotent" means repeated calls produce the same result. DELETE changes state (removes a resource) but is still idempotent because doing it twice doesn't change anything beyond the first call.

Why Idempotency Matters

In the real world, requests don't always arrive once. Networks are unreliable. Clients retry. Users double-click. Here's where non-idempotent APIs break:

Network Retries

HTTP clients (Axios, fetch with retry libraries) automatically retry on timeout or 5xx errors. If your POST endpoint creates a resource on each call, retries create duplicates.

Double-Clicks

Users click submit buttons multiple times — especially when the UI doesn't show a loading state fast enough. Without idempotency, each click creates a new transaction.

Mobile Networks

Mobile connections drop and reconnect constantly. A request might reach the server, get processed, but the response gets lost. The client retries — and without idempotency, the operation executes again.

Webhooks

Webhook providers (Stripe, GitHub, Shopify) retry on delivery failure. Your webhook handler might receive the same event 2-5 times. If it's not idempotent, you process the event multiple times.

How to Implement Idempotency Keys

The standard pattern for making non-idempotent operations (like POST) safe to retry is the idempotency key. Here's the flow:

Client                              Server
  |                                    |
  |--- POST /payments ----------------->|
  |    Header: Idempotency-Key: abc-123 |
  |                                    |
  |    1. Check: has "abc-123" been     |
  |       seen before?                  |
  |                                    |
  |    [FIRST TIME]                     |
  |    2. Store key as "processing"     |
  |    3. Execute the operation         |
  |    4. Store result with key         |
  |    5. Return response               |
  |<--- 201 Created -------------------|
  |                                    |
  |--- POST /payments ----------------->|  (retry — same key)
  |    Header: Idempotency-Key: abc-123 |
  |                                    |
  |    [KEY EXISTS]                      |
  |    2. Return stored response        |
  |<--- 201 Created (cached) ----------|
  |    (no duplicate payment created)   |

The client generates a unique key (UUID v4 works well) before sending the request. If it needs to retry, it sends the same key. The server recognizes the duplicate and returns the original response without re-executing the operation.

Code Example: Express.js Idempotency Middleware

A production-ready middleware using Redis to store idempotency keys and responses:

import Redis from 'ioredis'

const redis = new Redis(process.env.REDIS_URL)
const IDEMPOTENCY_TTL = 86400 // 24 hours

export function idempotencyMiddleware(req, res, next) {
  // Only apply to non-idempotent methods
  if (['GET', 'PUT', 'DELETE', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next()
  }

  const idempotencyKey = req.headers['idempotency-key']
  if (!idempotencyKey) {
    return next() // No key provided — process normally
  }

  // Validate key format (UUID v4)
  const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
  if (!uuidRegex.test(idempotencyKey)) {
    return res.status(400).json({
      error: 'Invalid idempotency key format. Use UUID v4.'
    })
  }

  const cacheKey = `idempotency:${req.path}:${idempotencyKey}`

  handleIdempotency(cacheKey, req, res, next)
}

async function handleIdempotency(cacheKey, req, res, next) {
  try {
    // Check if key exists
    const existing = await redis.get(cacheKey)

    if (existing) {
      const cached = JSON.parse(existing)

      // Key is still processing (server crashed mid-request?)
      if (cached.status === 'processing') {
        return res.status(409).json({
          error: 'Request is already being processed. Retry later.'
        })
      }

      // Return cached response
      return res.status(cached.statusCode).json(cached.body)
    }

    // Mark as processing
    await redis.setex(cacheKey, IDEMPOTENCY_TTL, JSON.stringify({
      status: 'processing',
      startedAt: Date.now()
    }))

    // Intercept res.json to capture the response
    const originalJson = res.json.bind(res)
    res.json = (body) => {
      // Store completed response
      redis.setex(cacheKey, IDEMPOTENCY_TTL, JSON.stringify({
        status: 'completed',
        statusCode: res.statusCode,
        body,
        completedAt: Date.now()
      }))
      return originalJson(body)
    }

    next()
  } catch (err) {
    console.error('Idempotency middleware error:', err)
    next() // Fail open — process request normally
  }
}

Usage in your Express app:

import express from 'express'
import { idempotencyMiddleware } from './middleware/idempotency.js'

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

app.post('/api/payments', async (req, res) => {
  const payment = await processPayment(req.body)
  res.status(201).json(payment)
})

// Client sends:
// POST /api/payments
// Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
// Content-Type: application/json
// { "amount": 2999, "currency": "usd" }

How Stripe Handles Idempotency

Stripe's implementation is the gold standard for idempotency in payment APIs. Here's how they do it:

Client sends Idempotency-Key header

Any POST request to Stripe can include an Idempotency-Key header with a unique string (up to 255 characters). Stripe recommends UUID v4.

Keys expire after 24 hours

Stripe stores the key and response for 24 hours. After that, the same key can be reused (though you generally shouldn't).

Parameter mismatch = 422 error

If you send the same key with different request body parameters, Stripe returns a 422 error. The key is bound to the original request parameters to prevent misuse.

Automatic retries in SDKs

Stripe's official SDKs automatically generate and reuse idempotency keys when retrying failed requests. You get safety without thinking about it.

// Stripe API with idempotency key
const stripe = require('stripe')('sk_test_...')

const paymentIntent = await stripe.paymentIntents.create(
  {
    amount: 2000,
    currency: 'usd',
    payment_method: 'pm_card_visa',
    confirm: true,
  },
  {
    idempotencyKey: '550e8400-e29b-41d4-a716-446655440000'
  }
)

// If this request is retried (network timeout, etc.),
// Stripe returns the same PaymentIntent without charging again

Database-Level Idempotency

You don't always need Redis and middleware. Sometimes the database itself can enforce idempotency through constraints and conflict handling:

Unique Constraints

Add a unique constraint on a business-meaningful field. Duplicate inserts fail at the database level instead of creating duplicates.

-- Unique constraint on order reference
CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  order_ref VARCHAR(50) UNIQUE NOT NULL,  -- client-generated reference
  user_id INTEGER NOT NULL,
  amount DECIMAL(10,2) NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

-- Second insert with same order_ref fails with unique violation
INSERT INTO orders (order_ref, user_id, amount)
VALUES ('ORD-2026-ABC123', 42, 99.99);

Upserts (INSERT ON CONFLICT)

Instead of failing on duplicates, upsert either does nothing or updates the existing row. This makes the INSERT idempotent by definition.

-- PostgreSQL: Insert or do nothing if duplicate
INSERT INTO webhook_events (event_id, type, payload, processed_at)
VALUES ('evt_123abc', 'payment.completed', '{"amount": 5000}', NOW())
ON CONFLICT (event_id) DO NOTHING;

-- MySQL equivalent
INSERT IGNORE INTO webhook_events (event_id, type, payload)
VALUES ('evt_123abc', 'payment.completed', '{"amount": 5000}');

Conditional Inserts

Check a condition before inserting. Only insert if the precondition holds — making the operation safe to retry.

-- Only insert if not already processed
INSERT INTO ledger_entries (transaction_id, account_id, amount, type)
SELECT 'txn_abc123', 42, 100.00, 'credit'
WHERE NOT EXISTS (
  SELECT 1 FROM ledger_entries WHERE transaction_id = 'txn_abc123'
);

Common Idempotency Mistakes

These are the pitfalls that catch teams when implementing idempotency:

1. Not Validating Key Format

Accepting any string as an idempotency key means clients might accidentally reuse keys (like sending "test" or "1"). Require UUID v4 format or at least a minimum length to prevent collisions.

2. Not Expiring Keys

Storing idempotency keys forever means your storage grows without limit. Set a TTL (24-48 hours is standard). After expiry, the same key can technically be reused, but the retry window has long passed.

3. Returning Different Responses for the Same Key

If the stored response doesn't match what the operation would produce today (because data changed), you should still return the original stored response. The idempotency contract is: same key = same response. Period.

4. Not Binding Key to Request Parameters

If you accept the same idempotency key with different parameters, you might return a cached response for a completely different request. Always validate that the request body matches what was originally sent with that key.

5. Server-Side Key Generation

The client must generate the idempotency key. If the server generates it, the client can't retry with the same key after a timeout (because it never received the key). The whole point is the client controls the deduplication.

6. Not Handling the "Processing" State

If a request is mid-execution and a retry arrives, you need to handle this gracefully. Without a "processing" state, you might execute the operation twice concurrently. Use a state machine: received → processing → completed.

Idempotency Best Practices

1.

Use UUID v4 for idempotency keys. They're globally unique without coordination, 128 bits of randomness, and impossible to accidentally reuse. Generate one per user action, not per retry attempt.

2.

Store keys with a 24-hour TTL. Long enough to handle any realistic retry scenario. Short enough to prevent unbounded storage growth. Match your client's maximum retry window.

3.

Scope keys to the endpoint + user. The cache key should include the API path and authenticated user to prevent cross-endpoint collisions. Format: idempotency:{userId}:{path}:{key}

4.

Validate request body matches. When a cached key is found, compare the incoming request parameters with the original. If they differ, return a 422 error — don't silently return a mismatched response.

5.

Fail open on cache errors. If Redis is down, process the request normally rather than blocking all writes. Log the failure and accept the (unlikely) risk of a duplicate. Availability beats perfect idempotency.

6.

Make webhook handlers idempotent. Use the event ID as a natural idempotency key. Store processed event IDs and skip duplicates. This is critical for payment webhooks where reprocessing means double-crediting accounts.

7.

Document idempotency behavior in your API docs. Tell clients which endpoints accept idempotency keys, what header to use, what format is expected, and how long keys are stored. Don't make them guess.

8.

Test with concurrent duplicate requests. Unit tests aren't enough. Send the same request simultaneously from multiple threads and verify only one operation executes. Race conditions hide in idempotency logic.

Frequently Asked Questions

What does idempotent mean in APIs?+
An API operation is idempotent if performing it multiple times produces the same result as performing it once. Setting a user's email is idempotent — doing it 5 times leaves the same state. Creating a new order is NOT idempotent — doing it 5 times creates 5 orders.
Which HTTP methods are idempotent?+
GET, PUT, DELETE, HEAD, and OPTIONS are idempotent by specification. POST and PATCH are NOT idempotent. GET always returns the same resource. PUT replaces the entire resource. DELETE removes the resource — subsequent calls return 404 but the state remains the same.
What is an idempotency key?+
An idempotency key is a unique identifier (usually a UUID) sent by the client in a request header. The server uses it to detect duplicate requests. If it sees the same key again, it returns the cached response from the first execution instead of re-processing the request.
Why does idempotency matter for payment APIs?+
Without idempotency, network timeouts or retries can cause double charges. A user clicks "Pay" once, the request times out, the client retries, and the server processes the payment twice. With an idempotency key, the second request returns the result of the first payment without charging again.
How does Stripe handle idempotency?+
Stripe accepts an Idempotency-Key header on POST requests. They store the key, request parameters, and response for 24 hours. If the same key is sent again with the same parameters, Stripe returns the stored response. If the same key is sent with different parameters, Stripe returns a 422 error.
How long should idempotency keys be stored?+
Most implementations store keys for 24 to 48 hours. This is long enough to handle retries from network failures and user double-clicks, but short enough to prevent storage from growing indefinitely. Set a Redis TTL that matches your retry window.
Is PATCH idempotent?+
PATCH is NOT guaranteed to be idempotent by the HTTP specification, though it can be implemented idempotently. A PATCH that sets a field to a specific value ("set email to X") is idempotent. A PATCH that increments a counter ("add 1 to balance") is NOT idempotent. Use idempotency keys for non-idempotent PATCH operations.
What happens if the server crashes mid-request with an idempotency key?+
If the server crashes after storing the key but before completing the operation, the key is in a "processing" state. On retry, the server should detect this and either retry the operation or return an error asking the client to retry with a new key. Good implementations use a state machine: received → processing → completed.

Idempotency Implementation Checklist

Use this as a reference when adding idempotency to your API:

  • Identify all non-idempotent endpoints (POST, non-idempotent PATCH)
  • Add Idempotency-Key header support to those endpoints
  • Validate key format (UUID v4, minimum length)
  • Store keys in Redis with 24h TTL (or database for simpler setups)
  • Implement "processing" state to handle concurrent duplicates
  • Validate request body matches on cache hit (return 422 on mismatch)
  • Return exact cached response (same status code, same body)
  • Fail open on Redis errors (process request normally)
  • Add unique constraints on business-meaningful fields in database
  • Make webhook handlers idempotent using event IDs
  • Test with concurrent duplicate requests (race condition testing)
  • Document idempotency behavior in API docs

Idempotency vs Exactly-Once Delivery

People often confuse these concepts. Exactly-once delivery means a message is delivered precisely one time — no more, no less. This is extremely hard (some argue impossible) in distributed systems.

Idempotency sidesteps the problem entirely. Instead of guaranteeing a message arrives once, you guarantee that processing it multiple times has the same effect as processing it once. You get "effectively exactly-once" semantics by combining "at-least-once delivery" with "idempotent processing."

At-least-once delivery + Idempotent handler = Effectively exactly-once

// Message queue delivers the same message 3 times due to retries
// Idempotent handler: processes first, skips duplicates
// Result: same as if message was delivered once

This is why idempotency is critical for message queue consumers, webhook handlers, and any system that receives events from an unreliable transport.

Related Articles

Conclusion

Idempotency isn't a nice-to-have — it's a requirement for any API that handles money, creates resources, or processes events. Without it, network retries become data corruption, double-clicks become double charges, and webhook redeliveries become duplicated side effects.

The implementation doesn't need to be complex. A UUID-based idempotency key stored in Redis with a 24-hour TTL handles the vast majority of cases. For simpler scenarios, database unique constraints and upserts give you idempotency without extra infrastructure.

Start with your payment endpoints and webhook handlers — those are where duplicate execution hurts most. Then extend to any POST endpoint where duplicates would cause visible problems for users.