DEV Community

Cover image for Webhooks arrive twice and out of order: making payment callbacks duplicate-safe in Laravel
osama
osama

Posted on

Webhooks arrive twice and out of order: making payment callbacks duplicate-safe in Laravel

In my last post I wrote about proving a payment callback is real: verify the signature, then ask the gateway for the status instead of trusting the payload.

That solves "who sent this?". It doesn't solve the next problem: a perfectly valid callback can arrive twice, late, or before the event it depends on.

This isn't theory. Tabby's webhook docs say it directly:

"the same event may occasionally arrive twice"

"the delivery order is not guaranteed. A closed event can arrive before the authorized one."

Tabby also retries a failed delivery up to 4 more times. Other gateways have their own retry rules, so I treat every gateway's callbacks as "at least once, in any order" and design the handler for that.

On a multi-branch e-commerce and POS platform I work on (40+ branches, 2M+ transactions a month), the callback handlers are built around four guards. Here is the general approach, with a simplified order/items/stock example.

The scenario

A customer pays for an order with three items. The gateway sends a "paid" callback. Your handler:

  1. marks the order as paid,
  2. decrements stock for each item,
  3. sends a confirmation (email, SMS, push).

Now the same callback arrives twice, 40 ms apart, on two different PHP workers. If nothing stops it, stock drops twice and the customer gets two confirmations. On a busy day, that's a stock count nobody trusts.

Why the obvious check isn't enough

Most handlers start like this:

$order = Order::where('reference', $reference)->firstOrFail();

if ($order->status === OrderStatus::Paid) {
    return response()->noContent(); // already handled
}

$order->markAsPaid();
$this->decrementStock($order);
Enter fullscreen mode Exit fullscreen mode

It looks safe, and it works when callbacks arrive seconds apart. But when two requests arrive together, both read status = pending before either one writes paid. Both pass the check. This is a classic check-then-act race: the check and the write are two separate steps, and the other request runs between them.

Guard 1: a transaction and a row lock

The fix is to make "read the status, decide, write" one step that only one request can run at a time for the same order. In Laravel that means a transaction plus lockForUpdate():

use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($reference, $remote) {
    $order = Order::where('reference', $reference)
        ->lockForUpdate()
        ->firstOrFail();

    // Re-check INSIDE the lock. The second request waits here
    // until the first one commits, then sees the new status.
    if ($order->status === OrderStatus::Paid) {
        return;
    }

    $order->markAsPaid($remote);
    $this->decrementStock($order);
}, attempts: 3);
Enter fullscreen mode Exit fullscreen mode

What changed:

  • lockForUpdate() adds FOR UPDATE to the SELECT. The second request blocks on that row until the first transaction commits or rolls back.
  • The status check now runs after the lock is taken, so the second request sees paid and stops.
  • If anything throws, the transaction rolls back and the lock is released. Laravel's docs recommend exactly this pairing: pessimistic locks inside a transaction.
  • attempts: 3 retries the closure if the database reports a deadlock.

The trade-off: a lock makes requests for the same order wait for each other. Requests for different orders don't block each other, because the lock is on one row. For payment callbacks that's a good deal. Keep the locked section short, though: no HTTP calls, no emails, no PDF generation inside it.

That's also why the gateway re-check from my last post happens before the transaction:

// Outside the lock: never hold a row lock while waiting on a network call.
$remote = $gateway->fetchPayment($reference); // status, amount, currency

DB::transaction(function () use ($reference, $remote) {
    // ...lock, re-check, write
}, attempts: 3);
Enter fullscreen mode Exit fullscreen mode

Guard 2: lock the stock too

Stock has the same race, with one difference: two different orders can compete for the same product. A customer online and a cashier in a branch can both sell the last unit in the same second.

So inside the same transaction, I lock the product rows before touching stock:

private function decrementStock(Order $order): void
{
    $items = $order->items;

    // Lock every product in this order, in a fixed order (by id),
    // so two transactions never wait on each other in a circle.
    $products = Product::whereIn('id', $items->pluck('product_id'))
        ->orderBy('id')
        ->lockForUpdate()
        ->get()
        ->keyBy('id');

    foreach ($items as $item) {
        $product = $products[$item->product_id];

        if ($product->stock < $item->quantity) {
            // Paid, but the stock is gone. Don't throw: a rollback here
            // would fail the callback and the gateway would retry forever.
            // Flag it so a human decides: refund, backorder, or transfer.
            $order->flagForReview('insufficient_stock');
            continue;
        }

        $product->decrement('stock', $item->quantity);
    }
}
Enter fullscreen mode Exit fullscreen mode

Two details matter here:

  • Lock in a consistent order. If transaction A locks product 5 then product 9, while transaction B locks 9 then 5, each can end up waiting for the other. That's a deadlock. Sorting by id before locking avoids that circle. attempts: 3 is the safety net if one still happens.
  • Don't throw for a business problem. An exception rolls everything back, the handler returns a 500, and the gateway retries a callback that will never succeed. Record the problem and let a person handle it.

Or let one UPDATE do the check

For a single counter like stock, there's a lighter option: put the check inside the UPDATE itself, so reading, checking and writing happen in one SQL statement.

$affected = Product::where('id', $item->product_id)
    ->where('stock', '>=', $item->quantity)
    ->decrement('stock', $item->quantity);

