cashier-stripe: Order of webhooks from Stripe may not be consistent causing subscription to be in incorrect state

  • Cashier Version: 13.0.0
  • Laravel Version: 8.46.0
  • PHP Version: 8.0.3
  • Database Driver & Version: MySQL 8

Description:

From the stripe documentation:

Order of events

Stripe does not guarantee delivery of events in the order in which they are generated. For example, creating a subscription might generate the following events:

  • customer.subscription.created
  • invoice.created
  • invoice.paid
  • charge.created (if there’s a charge)

Your endpoint should not expect delivery of these events in this order and should handle this accordingly. You can also use the API to fetch any missing objects (e.g., you can fetch the invoice, charge, and subscription objects using the information from invoice.paid if you happen to receive this event first).

This actually just bit me - image

  • We received the “Updated” event with a created value of 1623979266 first
  • We received the “Created” event with a created value of 1623979265 second

With the end result being - the subscription was marked as “incomplete” in the database as that was the ‘latest’ status we’d received.

Steps To Reproduce:

  • Create and complete a subscription payment with webhooks enabled
  • Check the database - if the subscription is “incomplete” then you’ve probably hit the bug
  • if the subscription is “active” then you haven’t hit the bug - retrigger the “created” event from the stripe dashboard to simulate the event coming through second image

Solution

Record the last “event time” from stripe which would be the created value image if the subscription/customer (based off the webhook) has a newer “stripe event time” then discard the event.

About this issue

  • Original URL
  • State: closed
  • Created 3 years ago
  • Comments: 25 (12 by maintainers)

Most upvoted comments

@hailwood this is a technicality that we should solve in Cashier, nothing that Stripe can help. What’s basically happening is that Stripe operates so fast that its webhooks are sent out at almost the same time down to the second or even closer. The web server receiving the webhook and handling it cannot react fast enough to execute the code of the created webhook and therefor the data isn’t available when the updated webhook is received asynchronously.

Note that the way webhooks (or any event based system for that matter) work is that it’s expected that webhooks/events are sent as soon as they’re handled. The asynchronously nature of them arriving at different times (Network/IO) or not being handled fast enough (system resources) are a technicality that the sender can’t account for.

I actually very much like @JhumanJ’s dead simple solution. That one second gap could be enough to let the created webhook arrive in time and be processed first. Can everyone here maybe try that one out on their apps to see if that solves your issues? We can then re-evaluate within a few weeks if it was enough to solve all issues here.

Hey, same issue here. Did the dirtiest fix I’ve ever done:

<?php

namespace App\Http\Controllers\Webhook;

use Laravel\Cashier\Http\Controllers\WebhookController;

class StripeController extends WebhookController
{

    public function handleCustomerSubscriptionUpdated(array $payload)
    {
        sleep(1);
        return parent::handleCustomerSubscriptionUpdated($payload); 
    }

}

Just to note, we are experiencing this same issue, not very frequently but at least once a month with our subscriptions. I found a solution here that sounds like the best option, simply ignore the webhook and retrieve the most up to date subscription data from stripe. Let me know if I can be of any assistance here.

I’m gonna re-open this to look into at some point since more people are experiencing this. But we won’t be actively developing this feature right now.

At the time of checkout success page, can we sync the database entry with Stripe using:

optional($user->subscription('main'))->syncStripeStatus();

https://stackoverflow.com/a/69031618/2752871

I don’t like the ‘blocking’ of the request by using sleep()

I created 2 jobs, one for the creating and one for updating the subscription then I implemented my version of handleCustomerSubscriptionCreated and handleCustomerSubscriptionUpdated

In handleCustomerSubscriptionUpdated, i check if the subscription is created, if not, then I re-submit the job. I did create a retry count so that the job does not go into a endless loop

@JhumanJ thank you for sharing this suggestion! I’m unfamiliar with PHP’s approach to asynchronous execution, do you know if your proposed solution would work with something like NodeJS as well?

Node doesn’t have a builtin sleep() function so I was thinking of building a solution on top of the Promise API + setTimeout().

I’ve sent in a PR with your solution @JhumanJ. Thank you for suggesting that. We’ll see how it goes.

https://github.com/laravel/cashier-stripe/pull/1227

I’ve had this issue since before cashier had official support for checkout (I manually added the PR code back then). I used the sleep hack in 2 apps in production, and never had the issue again!

This is for a Stripe Checkout session, correct?