A payout is the one place in a money product where a bug costs real money and cannot be undone. Everything around it wants to run the same transfer twice: retries, timeouts, workers, webhooks.
Three ways a payout runs twice
- The provider times out. You retry, but the first request had actually gone through.
- Two workers pick up the same job at the same moment.
- The provider sends the same webhook twice, and each one triggers a follow-up.
None of these are exotic. Each one happens in the first month of any payout system, so the fix has to be built in, not patched on.
Lock it, name it, ask first
The job is unique per payout, the row is locked while it is processed, and every transfer is sent with a unique gateway_partner_ref. A second worker simply finds the status has already changed and leaves.
<?php
class SendPayout implements ShouldQueue, ShouldBeUnique
{
public function uniqueId(): string
{
return "payout:{$this->payoutId}";
}
public function handle(): void
{
DB::transaction(function () {
$p = Payout::lockForUpdate()->findOrFail($this->payoutId);
if ($p->status !== 'pending') {
return; // someone else already moved it
}
$this->provider->send($p->gateway_partner_ref, $p);
});
}
}The last step matters most. After a timeout, the system does not blindly resend. It asks the provider whether that reference already exists, and only sends again if it does not.
“Before any retry, ask the provider whether the first attempt already went through.”
Webhooks are retries from the other side
The same idea applies in reverse. Every incoming webhook is deduplicated before it touches a balance, so a repeated message becomes a no-op instead of a second credit.
If you remember one thing: make the second attempt boring. The best payout code is the code that, when run twice, does nothing the second time.