Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

API Idempotency Keys Implementation Guide

By Kokil Thapa | Last reviewed: September 2026

Network timeouts, mobile app retries, and webhook redelivery can turn one user action into two charges, two bookings, or two legal filings. This API Idempotency Keys Implementation Guide explains how to make unsafe HTTP methods safe under retry. You will learn header design, server-side storage, response replay, and the edge cases that break naive implementations. The patterns here apply to Laravel REST APIs, payment gateways, and any endpoint where duplicate side effects cost real money.

What are API idempotency keys and why do you need them?

HTTP defines idempotent methods. GET, PUT, and DELETE should produce the same server state when repeated with identical inputs. POST is not idempotent by default. A client that posts twice may create two orders.

Idempotency keys solve that gap. The client generates a UUID or similar token once per logical operation. It sends that token on every retry of the same intent. The server treats the key as a deduplication handle for that user, route, and payload fingerprint.

On real client projects I have seen payment callbacks arrive three times within seconds. Without idempotency, the ledger shows three credits for one bank transfer. The fix is not "tell users not to double-click." Clients will retry. Networks will drop packets. Load balancers will time out while your database commit still runs.

Idempotency Key Request FlowClient AppUUID key onceAPI ServerCheck key storeKey StoreRedis or MySQLPOSTlookupFirst request: run handler, persist responseRetry: replay stored status + bodyWithout KeyDuplicate charge riskWith KeySafe client retries
API idempotency keys let clients retry POST requests without triggering duplicate server-side effects.

Common triggers for duplicate writes include mobile apps retrying after a spinner timeout, JavaScript fetch wrappers with automatic backoff, and partner webhooks that assume at-least-once delivery. REST API design best practices in 2026 treat idempotency as mandatory for money-moving endpoints, not an optional nicety.

Where idempotency matters most

  • Payment capture, refunds, and wallet top-ups
  • Booking confirmations with inventory holds
  • Document submission portals where each upload triggers fees
  • Webhook handlers that create orders from external events
  • Batch imports that clients restart after partial failure

On a legal-tech portal I built, a user could pay a court-fee deposit and upload affidavits in one flow. A dropped connection during checkout looked like failure to the browser. The user paid again. Idempotency keys on the payment endpoint prevented a second ledger entry while still returning the original receipt on retry.

How do you implement idempotency keys in a Laravel API?

Laravel 13 on PHP 8.3 or higher gives you middleware, database transactions, and Redis out of the box. The implementation pattern is consistent across frameworks: accept a key, lock or insert a record, execute once, cache the outcome.

Stripe popularised the Idempotency-Key header. That convention is now the de facto standard. Document it in your OpenAPI spec and enforce it on every non-safe write route that creates billable state.

Step 1: Define the header contract

Require a header such as Idempotency-Key or X-Idempotency-Key. Pick one name and never accept both silently. Keys should be opaque strings between 16 and 255 characters. UUID v4 is the usual choice.

POST /api/v1/orders HTTP/1.1
Host: api.example.com
Authorization: Bearer <token>
Idempotency-Key: 7f3c9a2e-4b1d-4c8a-9e6f-2a1b3c4d5e6f
Content-Type: application/json

{
  "product_id": 42,
  "quantity": 1
}

Scope keys per authenticated principal. A key reused by a different user must return 409 Conflict, not someone else's cached response. Also scope by HTTP method and path, or store the route name in the record.

Step 2: Create the persistence layer

MySQL 9.7 or Redis 8.10 both work. Redis is faster for short TTL caches. MySQL gives durable audit trails. Many production systems use Redis for hot lookups and MySQL for compliance logs.

Schema::create('idempotency_keys', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('key_hash', 64);
    $table->string('route', 191);
    $table->string('request_hash', 64);
    $table->unsignedSmallInteger('response_status');
    $table->json('response_body');
    $table->string('status', 20)->default('processing');
    $table->timestamp('expires_at');
    $table->timestamps();
    $table->unique(['user_id', 'key_hash', 'route']);
});

Store a SHA-256 hash of the raw key, not the plaintext key, if you want defence in depth. Always hash the request body and compare on replay. Same key with a different payload is an error, not a silent match.

