How to Handle Payment Webhooks in Laravel Without Double-Processing

Payment gateways deliver webhooks at least once, not exactly once. If your handler credits an order every time it is called, a retry means a double credit. Here is a pattern that stays correct.

6 min readBy Saad Ali KhanOct 2026
How to Handle Payment Webhooks in Laravel Without Double-Processing

Why webhooks arrive more than once

Most gateways retry a webhook when your server does not answer with a 2xx status quickly enough. Network timeouts, a deploy in the middle of a request, or a slow database can all make a successful delivery look like a failure to the sender. The sender then tries again, so your code sees the same event two or three times. Treat duplicates as normal, not as an edge case.

Step 1: verify the signature

Never trust a webhook just because it hits your URL. Compute the HMAC of the raw request body with your signing secret and compare it in constant time. Read the raw body, not re-encoded JSON, because re-encoding can change the bytes.

$payload   = $request->getContent();
$signature = (string) $request->header('X-Signature');
$expected  = hash_hmac('sha256', $payload, config('services.gateway.secret'));

abort_unless(hash_equals($expected, $signature), 401);

The header name and hashing scheme differ per gateway, so follow your provider's documentation. The shape of the check stays the same.

Step 2: record the event ID under a unique index

Create a table that stores every processed event ID and put a unique index on it. The database, not your application code, then guarantees that an event is only accepted once, even when two requests arrive at the same moment.

Schema::create('webhook_events', function (Blueprint $table) {
    $table->id();
    $table->string('provider_event_id')->unique();
    $table->string('type');
    $table->timestamps();
});

A check like "if the event exists, skip it" followed by an insert is not safe on its own. Two parallel requests can both pass the check. The unique index closes that gap.

Step 3: process and record in one transaction

try {
    DB::transaction(function () use ($event) {
        WebhookEvent::create([
            'provider_event_id' => $event['id'],
            'type'              => $event['type'],
        ]);

        $this->applyEvent($event); // mark order paid, credit wallet, etc.
    });
} catch (UniqueConstraintViolationException $e) {
    // Already handled. Fall through and return 2xx.
}

return response()->noContent();

UniqueConstraintViolationException is available in current Laravel versions. On older ones, catch QueryException and check the error code. Because the event row and the business change commit together, a crash halfway through leaves neither behind, and the gateway's next retry processes the event cleanly.

Step 4: answer fast, work later if needed

Return a 2xx response as soon as the event is safely stored. If the real work is slow, such as sending emails or calling other APIs, dispatch a queued job from inside the handler and let the queue worker do it. Make that job idempotent too, since queues can also run a job twice.

Also handle out-of-order events

Events can arrive in a different order than they happened. Do not assume payment.succeeded always arrives after payment.created. Compare the state you hold with the state the event implies, and ignore events that would move an order backwards.

Checklist

  • Signature verified against the raw body, with a constant-time compare
  • Event ID stored under a unique database index
  • Business change and event record committed in one transaction
  • 2xx returned quickly, slow work moved to idempotent queued jobs
  • A test that sends the same event twice and asserts a single credit

That last test is the cheapest insurance you can buy. If you want payment flows built and tested this way, see my payment gateway integration service or the payment integrations case study.

Original article written by Saad Ali Khan. Code samples are illustrative and free to adapt in your own projects.

Want this done right in your project?

Send a short description and I'll reply by email with questions or a suggested approach.