Webhook racing against onReturn

Created on 16 July 2025, 16 days ago

Describe your bug or feature request.

We're now making great use of the webhook feature to add robustness to our checkout flow - great job!

However - we seem to be having a problem with a race condition where the webhook from Stripe (payment_intent.succeeded) - is arriving before the onReturn callback from the browser.

This then results in a "Call to a member function getState() on null" error returned to Stripe.

The onReturn route is then processed - and the order is left with two Payment items attached to the order - both with the same payment intent. Thankfully - we only see one real payment in Stripe....

I've put a small patch in place - on my test site - which prevents the Payment entity from being created if it already exists:

commerce_stripe\src\Plugin\Commerce\PaymentGateway\StripePaymentElement.php - around line 570

    $existing_payment = $payment_storage->loadByRemoteId($payment_intent->id);
    if ($existing_payment) {
      // Payment already exists - don't create duplicate
      \Drupal::logger('commerce_stripe_webhook')->error('Duplicate found; React to the payment intent returned in the onReturn callback.: Order; @order_id, Intent: @intent_id', [
        '@order_id' => $order->id(),
        '@intent_id' => $payment_intent->id,
      ]);
      return;
    }

Has anybody else seen repeated Payments - and does the above make sense?

If a bug, provide steps to reproduce it from a clean install.

🐛 Bug report
Status

Active

Version

3.1

Component

Payment

Created by