Step 3: Build middleware that gates execution

Register middleware on routes that need protection. The flow below mirrors what I ship on production Laravel applications integrated with API development workflows.

  1. Read and validate the idempotency header.
  2. Look up an existing record for user + key + route.
  3. If status is completed, return the stored response immediately.
  4. If status is processing, return 409 Conflict or 202 Accepted with a retry hint.
  5. Insert a processing row inside a transaction with a unique constraint.
  6. Run the controller logic.
  7. Persist status code and JSON body, then mark completed.
public function handle(Request $request, Closure $next)
{
    $rawKey = $request->header('Idempotency-Key');
    if (!$rawKey || strlen($rawKey) < 16) {
        return response()->json(['error' => 'Missing Idempotency-Key'], 400);
    }

    $keyHash = hash('sha256', $rawKey);
    $route = $request->route()->getName() ?? $request->path();
    $bodyHash = hash('sha256', $request->getContent());

    $record = IdempotencyKey::where([
        'user_id' => $request->user()->id,
        'key_hash' => $keyHash,
        'route' => $route,
    ])->first();

    if ($record && $record->status === 'completed') {
        if ($record->request_hash !== $bodyHash) {
            return response()->json(['error' => 'Key reused with different body'], 422);
        }
        return response()->json($record->response_body, $record->response_status);
    }

    if ($record && $record->status === 'processing') {
        return response()->json(['error' => 'Request in progress'], 409);
    }

    $record = IdempotencyKey::create([
        'user_id' => $request->user()->id,
        'key_hash' => $keyHash,
        'route' => $route,
        'request_hash' => $bodyHash,
        'status' => 'processing',
        'expires_at' => now()->addHours(24),
    ]);

    $response = $next($request);

    $record->update([
        'response_status' => $response->getStatusCode(),
        'response_body' => json_decode($response->getContent(), true),
        'status' => 'completed',
    ]);

    return $response;
}

Wrap the handler and the final update in a database transaction when the business logic itself is transactional. If the handler throws, delete or mark the idempotency row as failed so the client can retry with the same key.

Validate JSON payloads with a JSON formatter and linter during development. Mismatched whitespace should not change the request hash if you normalise canonical JSON before hashing.

How should you store, expire, and clean up idempotency key records?

Storage TTL is a product decision tied to retry windows. Payment APIs often keep keys for 24 hours. Booking systems may need 72 hours if clients reconcile overnight batch jobs.

Redis keys with SET key value EX 86400 NX give cheap atomic claims. The NX flag prevents two concurrent requests from both believing they are first. Pair Redis with a Laravel scheduled command that purges expired MySQL rows weekly.

Idempotency Record LifecycleprocessingLock acquiredcompletedResponse cachedreplaySame key returnsTTLFailure PathsHandler throwsMark failed, allow retryConcurrent duplicate409 until completedPurge expired rows with a nightly scheduled job
Idempotency records move from processing to completed, then expire after your configured TTL window.

Concurrency and race conditions

Two simultaneous requests with the same key are the hardest edge case. A unique database index on (user_id, key_hash, route) turns the second insert into a catchable exception. Your middleware should then re-read the row and either wait briefly or return 409.

Do not rely on application-level locks alone under PHP-FPM. Multiple workers can enter the same code path at once. Database uniqueness or Redis SET NX is the reliable guard.

What to cache in the stored response

Store enough to reconstruct the HTTP response faithfully: status code, headers that matter (Location for 201 Created), and JSON body. Strip volatile fields like processed_at if they would confuse clients comparing retries, or keep them identical by freezing timestamps at first write.

For large binary responses, store a pointer to an object in S3 rather than the full payload. Replay a redirect or a lightweight JSON envelope that references the stored asset.

What is the difference between idempotency keys and duplicate detection?

Teams often confuse the two patterns. Duplicate detection asks, "Have we seen this business event before?" Idempotency asks, "Have we already executed this specific client attempt?"

Duplicate detection might use a natural key such as gateway_transaction_id from eSewa or Khalti. That works for webhook ingestion. It does not help when your own mobile app retries POST /orders before the gateway ID exists.

