A client pays for a subscription, then seconds later they decide to cancel it. Your payment provider sends two webhooks: "payment successful" then "subscription cancelled".
However, your server receives the webhooks in the opposite order, giving free unpaid access to the user.
Why This Happens
Webhooks aren't sent as a single sequence. They are sent separately, traveling at independent speeds and asynchronous times. This means one of the webhooks may get delayed without affecting the other one.
Delays can happen for several reasons such as a network congestion, server load, or the most common: a retry after a failure to respond.
The Naive Approach and Why It Fails
A common approach of handling webhooks is by processing them on arrival, updating the DB as soon as possible. This method has a fatal flaw.
public function handleWebhook(Request $request)
{
$event = $request->input('type');
$userId = $request->input('user_id');
if ($event === 'payment_succeeded') {
User::find($userId)->update(['active' => true]);
}
if ($event === 'subscription_cancelled') {
User::find($userId)->update(['active' => false]);
}
}
This naive implementation only works if the webhooks arrive in order. But if 'subscription cancelled' arrives first, it sets active to false, then when 'payment successful' arrives late, active is changed to true, giving free access to the user.
The Correct Implementation
A webhook should only be processed if its actually newer than the last one.
Webhook payloads include a timestamp of when the event actually occurred. The timestamp should be checked before applying any update and stored along with the user's status.
public function handleWebhook(Request $request)
{
$event = $request->input('type');
$userId = $request->input('user_id');
$eventTime = $request->input('timestamp');
$user = User::find($userId);
if ($eventTime <= $user->last_event_timestamp) {
return; // older event, ignore it
}
if ($event === 'payment_succeeded') {
$user->update(['active' => true, 'last_event_timestamp' => $eventTime]);
}
if ($event === 'subscription_cancelled') {
$user->update(['active' => false, 'last_event_timestamp' => $eventTime]);
}
}
This way, even if 'payment successful' arrives late, it will be rejected before any update can be applied since its timestamp is older than the previous 'subscription cancelled' webhook.
Storing Raw Events First
For extra safety, it's critical to save every incoming webhook to the database immediately, no matter how old the webhook is or whether it was rejected by the timestamp check or not.
This preserves a permanent record of every event exactly the way it was received, so if anything goes wrong, the events are all preserved, untouched, and can be replayed safely at any time. Saving the webhook should happen before the webhook gets processed.
Wrapping Up
Webhooks aren't guaranteed to arrive in the correct order. Processing them on arrival holds the potential of giving the user free, unpaid access.
To prevent handing out free subscriptions, reject any webhook that arrives with a timestamp older than the previous webhook, and make sure to first store the webhook's response as-is in the database just in case you need the record later.
Integrating this system correctly ensures you don't have to worry about webhooks arriving late ever again.