🇬🇧United Kingdom newaytech

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Comments & Activities

  • Issue created by @newaytech
  • 🇮🇱Israel jsacksick

    This is probably a Commerce Stripe issue and I don't think it is still relevant as the webhook processing is queued? Are you running the latest version?

  • 🇬🇧United Kingdom newaytech

    Hi @jsacksick - thanks for checking in. Yes - Commerce Version: 3.1.0 & Commerce stripe: Version: 2.0.1

    Was not aware that the webhooks could be diverted to a queue - is that a setting somewhere?

  • 🇮🇱Israel jsacksick

    Yes, you need to turn on the commerce_stripe_webhook_event module. And we support processing the jobs with advancedqueue as well if it's installed.

  • 🇬🇧United Kingdom newaytech

    Wow - a tick box fix. nice one - sorted now...

  • 🇺🇸United States TomTech

    @newaytech,

    Thanks for the report.

    Receiving the webhook before the return is a valid and expected scenario.

    The most important scenario, is if the customer completes the payment, the payment is processed on stripe, but the onReturn does NOT occur at all. e.g. If the customer loses their internet connection before onReturn is triggered from their browser.

    The payment completed on the stripe side, and the webhook is there to complete the placement of the order, even if onReturn does not. Otherwise, we would have a stripe payment, but the order is not marked as placed/paid.

    We have tested this extensively and built the module to handle race conditions between onReturn and onNotify(webhook). No matter which occurs first, both should work, and duplicate payment entities should not be created.

    Sounds like you have encountered a scenario we are not accounting for, or something new causing an issue.

    Can you provide more details on the "Call to a member function getState() on null" error you encountered?

    There should hopefully be a full stack trace or at least a more detailed error message in the log messages so we can look into this.

    (Queueing Webhook events is primarily helpful when your site is under load and can't process all the messages in real time. e.g. a Black FridayGiving Tuesday type of event, or a ticket drop for a large concert. It also provides other benefits, but queuing should solely be relied on for handling the race condition, as a slow internet connection could still cause onReturn to occur after onNotify.)

  • 🇬🇧United Kingdom newaytech

    Hi @tomtech,

    Thanks for looking into this - and also all the work around making the module more robust with the webhooks.

    I've turned off the queue feature - and placed a test order (using a 3DS card) - I then see the below stack trace on the initial webhook (I changed the code to see the full stack being returned to Stripe) : - This

    I'm using Fulfilment, with validation - and have a custom flow plugin defined (but I also went back to the default flow - and got the same error)

    Origin date
    17 Jul 2025, 17:21:02
    Source
    Automatic
    API version
    2020-08-27
    Description
    The payment pi_3RluYAIiLfgmjlFs1tYEAWP3 for GBP 175.18 has succeeded
    Response
    HTTP status code
    500
    #0 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/modules/contrib/commerce/modules/checkout/src/Plugin/Commerce/CheckoutFlow/CheckoutFlowWithPanesBase.php(113): Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\CompletionRegister->isVisible()
    #1 [internal function]: Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowWithPanesBase->Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\{closure}()
    #2 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/modules/contrib/commerce/modules/checkout/src/Plugin/Commerce/CheckoutFlow/CheckoutFlowWithPanesBase.php(114): array_filter()
    #3 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/modules/contrib/commerce/modules/checkout/src/Plugin/Commerce/CheckoutFlow/CheckoutFlowWithPanesBase.php(132): Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowWithPanesBase->getVisiblePanes()
    #4 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/modules/contrib/commerce/modules/checkout/src/Plugin/Commerce/CheckoutFlow/CheckoutFlowBase.php(208): Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowWithPanesBase->isStepVisible()
    #5 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/modules/contrib/commerce_stripe/src/Plugin/Commerce/PaymentGateway/StripePaymentElement.php(1581): Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowBase->getNextStepId()
    #6 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/modules/contrib/commerce_stripe/src/Plugin/Commerce/PaymentGateway/StripePaymentElement.php(916): Drupal\commerce_stripe\Plugin\Commerce\PaymentGateway\StripePaymentElement->placeOrder()
    #7 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/modules/contrib/commerce_stripe/src/Plugin/Commerce/PaymentGateway/StripePaymentElement.php(758): Drupal\commerce_stripe\Plugin\Commerce\PaymentGateway\StripePaymentElement->processWebHook()
    #8 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/modules/contrib/commerce/modules/payment/src/Controller/PaymentNotificationController.php(35): Drupal\commerce_stripe\Plugin\Commerce\PaymentGateway\StripePaymentElement->onNotify()
    #9 [internal function]: Drupal\commerce_payment\Controller\PaymentNotificationController->notifyPage()
    #10 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/core/lib/Drupal/Core/EventSubscriber/EarlyRenderingControllerWrapperSubscriber.php(123): call_user_func_array()
    #11 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/core/lib/Drupal/Core/Render/Renderer.php(637): Drupal\Core\EventSubscriber\EarlyRenderingControllerWrapperSubscriber->Drupal\Core\EventSubscriber\{closure}()
    #12 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/core/lib/Drupal/Core/EventSubscriber/EarlyRenderingControllerWrapperSubscriber.php(124): Drupal\Core\Render\Renderer->executeInRenderContext()
    #13 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/core/lib/Drupal/Core/EventSubscriber/EarlyRenderingControllerWrapperSubscriber.php(97): Drupal\Core\EventSubscriber\EarlyRenderingControllerWrapperSubscriber->wrapControllerExecutionInRenderContext()
    #14 /etc/apache2/htdocs/drupal-8/neway-drupal-project/vendor/symfony/http-kernel/HttpKernel.php(181): Drupal\Core\EventSubscriber\EarlyRenderingControllerWrapperSubscriber->Drupal\Core\EventSubscriber\{closure}()
    #15 /etc/apache2/htdocs/drupal-8/neway-drupal-project/vendor/symfony/http-kernel/HttpKernel.php(76): Symfony\Component\HttpKernel\HttpKernel->handleRaw()
    #16 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/core/lib/Drupal/Core/StackMiddleware/Session.php(53): Symfony\Component\HttpKernel\HttpKernel->handle()
    #17 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/core/lib/Drupal/Core/StackMiddleware/KernelPreHandle.php(48): Drupal\Core\StackMiddleware\Session->handle()
    #18 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/core/lib/Drupal/Core/StackMiddleware/ContentLength.php(28): Drupal\Core\StackMiddleware\KernelPreHandle->handle()
    #19 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/core/modules/page_cache/src/StackMiddleware/PageCache.php(116): Drupal\Core\StackMiddleware\ContentLength->handle()
    #20 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/core/modules/page_cache/src/StackMiddleware/PageCache.php(90): Drupal\page_cache\StackMiddleware\PageCache->pass()
    #21 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/core/lib/Drupal/Core/StackMiddleware/ReverseProxyMiddleware.php(48): Drupal\page_cache\StackMiddleware\PageCache->handle()
    #22 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/core/lib/Drupal/Core/StackMiddleware/NegotiationMiddleware.php(51): Drupal\Core\StackMiddleware\ReverseProxyMiddleware->handle()
    #23 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/core/lib/Drupal/Core/StackMiddleware/AjaxPageState.php(36): Drupal\Core\StackMiddleware\NegotiationMiddleware->handle()
    #24 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/core/lib/Drupal/Core/StackMiddleware/StackedHttpKernel.php(51): Drupal\Core\StackMiddleware\AjaxPageState->handle()
    #25 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/core/lib/Drupal/Core/DrupalKernel.php(741): Drupal\Core\StackMiddleware\StackedHttpKernel->handle()
    #26 /etc/apache2/htdocs/drupal-8/neway-drupal-project/web/index.php(19): Drupal\Core\DrupalKernel->handle()
    #27 {main}

  • 🇺🇸United States TomTech

    @newaytech,

    Thanks for providing this.

    Some observations:

    1. The StripePaymentElement line numbers indicated in the stack trace don't seem to line up with commerce_stripe 2.0.1. Can you confirm you are using 2.0.1 with no patches/modifications?

    2. The order workflow shouldn't be relevant here. The checkout flow could be if you have a custom checkout flow.

    3. While the stack trace is here in full, there is usually one more line that reports the error in the error log. It appears the the "Call to a member function getState() on null" is occurring when isVisible() is called on a particular checkout pane. Identifying that pane, and the line this occurs on would be helpful in determining this issue. This is possibly an issue with that particular checkout pane.

  • 🇬🇧United Kingdom newaytech

    Hi @tomtech

    Thanks for the comments - in response:

    1. Yes - I added various extra logging to understand better what was happening - but base module is 100% 2.0.1
    2. I have reverted to the default workflow - and the checkout flow got me thinking...
    3. Yes - I saw that too - and used the error we saw before I changed the call to show a full trace. The issue does point toward a problem when rendering the panes (why do we do this for a server to server call?)

    Soo... Thinking about your comment regarding the panes - and also along with my own debugging - decided to turn off the "Email registration" module - and then hey presto - the first webhook from Stripe succeeded. I also saw the chain of events in watchdog - and the thread belonging to the Stripe IP processes the order to the point where it drops into my queue for onward warehouse processing.

    Do we now have to transfer this over to the Email Reg (email_registration - Version: 2.0.0-rc8) team?

Production build 0.71.5 2024