ApproachKey sourceBest forLimitation
Idempotency-Key headerClient-generated UUIDUser-initiated POST retriesRequires client SDK discipline
Natural business keyServer or partner IDWebhook deduplicationUnavailable before first success
Idempotent HTTP methodsResource URI + verbPUT upserts, DELETEDoes not cover POST creates
Database unique constraintColumn like reference_noInternal batch jobsExposes DB errors to clients

Use both layers on payment-heavy systems. The idempotency key protects the HTTP boundary. A unique index on payment_reference protects the ledger even if a buggy client omits the header. OWASP API security checks flag broken object-level authorization when keys are scoped incorrectly, so test cross-user replay explicitly.

Two Layers of Duplicate PreventionIdempotency KeyClient UUID on POSTHTTP retry safety24h TTL typicalBusiness Unique Keygateway_txn_idWebhook dedupePermanent ledger guardPayment Endpoint StackMiddleware replay + DB unique on referenceQueue job idempotent by design
Combine client idempotency keys with server-side unique business keys for payment-grade reliability.

How do major payment APIs implement idempotency keys?

Stripe's idempotent requests documentation defines the reference behaviour most teams copy. Send Idempotency-Key on POST requests. Stripe stores the result for 24 hours. Replays return the same status and body, including error responses from the first attempt.

That last detail matters. If the first request failed validation, retries must replay the same 400 response until the client generates a new key. Do not silently succeed on retry after a failed first attempt unless your API contract explicitly allows correction flows.

SDK and client responsibilities

Your public SDK should generate the key once per high-level method call and attach it automatically. Document that users must not reuse keys across different operations. SDK design for public APIs should expose an optional override for testing, but default to safe behaviour.

When integrating outbound calls to Stripe, PayPal, eSewa, or Khalti, pass your own idempotency key upstream if the gateway supports it. On Nepal Gift Card–style Laravel platforms, I forward a deterministic key derived from the internal order UUID so both sides dedupe consistently.

Testing idempotent endpoints

Automated tests should fire two identical requests and assert one database row. Also test payload mismatch, missing header, expired key, and parallel requests. API contract testing with Pact helps ensure clients send the header your middleware expects.

Load testing with rate limiting and throttling enabled verifies that 429 responses do not corrupt idempotency state. A throttled first attempt should leave the record in processing or roll back cleanly.

Production Idempotency ChecklistRequire header on POSTScope per user + routeHash request bodyUnique DB constraintReplay exact responseTTL + nightly purgeMonitor: duplicate_key_replays, conflict_409_rateAlert spikes may indicate client bugs or attack attempts
Production-ready API idempotency keys need header rules, body hashing, atomic locks, and observability.

How do you document and version idempotency behaviour?

Publish idempotency rules in your OpenAPI document under each protected operation. State header name, max length, TTL, and error codes. Link to a worked example with curl. API documentation with Redoc and Swagger UI renders those notes where integrators actually read them.

When you ship API versioning, do not change idempotency semantics silently. If v2 alters which routes require keys, call that out in your deprecation guide. Clients cache SDK behaviour for years.

Queue workers need the same guarantees. If POST /orders dispatches a job, pass the idempotency key into the job payload. The worker should skip processing when the order row already exists. I use this pattern on booking systems like trek management platforms where confirmation emails must not send twice.

For webhook endpoints, store the provider's event ID with a unique index. Return 200 on duplicates so the sender stops retrying. Combine that with internal idempotency when your handler creates downstream POST calls to other services.

Redis 8.10 memory is cheap relative to duplicate refunds. Still, cap stored response bodies. A 2 MB JSON replay bloats cache and slows lookups. Truncate or externalise heavy fields.

Observability closes the loop. Log idempotency hits at info level, conflicts at warn. Dashboard replay ratio alongside custom rate-limit keys. A sudden spike in 409 responses often means a mobile release regressed retry logic.

Security review belongs in the same pass. Keys must not be guessable sequential integers. Treat predictable keys like missing auth. Pair idempotency with token authentication so anonymous callers cannot pollute your key store.

On client portals with document payments, idempotency also protects support staff. Admin tools that "retry payment" must send the same key as the original attempt, not mint a fresh one each click.

