PHP cookbook · Advanced
Make a PHP payment endpoint idempotent
Prevent double charges when clients retry a POST /payments request after a timeout.
Approach
Require an Idempotency-Key header. Store the key with a request fingerprint and the eventual response in a table with a unique constraint. The first request inserts the key and processes; concurrent duplicates hit the unique constraint and get 409 (in progress) or the stored response. Pass the same key to your payment provider so their side is idempotent too.
Solution
<?php
declare(strict_types=1);
function handlePayment(PDO $pdo, string $idemKey, int $userId, array $body, PaymentGateway $gateway): array
{
$fingerprint = hash('sha256', json_encode($body));
try {
$pdo->prepare('INSERT INTO idempotency_keys (key, user_id, fingerprint, status) VALUES (?, ?, ?, ?)')
->execute([$idemKey, $userId, $fingerprint, 'processing']);
} catch (PDOException $e) {
if ($e->getCode() !== '23505') throw $e; // unique_violation (PostgreSQL)
$row = $pdo->prepare('SELECT * FROM idempotency_keys WHERE key = ? AND user_id = ?');
$row->execute([$idemKey, $userId]);
$existing = $row->fetch(PDO::FETCH_ASSOC);
if ($existing['fingerprint'] !== $fingerprint) return [422, ['error' => 'Idempotency key reused with different body']];
if ($existing['status'] === 'processing') return [409, ['error' => 'Request in progress']];
return [(int) $existing['response_status'], json_decode($existing['response_body'], true)];
}
$charge = $gateway->charge($body['amount_cents'], $body['currency'], $body['token'], idempotencyKey: $idemKey);
$response = [201, ['payment_id' => $charge->id, 'status' => $charge->status]];
$pdo->prepare('UPDATE idempotency_keys SET status = ?, response_status = ?, response_body = ? WHERE key = ?')
->execute(['done', $response[0], json_encode($response[1]), $idemKey]);
return $response;
}
Pitfalls
- Keys scoped globally instead of per user/account.
- Storing keys forever — expire them after 24–72 hours.
- Not handling crashes mid-processing (a 'processing' row that never finishes needs a timeout/recovery).
Variations
- Use Redis
SET key NX EXfor the lock and the database for the stored response. - Return the original response headers too, not just the body.
Further reading: Stripe: Idempotent requests
Reference: https://docs.stripe.com/api/idempotent_requests
More PHP recipes
- Add CSRF protection to PHP forms
- Add health check endpoints to a PHP app
- Add structured logging with request IDs in PHP
- Build a secure password reset flow in PHP
- Cache expensive queries with Redis in PHP
- Call external APIs with timeouts and retries in PHP
- Handle file uploads securely in PHP
- Implement rate limiting in a PHP API