ThisCalculator

Everyday

Laravel Payment Timeouts: Prevent Duplicate Charges

Updated · Standard formula, shown below

Laravel payment timeouts can lead to duplicate charges when a request times out but the payment provider still processes the transaction. To prevent this, you must use idempotency keys and verify payment status before charging. This article explains how to implement these safeguards in Laravel.

Background: Why Timeouts Cause Duplicate Charges

When your Laravel app sends a payment request, the provider may take longer than your HTTP client’s timeout. Your app aborts the request, but the provider might still complete the charge. If your user retries, you risk charging them twice. This is a common issue with payment gateways like Stripe, PayPal, and Braintree.

The core problem is that a timeout does not mean the payment failed—it means you don’t know the outcome. Without a mechanism to check status or make requests idempotent, retries become dangerous.

How Idempotency Keys Prevent Duplicates

An idempotency key is a unique string you send with each payment request. The provider stores it and, if you retry with the same key, returns the original response instead of processing a new charge. This ensures that even if your request times out and you retry, the customer is only charged once.

In Laravel, you can generate a UUID for each payment attempt and store it in your database. Here’s a basic example using Stripe:

$payment = Payment::create([
    'user_id' => $user->id,
    'amount' => 100,
    'idempotency_key' => (string) Str::uuid(),
]);

try {
    $charge = Stripe::charges()->create([
        'amount' => 100,
        'currency' => 'usd',
        'source' => $token,
    ], ['idempotency_key' => $payment->idempotency_key]);
} catch (Exception $e) {
    // Handle error, but do NOT retry without checking status
}

If the request times out, you can safely retry with the same key. The provider will return the original charge object, and you can update your database accordingly.

Verifying Payment Status Before Retrying

Even with idempotency keys, it’s wise to verify the payment status before retrying. Some providers may not support idempotency keys for all endpoints. In that case, you can query the provider for the charge status using a transaction ID or a reference.

For example, if you have a `payment_reference` from a previous attempt, you can call the provider’s retrieval endpoint:

$charge = Stripe::charges()->retrieve($payment->provider_id);
if ($charge->status === 'succeeded') {
    // Payment already processed, update your records
} else {
    // Retry payment with a new key
}

This approach ensures you never blindly retry a payment that might have succeeded.

Setting Timeout Values in Laravel HTTP Client

Laravel’s HTTP client allows you to set timeout and retry options. You can configure a reasonable timeout (e.g., 30 seconds) and use the `retry` method to automatically retry on specific conditions. However, be careful: automatic retries can cause duplicates if not combined with idempotency keys.

$response = Http::timeout(30)->retry(3, 100, function ($exception) {
    return $exception instanceof ConnectionException;
})->post('/charge', $data);

If you use retries, ensure your payment request includes an idempotency key. Otherwise, a retry after a timeout could create a second charge.

Common Problems and Misconceptions

Misconception: “If the request times out, the payment failed.” This is false. The provider may have processed the charge successfully. Always verify.

Problem: Using the same idempotency key for different attempts. If a payment fails and you want to retry with a different amount or method, you must use a new key. The same key with different parameters will cause an error.

Problem: Not storing the idempotency key. If you generate a key on the fly and don’t persist it, you can’t reuse it on retry. Store it with the payment record.

Comparing Approaches: Idempotency Keys vs. Status Checks

Idempotency keys are the most robust solution because they prevent duplicates at the provider level. Status checks are a fallback for providers that don’t support idempotency keys. In practice, you should use both: send an idempotency key when possible, and always verify status before retrying.

Some providers (like Stripe) have built-in idempotency, while others (like PayPal) require you to use a `payment_id` returned in the initial response. Know your provider’s capabilities.

Safety and Best Practices

  • Always use HTTPS for payment requests.
  • Never log full credit card details or tokens.
  • Store idempotency keys in a unique column in your database.
  • Use database transactions when updating payment status to avoid race conditions.
  • Implement a webhook handler to sync payment status from the provider.

Webhooks are particularly important: they notify your app of payment success or failure even if your request timed out. Handle them idempotently by checking if the event has already been processed.

Frequently Asked Questions

Q: Can I use the same idempotency key for multiple attempts?
A: Yes, but only for the same payment parameters. If you change the amount or currency, you must use a new key. Using the same key with different parameters will result in an error.

Q: What if my provider doesn’t support idempotency keys?
A: You can still prevent duplicates by storing a unique reference (like a transaction ID) and checking its status before retrying. Some providers allow you to pass a `merchant_transaction_id` that they use for deduplication.

Q: How long should I wait before retrying a timed-out payment?
A: It depends on your provider. A common practice is to wait a few seconds and then verify status. If you use automatic retries, set a reasonable delay (e.g., 100ms, 500ms) and a max of 3 attempts.

Q: Should I use webhooks for payment confirmation?
A: Yes. Webhooks are the most reliable way to know the final status of a payment. They are triggered by the provider and don’t depend on your HTTP request completing.

Q: What happens if the webhook also times out?
A: Webhooks are sent by the provider to your endpoint, so your server isn’t making a request. If your endpoint is down, the provider will retry the webhook after a delay. Ensure your webhook handler is idempotent.

Related

Method: the standard formula for this calculation, rounded as each result states. See how the calculators work or report an error.