Enterprise integrations sometimes ask for idempotency in the JSON body instead of a header. That works for JSON-only clients but breaks file uploads. Standardise on headers unless a legacy constraint forces otherwise. Enterprise application projects benefit from a written integration contract that names the header explicitly.

Composer 2.10 and Laravel 13 migrations make the schema portion straightforward. The hard part is defining failure semantics and testing concurrent retries. Budget time for that in estimates. A middleware skeleton takes an hour. Race-safe behaviour takes a day.

Reference the IETF draft on Idempotency-Key header standardisation when writing public docs. Aligning with the emerging standard reduces friction for integrators who already send Stripe-style headers.

Finally, read Laravel API best practices alongside this guide. Idempotency complements pagination, validation, and cursor pagination as baseline API hygiene, not a special-case patch.

Key Takeaways

  • Require a client-generated Idempotency-Key header on every POST that creates money, inventory, or irreversible records.
  • Scope keys by authenticated user, route, and hashed request body; reject reuse with different payloads.
  • Use a unique database constraint or Redis SET NX to win concurrent duplicate requests safely under PHP-FPM.
  • Replay the exact stored status and JSON body, including first-attempt errors, for the TTL window.
  • Layer natural business unique keys underneath for webhook and ledger protection.
  • Document TTL, error codes, and SDK behaviour in OpenAPI, then test parallel retries in CI.

People Also Ask

How long should an idempotency key be stored?

Twenty-four hours covers most client retry and reconciliation windows. Payment processors like Stripe use that default. Extend to 72 hours if batch jobs replay overnight, and purge expired rows with a scheduled task so tables stay bounded.

Should GET requests use idempotency keys?

No. GET is already idempotent by HTTP semantics. Adding keys to safe methods adds noise without benefit. Focus keys on POST, and selectively on PATCH when partial updates create side effects like fees.

What HTTP status code should a replay return?

Return the original status code and body from the first completed request. Do not convert a replay into 200 OK if the first response was 201 Created with a Location header. Clients depend on identical outcomes.

Can the same idempotency key be used for different endpoints?

Only if your server scopes keys per route, which is recommended. The same UUID on POST /orders and POST /refunds should be treated as two independent records, preventing accidental cross-route replay.

Ship retry-safe APIs with confidence

This API Idempotency Keys Implementation Guide gives you the header contract, Laravel middleware pattern, storage choices, and testing checklist production systems need. Start with payment and booking endpoints, add observability, then roll the middleware across write routes. If you want help hardening a Laravel or Symfony API for Nepal payments and international gateways, contact us about API implementation or explore custom software development and ongoing support and maintenance for the systems you already run.

Frequently Asked Questions

A client-supplied unique token sent with POST or PATCH retries. The server stores the first response and replays it instead of executing the operation again.

Use Idempotency-Key, following Stripe's convention. Pick one header name and document it in your OpenAPI spec — never accept both silently.

Payment APIs typically retain keys for 24 hours. Booking systems may need 72 hours when clients reconcile overnight batch jobs.

HTTP GET, PUT, and DELETE are idempotent by design — repeating them should not change server state unexpectedly. POST creates new resources and is not idempotent by default, so a network timeout plus client retry can create two orders or two charges. Mobile apps, JavaScript fetch backoff, partner webhooks, and load balancer timeouts all trigger duplicate writes. Idempotency keys let clients retry safely because the server deduplicates by key rather than executing the business logic twice. REST API design best practices in 2026 treat this as mandatory for money-moving endpoints.

On Laravel 13 with PHP 8.3 or higher, register middleware on protected write routes. Validate the Idempotency-Key header, hash it with SHA-256, and scope lookups by authenticated user plus route. Insert a processing record inside a transaction with a unique constraint on user_id, key_hash, and route. Run controller logic once, then persist the status code and JSON body as completed. If the handler throws, delete or mark the row failed so the client can retry. Wrap business logic in database transactions when operations are themselves transactional.

