Contents

Payment idempotency means a repeated request for the same payment never charges twice, and a repeated notification from the provider never changes the order status twice. Three things deliver it: an idempotency key on every request to the payment provider, unique constraints in the database, and webhook handling that tolerates duplicates and out-of-order events.

Here is where double charges come from and how to build a payment flow that has none.

Why do double charges happen?

Double charges happen because on a network any request can be sent again: a user clicks twice, a client times out and retries, a background job crashes after a successful payment and restarts. If the system treats each such request as new, the money is taken again.

Typical sources of repeats:

  • a double click on "Pay" or a page refresh;
  • a mobile app retrying after a connection drop;
  • a background job restarting after a failure, after it already called the payment API;
  • the payment provider resending a webhook when it did not get a quick 2xx response;
  • webhooks arriving in a different order from the events themselves.

All of these are normal — they cannot be "fixed", only accounted for in the architecture.

What is an idempotency key?

An idempotency key is a unique operation identifier the client sends with the request. The server stores the key and the result: if a request with the same key arrives again, it returns the stored result instead of executing again. Many payment providers support such a key in a request header.

How to set it up on your side:

  1. Create the key once per operation — for example, when a payment attempt for an order is created — and store it.
  2. Every repeat of that operation, including background job retries, uses the same key.
  3. On the server, a keys table with a unique index stores the status and result.
  4. If a request with the key is still running, a repeat gets an "in progress" response rather than starting a second run.
Where a key is needed What it protects against Who creates the key
Create-payment request to the provider A second charge on retry Your backend, one per payment attempt
Your payment API for web and mobile Double clicks and client retries The front end or mobile app
Webhook processing Handling the same event twice The provider — its event ID
Background jobs around payment Duplicates when a job restarts The order or payment ID

A bank card on a laptop keyboard Photo: Unsplash

How should payment webhooks be handled?

Handle a webhook like this: verify the signature, store the event under its unique ID, respond 200 quickly and only then run the business logic in the background. A repeated event with the same ID is simply skipped.

public function handle(Request $request, PaymentProvider $provider): Response
{
    // 1. Signature: the event really comes from the provider
    $event = $provider->verifyWebhook($request->getContent(), $request->header('Signature'));

    // 2. Unique event ID — a repeat will not create a second record
    $stored = PaymentEvent::query()->insertOrIgnore([
        'provider_event_id' => $event->id,
        'type'              => $event->type,
        'payload'           => $request->getContent(),
        'created_at'        => now(),
    ]);

    // 3. A new event goes to the queue; the provider gets an answer at once
    if ($stored) {
        ProcessPaymentEvent::dispatch($event->id)->afterCommit();
    }

    return response()->noContent();
}

Event order and status transitions

Webhooks can arrive out of order: a "refund" before the "payment succeeded". So order status changes go through a state machine with allowed transitions: "paid" can become "refunded", but never go back to "awaiting payment". An invalid transition is logged and not applied, and when in doubt the current status is fetched from the provider's API.

Which database constraints protect against duplicates?

Unique constraints at the database level protect against duplicates — they work even when checks in code ran in parallel and both concluded the record did not exist yet. A "create if not exists" check without a unique index does not protect against a race between two simultaneous requests.

What to enforce with unique indexes:

  • the provider's event ID in the webhooks table;
  • the idempotency key in the operations table;
  • the provider's payment ID in the payments table;
  • one successful payment per order — with a partial unique index on status.
CREATE UNIQUE INDEX payments_one_success_per_order
    ON payments (order_id)
    WHERE status = 'succeeded';

How do you verify the payment flow is reliable?

Verify reliability with tests for repeats and with regular reconciliation against the provider. Tests reproduce double clicks, repeated webhooks and reordered events; a daily reconciliation catches discrepancies the logic missed.

Checklist:

  1. Test: two simultaneous payment requests for one order produce one charge.
  2. Test: one webhook delivered three times produces one status change and one email.
  3. Test: a refund webhook before the payment webhook ends in the correct status.
  4. A daily reconciliation of your payments against the provider's report.
  5. Alerts on discrepancies and on webhooks that failed to process.

We covered general rules for reliable background jobs — retries, timeouts, idempotency — in job queues in web applications, and API design in how to design a REST API.

In short

  • Repeated requests and webhooks are normal; the architecture must handle them.
  • Send an idempotency key with every create-payment request and reuse it on retries.
  • Webhooks: verify the signature, store by unique ID, respond 200, process in the background.
  • Change statuses through a state machine and block duplicates with unique indexes.
  • Test repeats and reordering, and reconcile with the provider daily.

FAQ

  • How long should idempotency keys be kept?

    At least as long as the window for possible repeats: client retries, background jobs and provider webhooks. That is usually a day to a few days; payment records themselves are kept as long as accounting requires.

  • What if a webhook never arrives?

    Do not rely on webhooks alone: for stuck payments a background job periodically queries the provider's API for the status. A daily reconciliation catches whatever remains.

  • Is disabling the pay button after a click enough?

    No, it only improves the UI. Repeats happen without clicks too — through networks, retries and job restarts — so protection must live on the server and in the database.