Idempotency Keys for PHP APIs: Safe POST and PUT Requests
AI generated
<?php
8.4
PHP · API Design · Idempotency · Payments
Idempotency Keys for PHP APIs
Safe POST and PUT requests under retries

A network error after a successful payment leads, without protection, to the client repeating the same request and triggering a second payment. This article shows how idempotency keys are implemented in PHP APIs to reliably prevent exactly this problem, including request fingerprinting, response caching, and protection against race conditions.

18 min read Idempotency Key · Retry Safety · Race Conditions PHP 8.4

1. Why POST requests without idempotency are dangerous

HTTP defines GET, PUT, and DELETE as idempotent, a repeated call should produce the same state as a single call. POST is explicitly not idempotent, every call creates a new resource by definition. This is exactly what becomes a problem in payment flows and order processes: if the network connection fails after successful server side processing but before the response is received, the client does not know whether the request was actually processed. An automatic retry mechanism then repeats the POST request and triggers a second, unintended payment or order.

An idempotency key solves exactly this problem: the client generates a unique key before the first attempt and sends it identically with every retry of the same logical operation. The server uses this key to recognize whether the operation has already been executed, and returns the original response on a repeat instead of executing the operation a second time.

This pattern is especially established in payment APIs like Stripe and PayPal, but applies to every PHP API where a repeated POST or PUT request would have real, irreversible consequences: orders, shipping notifications, email delivery, or bookings. The idempotency key effectively makes these operations safe against network errors and automatic retries.

2. What an idempotency key is

Technically, an idempotency key is a unique string generated by the client, typically a UUID version 4, sent identically in the Idempotency-Key header with every attempt of the same logical request. It is important that the client generates the key once per logical operation, not per HTTP request: on a retry of the same order attempt, the key stays the same, for a new, independent order, a new key is generated.

The server treats the idempotency key as a unique identifier for a specific processing attempt. When a key arrives for the first time, the operation is executed normally and the result is stored linked to the key. If the same key arrives again, the server directly returns the stored result without executing the underlying business logic again.

3. Server side storage: fingerprint and response cache

Server side storage for an idempotency key needs at least three fields: the key itself, a fingerprint of the request body, and the stored response including status code. The fingerprint, usually a hash over method, path, and body, serves conflict detection: the same key with a different body indicates a client bug or an attempt to reuse the key for a different operation.

A relational table for idempotency keys is sufficient in most PHP projects, alternatively Redis with an expiration works well if a Redis cluster is already available for other purposes. A unique index on the key column is important so concurrent requests with the same key cannot be inserted into the table twice.


-- MySQL/MariaDB table for storing idempotency keys
CREATE TABLE idempotency_keys (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    idempotency_key VARCHAR(64) NOT NULL,
    request_fingerprint CHAR(64) NOT NULL,
    response_status SMALLINT UNSIGNED NULL,
    response_body MEDIUMTEXT NULL,
    status ENUM('processing', 'completed') NOT NULL DEFAULT 'processing',
    created_at DATETIME NOT NULL,
    expires_at DATETIME NOT NULL,
    UNIQUE KEY uniq_idempotency_key (idempotency_key)
);

4. The flow of an idempotent request in detail

The complete flow of an idempotent request consists of four steps: first, the server checks whether the submitted idempotency key already exists. If it does not exist, a new entry is created in the processing state to block parallel duplicates, then the actual business logic runs, and finally the result is stored in the entry and the state is set to completed.

If the key already exists in the completed state, the server immediately returns the stored response without executing the business logic again. If it exists in the processing state, that means a parallel request with the same key is still running, in this case the server should return a 409 Conflict instead of also processing the request and thereby undermining the actual protective function of the idempotency key.

5. Building an idempotency middleware in PHP

The practical implementation belongs as middleware or a pre filter in front of the actual controller logic, so every affected endpoint is automatically protected without the business logic itself needing to know anything about the idempotency check. The middleware reads the Idempotency-Key header, checks the store, and decides whether the actual request is processed or a stored response is returned.


<?php

declare(strict_types=1);

/**
 * Middleware enforcing idempotent processing for POST/PUT requests.
 */
final class IdempotencyMiddleware
{
    public function __construct(private readonly IdempotencyStore $store)
    {
    }

    /**
     * @param callable(): array{status: int, body: string} $next
     * @return array{status: int, body: string}
     */
    public function handle(?string $idempotencyKey, string $method, string $path, string $rawBody, callable $next): array
    {
        if ($idempotencyKey === null || !in_array($method, ['POST', 'PATCH'], true)) {
            return $next();
        }

        $fingerprint = hash('sha256', $method . '|' . $path . '|' . $rawBody);
        $existing = $this->store->find($idempotencyKey);

        if ($existing !== null) {
            if ($existing->fingerprint !== $fingerprint) {
                return ['status' => 409, 'body' => '{"title":"Idempotency key reused with a different request"}'];
            }

            if ($existing->status === 'processing') {
                return ['status' => 409, 'body' => '{"title":"Request with this idempotency key is still processing"}'];
            }

            return ['status' => $existing->responseStatus, 'body' => $existing->responseBody];
        }

        $this->store->markProcessing($idempotencyKey, $fingerprint);
        $result = $next();
        $this->store->complete($idempotencyKey, $result['status'], $result['body']);

        return $result;
    }
}

6. Conflict detection: different body, same key

An often overlooked aspect of idempotency keys: what happens when the same key arrives with a different request body? Without conflict detection, a client could accidentally reuse the same key for two different orders and thereby silently discard the second order, because the server returns the stored response from the first order.

The fingerprint comparison in the previous code example catches exactly this case: if the fingerprint of the new request differs from the stored fingerprint, the server responds with 409 Conflict instead of the cached response. This explicit error makes the client bug immediately visible, instead of masking it through silently incorrect behavior.

7. Expiration and cleaning up expired entries

Idempotency keys should not be stored indefinitely, a typical expiration lies between 24 hours and 7 days, depending on how long realistic client retry attempts are expected. After this period, the same key could theoretically be reused, though in practice every client generates a fresh key for each new logical operation anyway.

A regular cron job or scheduled task should remove expired entries from the idempotency table to avoid uncontrolled table growth. With Redis as the store, the built in TTL feature handles this automatically without an additional cleanup job.

8. Idempotency under concurrent requests

The most critical point of a correct idempotency implementation is protection against race conditions: if two requests with the same key arrive at exactly the same time, both must never execute the business logic. The unique index on the key column from the table schema takes on this crucial safeguard: the second INSERT attempt with the same key fails at the database level before the business logic even starts.

In PHP, this database error is caught and treated like the "key already exists in processing state" case, and the request is answered with 409 Conflict. This database level safeguard is more robust than a purely application side check, because it works correctly even with multiple parallel PHP processes or servers, without needing distributed locks.

9. Without idempotency vs. with idempotency keys compared

The following table shows the behavioral difference on a network error after successful server side processing.

Scenario Without idempotency key With idempotency key
Client retry after timeout Creates a second payment/order Returns the stored response
Two parallel requests Both get processed Only the first is processed
Accidental key reuse Not applicable 409 Conflict via fingerprint check
Implementation effort None Moderate, middleware plus storage
Suited for Read only, non critical operations Payments, orders, bookings

For every endpoint where a duplicate POST request would have real consequences, the extra implementation effort of idempotency keys is practically always justified compared to the possible fallout of duplicate payments or orders.

Mironsoft

PHP API reliability and payment processing

Reliably ruling out duplicate payments and orders?

We build idempotency middleware, conflict detection, and race condition protection for your critical PHP endpoints, production ready and without distributed locks.

Risk analysis

Reviewing critical POST/PUT endpoints for duplicate processing

Idempotency implementation

Setting up middleware, storage, and conflict detection production ready

Race condition protection

Implementing database based safeguards without distributed locks

10. Summary

Idempotency keys solve a real, often underestimated problem: without them, every network error after successful but unconfirmed processing leads to a retry that can trigger duplicate payments, orders, or bookings. Implementation in PHP needs a unique storage structure with fingerprint comparison, a middleware that runs before the actual business logic, and database based protection against race conditions via a unique index rather than application side locks.

The implementation effort for idempotency keys is manageable compared to the consequences of duplicate, critical operations. For every PHP endpoint where a repeated call would have real, irreversible consequences, an idempotency key belongs to the basic toolkit, not to the optional extras.

Idempotency Keys in PHP: The Key Points at a Glance

Core principle

Client generates a unique key once per logical operation, sends it identically on every retry.

Storage

Key, request fingerprint, and stored response in a table with a unique index.

Conflict detection

A differing fingerprint for the same key leads to 409 Conflict instead of a silently wrong response.

Race condition protection

A unique database index prevents duplicate processing on exactly simultaneous requests.

11. FAQ: Idempotency Keys for PHP APIs

1What is an idempotency key?
A unique, client generated string sent identically on every retry, so the server can detect duplicates.
2Why is POST risky without protection?
POST is not idempotent, a retry after an unnoticed success otherwise triggers a second execution.
3How does the server recognize a retry?
Via the identical idempotency key across multiple attempts.
4Same key, different body?
Fingerprint mismatch leads to 409 Conflict instead of a wrong stored response.
5How long should they be stored?
Typically 24 hours to 7 days, depending on realistic retry windows.
6How to prevent race conditions?
A unique database index blocks duplicate parallel inserts at the database level.
7Is an application check alone enough?
No, it fails with multiple processes. The database constraint is the reliable solution.
8Which endpoints need it most?
Payments, orders, bookings, and other endpoints with real consequences.
9Who generates the key?
The client, since only it knows whether it is a retry or an actually new operation.
10What does the server return during processing?
409 Conflict, to prevent duplicate processing during a still running request.