Both MySQL 9.7 and Redis 8.10 work in production. Redis is faster for short TTL hot lookups and supports atomic claims via SET key value EX 86400 NX, preventing two concurrent requests from both believing they are first. MySQL provides durable audit trails valuable for compliance. Many production systems combine them: Redis for fast deduplication lookups and MySQL for long-term logs. Pair Redis TTL expiry with a Laravel scheduled command that purges expired MySQL rows weekly so your key store does not grow unbounded.

The server hashes the request body and compares it on replay. Same key with a different payload returns 422 with an error such as Key reused with different body — not a silent match to the original cached response. During development, normalise canonical JSON before hashing so mismatched whitespace does not produce false mismatches. Use a JSON formatter and linter to catch payload drift early. This prevents a client from accidentally reusing a payment key while submitting altered amounts or product quantities.

Two simultaneous requests with identical keys are the hardest edge case. Rely on a unique database index on user_id, key_hash, and route so the second insert throws a catchable exception, then re-read the row. Alternatively use Redis SET NX for atomic claims. Do not depend on application-level locks alone under PHP-FPM because multiple workers can enter the same code path simultaneously. If a record is still processing when a retry arrives, return 409 Conflict or 202 Accepted with a retry hint rather than executing the handler twice.

Duplicate detection asks whether a specific business event was seen before, often using natural keys like gateway_transaction_id from eSewa or Khalti — useful for webhook ingestion but unavailable before the first POST succeeds. Idempotency keys ask whether this specific client attempt already executed, using a client-generated UUID on POST retries. PUT upserts and DELETE by resource URI cover method-level idempotency but not POST creates. Payment-grade systems combine both: the idempotency key protects the HTTP boundary while a unique payment_reference index protects the ledger if a buggy client omits the header.

Yes. Stripe stores the result of idempotent POST requests for 24 hours and replays the same status and body on retry, including validation error responses from the first attempt. If the first request returned 400, retries with the same Idempotency-Key must replay that 400 until the client generates a new key. Do not silently succeed on retry after a failed first attempt unless your API contract explicitly defines a correction flow. This behaviour is the reference pattern most teams copy when designing their own idempotency semantics.

Generate one UUID v4 per logical operation and attach it to every retry of that same intent. Keys should be opaque strings between 16 and 255 characters. Your public SDK should create the key once per high-level method call automatically. Users must not reuse keys across different operations. Admin tools that retry payments must send the same key as the original attempt, not mint a fresh one on each click. When calling Stripe, PayPal, eSewa, or Khalti outbound, forward a deterministic key derived from your internal order UUID so both sides dedupe consistently.

Automated tests should fire two identical requests and assert only one database row is created. Also test payload mismatch, missing header, expired key, and parallel concurrent requests. API contract testing with Pact verifies clients send the expected header. Load testing with rate limiting enabled confirms 429 throttled responses do not corrupt idempotency state — a throttled first attempt should leave the record in processing or roll back cleanly. Explicitly test cross-user replay: a key reused by a different authenticated user must return 409 Conflict, not someone else's cached response.

Publish idempotency rules under each protected operation in your OpenAPI document. State the header name, maximum key length, TTL, and applicable error codes such as 400, 409, and 422. Link to a worked curl example. Render the spec with Redoc or Swagger UI where integrators actually read it. When shipping API versioning, do not change idempotency semantics silently — if v2 alters which routes require keys, document that in your deprecation guide because clients cache SDK behaviour for years.

OWASP API security checks flag broken object-level authorization when keys are scoped incorrectly. A key reused by a different user must return 409 Conflict, not replay another user's cached response. Keys must not be guessable sequential integers — treat predictable keys like missing authentication. Pair idempotency with token authentication so anonymous callers cannot pollute your key store. Store SHA-256 hashes of raw keys rather than plaintext for defence in depth. Test cross-user replay explicitly during security review alongside functional idempotency tests.

Yes. If POST /orders dispatches a job, pass the idempotency key into the job payload so the worker skips processing when the order row already exists — preventing duplicate confirmation emails on booking systems. For webhook endpoints, store the provider's event ID with a unique index and return 200 on duplicates so the sender stops retrying. Combine provider event deduplication with internal idempotency when your handler creates downstream POST calls to other services. This layered approach covers both external redelivery and internal retry paths.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: