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.
Table of Contents
- 1. Why POST requests without idempotency are dangerous
- 2. What an idempotency key is
- 3. Server side storage: fingerprint and response cache
- 4. The flow of an idempotent request in detail
- 5. Building an idempotency middleware in PHP
- 6. Conflict detection: different body, same key
- 7. Expiration and cleaning up expired entries
- 8. Idempotency under concurrent requests
- 9. Without idempotency vs. with idempotency keys compared
- 10. Summary
- 11. FAQ
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.