if ($affected === 0) {
    // The condition failed: someone else took the stock first.
    $order->flagForReview('insufficient_stock');
}
Enter fullscreen mode Exit fullscreen mode

This runs as UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?. The database applies it atomically, so two concurrent requests can't both pass the stock >= ? check for the last unit. One updates the row, the other gets 0 affected rows. No explicit lock, no window between reading and writing.

The rule behind it: never update based on a value you read earlier unless the UPDATE checks that value is still true. The WHERE stock >= ? condition does that for stock. The version column in Guard 4 below does the same for a whole row.

When to use which: a conditional UPDATE is great for one counter. When you need to read several rows, make a decision across them and write several changes together, the transaction plus lockForUpdate() is clearer.

Guard 3: allow only forward status changes

Duplicates are one problem. Order is another. If "captured" arrives before "authorized", a naive handler processes "captured", then gets "authorized" a few seconds later and moves the order backwards.

I give the payment status explicit allowed transitions and ignore anything that isn't one of them:

enum PaymentStatus: string
{
    case Pending = 'pending';
    case Authorized = 'authorized';
    case Captured = 'captured';
    case Failed = 'failed';
    case Refunded = 'refunded';

    public function canTransitionTo(self $next): bool
    {
        return in_array($next, match ($this) {
            self::Pending => [self::Authorized, self::Captured, self::Failed],
            self::Authorized => [self::Captured, self::Failed],
            self::Captured => [self::Refunded],
            self::Failed, self::Refunded => [],
        }, true);
    }
}
Enter fullscreen mode Exit fullscreen mode

And inside the lock:

if (! $order->payment_status->canTransitionTo($remote->status)) {
    return; // duplicate or late event: nothing to do, acknowledge it
}
Enter fullscreen mode Exit fullscreen mode

Now a late "authorized" after "captured" is simply ignored, and a duplicate "captured" is ignored too, because Captured → Captured isn't an allowed transition. One rule covers both problems.

Each gateway names its statuses differently (Tabby, for example, sends lowercase statuses in webhooks and uppercase ones from its retrieve endpoint), so map them to your own enum at the edge and keep the rules in one place.

Re-querying the gateway helps here as well: when a late event arrives, you ask the gateway for the current status, not the status in that old event. The transition rule then stops anything that would move the order backwards.

Guard 4: a version column for writers that don't lock

The lock protects the callback handler from other callback handlers. But callbacks aren't the only code that writes to an order. An admin panel action, a scheduled job, or a support tool might load the order, wait a little, and save it back. If that code doesn't take the lock, it can overwrite the status your callback just set.

A version column catches this. Every write increments it, and every write only succeeds if the version is still the one you read:

$updated = Order::where('id', $order->id)
    ->where('version', $order->version)
    ->update([
        'payment_status' => $next,
        'version' => $order->version + 1,
    ]);

if ($updated === 0) {
    // Someone changed this order after we read it.
    // Reload and decide again; never overwrite blindly.
    throw new StaleOrderException($order->id);
}
Enter fullscreen mode Exit fullscreen mode

This is optimistic locking: it doesn't block anyone, it just refuses to save stale data. Row locks (pessimistic) protect the hot path where conflicts are likely: two callbacks for the same order. The version column (optimistic) protects the rest of the system where conflicts are rare but expensive.

Side effects after commit

Emails, SMS and push notifications can't be rolled back. If you send them inside the transaction and the transaction then fails, the customer gets "payment confirmed" for an order that isn't confirmed.

Laravel can hold a queued job until the transaction commits:

SendOrderConfirmation::dispatch($order)->afterCommit();
Enter fullscreen mode Exit fullscreen mode

If the transaction rolls back, the job is discarded. You can also turn this on for a whole queue connection with the after_commit option.

What to return to the gateway

Your response code tells the gateway whether to retry, so be deliberate:

  • 200 when you processed the event, or deliberately ignored it as a duplicate or late event. Tabby's docs say to acknowledge with 200 only in those two cases.
  • Non-200 when you couldn't process it yet and want a retry. Tabby gives the example of a callback for a payment you haven't saved yet: respond with something like 404 and the retry arrives after your order exists. Conveniently, firstOrFail() produces a 404 in Laravel.
  • Don't be slow. Tabby times out after 1 minute and counts that as a failure. Short locked sections and queued side effects keep you well under it.

The checklist

  1. Verify the callback and re-query the gateway (previous post). Do the HTTP call before the transaction.
  2. Wrap "read, decide, write" in DB::transaction(..., attempts: 3).
  3. lockForUpdate() the order row and re-check the status inside the lock.
  4. Lock stock rows in a fixed order, or use one conditional UPDATE (WHERE stock >= ?); flag business problems instead of throwing.
  5. Allow only forward status transitions; ignore duplicates and late events with a 200.
  6. Add a version column so writers that don't lock can't overwrite newer data.
  7. Dispatch emails and notifications afterCommit().

None of this is exotic. It's standard database tooling, used deliberately. The point is to design for "at least once, in any order" from day one, instead of finding out from a customer who got charged once and confirmed twice.

How do you handle this in your projects: row locks, a unique table of processed event IDs, or something else? I'd like to hear what's worked for you.


Sources: Tabby webhooks · Laravel pessimistic locking · Laravel database transactions · Laravel jobs & database transactions

Top comments (0)