Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Idempotency Keys: How to Stop Charging Users Twice

About Post

The user taps "Pay". Nothing seems to happen. The spinner keeps spinning, the signal is weak, so they tap again. Both requests reach your server. Both succeed.

Your code did exactly what it was told, twice. And now someone has paid their rent two times and is about to call your support team, not in a good mood.

Disabling the button helps, but it doesn't solve this. The real fix is an old, simple idea that payment providers have relied on for years: idempotency keys.

Why retries are unavoidable

Double requests aren't only caused by impatient fingers. They come from everywhere:

  • A mobile app retries automatically after a timeout, but the first request actually succeeded. The response just never made it back.
  • A load balancer or HTTP client retries on a dropped connection.
  • A queued job fails halfway and runs again.
  • A user refreshes the page and the browser offers to resubmit the form.

The nasty case is the first one. From the client's point of view, "the request failed" and "the request succeeded but I never heard back" look exactly the same. So the client has to retry, and the server has to be able to handle the same request arriving twice.

The idea in one paragraph

An operation is idempotent if doing it twice has the same effect as doing it once. GET, PUT and DELETE are meant to be idempotent. POST /payments is not: every call creates a new payment.

An idempotency key makes a POST behave idempotently. The client generates a unique key for one intent ("pay this invoice, this time") and sends it with the request. The server remembers the key and the result. If the same key arrives again, the server doesn't do the work again. It simply returns the result it already has.

Think of the ticket number at a takeaway counter. If you ask twice about order 47, you don't get two meals. They check the ticket and tell you where order 47 is.

The client side

The key must be created once per user action, and reused for every retry of that action:

const idempotencyKey = crypto.randomUUID(); // once, when the user taps Pay

async function submitPayment(payload) {
  return fetch('/api/payments', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey,
    },
    body: JSON.stringify(payload),
  });
}
// Retries call submitPayment() again with the SAME key

The classic mistake is generating the key inside the function that sends the request. Then every retry gets a fresh key and you've protected nothing.

The server side: a table with a unique constraint

The database is your best friend here, because a unique index is enforced even when two requests arrive at the same millisecond. Application-level checks like "does this key exist? if not, insert it" have a gap between the check and the insert. A unique constraint doesn't.

Schema::create('idempotency_keys', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id');
    $table->string('key');
    $table->string('request_hash', 64);
    $table->unsignedSmallInteger('status')->nullable();
    $table->longText('body')->nullable();
    $table->timestamps();

    $table->unique(['user_id', 'key']);
});

Keys are scoped per user, so two users can't collide, and one user can't replay someone else's response. The request_hash lets us detect a key being reused for a different request. A null status means "still being processed".

A Laravel middleware sketch

Here's a simplified middleware. The trick is to claim the key by inserting the row first, and let the unique constraint reject duplicates:

public function handle(Request $request, Closure $next): Response
{
    $key = $request->header('Idempotency-Key');
    if (! $key) {
        return $next($request);
    }

    $hash = hash('sha256', $request->getContent());

    try {
        $record = IdempotencyKey::create([
            'user_id' => $request->user()->id,
            'key' => $key,
            'request_hash' => $hash,
        ]);
    } catch (UniqueConstraintViolationException) {
        return $this->replay($request, $key, $hash);
    }

    $response = $next($request);

    $response->getStatusCode() >= 500
        ? $record->delete() // server error: let the client retry
        : $record->update(['status' => $response->getStatusCode(), 'body' => $response->getContent()]);

    return $response;
}

And the replay, which handles the three possible situations for a key we've seen before:

private function replay(Request $request, string $key, string $hash): Response
{
    $record = IdempotencyKey::where('user_id', $request->user()->id)
        ->where('key', $key)
        ->firstOrFail();

    if ($record->request_hash !== $hash) {
        abort(422, 'This idempotency key was used for a different request.');
    }

    if ($record->status === null) {
        abort(409, 'A request with this key is still being processed.');
    }

    return response($record->body, $record->status)
        ->header('Content-Type', 'application/json')
        ->header('Idempotent-Replayed', 'true');
}

Attach it only to the routes that need it, after authentication: ->middleware(['auth:sanctum', EnsureIdempotency::class]). The UniqueConstraintViolationException class lives in Illuminate\Database and is thrown by Laravel when an insert hits a unique index.

The details that matter in production

This is a sketch, not a library. Before using something like it for real money, think through these:

  • Which responses to store. Storing a 4xx (like a validation error) is usually right: the same request will fail the same way. Not storing a 5xx lets the client retry after a real server failure.
  • Crashes mid-request. If the process dies after claiming the key, the row stays "in progress" forever. Treat in-progress rows older than a few minutes as abandoned.
  • Expiry. Keys don't need to live forever. A scheduled command that deletes old rows keeps the table small. Retries happen within minutes or hours, not months.
  • Hashing the request. Hashing the raw body is simple but strict; the same JSON with keys in a different order hashes differently. That's usually fine, because a well-behaved client resends exactly the same bytes.
  • Pass the key downstream. If you call a payment gateway that supports idempotency keys (Stripe is the well-known example), send it a key too. Your middleware protects your API; the gateway's key protects the actual charge.

Belt and braces: idempotency keys protect the request. Unique constraints on your business data (for example, one payment per gateway transaction reference) protect the database. Use both for anything involving money.

Where else this pattern pays off

Payments are the obvious case, but the same idea fits anywhere a duplicate is costly: submitting a maintenance request from a flaky mobile connection, creating an order, sending an SMS, or processing an incoming webhook (where the provider's event ID works as a natural idempotency key).

The short version

  • Clients will retry. Design for it.
  • One key per user intent, reused on every retry.
  • Claim the key with a unique constraint, not a check-then-insert.
  • Same key, same request: replay. Same key, different request: reject. Still running: 409.

Have you ever had to clean up after a double submission in production? What was the endpoint that taught you to care about idempotency?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close