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 EX for 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