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.

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

newaytech created an issue. See original summary.

jsacksick’s picture

Project: Commerce Core » Commerce Stripe
Version: 3.1.0 » 2.0.x-dev
Component: Payment » Code

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?

newaytech’s picture

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?

jsacksick’s picture

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.

newaytech’s picture

StatusFileSize
new61.35 KB

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

tomtech’s picture

@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.)

newaytech’s picture

StatusFileSize
new134.69 KB

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}

tomtech’s picture

@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.

newaytech’s picture

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?

jonathanshaw’s picture

Do we now have to transfer this over to the Email Reg

Probably not

the order is left with two Payment items attached to the order - both with the same payment intent

because this should never happen even if there are bugs in other modules, it seems like our defences against it are not strong enough.

anybody’s picture

@newaytech could you maybe post a screenshot of your checkout steps configuration so we better understand how it's configured? Totally agree with #10 here.

ahmed eldesoky’s picture

Version: 2.0.x-dev » 2.1.0
StatusFileSize
new2.13 KB

We encountered the same issue using Commerce Stripe 2.1.0 with Commerce Core 3.1.0.

In our case, the Stripe webhook was triggered before the onReturn() function executed, which resulted in duplicate payments being created for the order. Stripe then received the following error response:
Call to a member function getState() on null

For debugging, we temporarily added the following line after line 979:

\Drupal\Core\Utility\Error::logException($this->logger, $throwable);

This produced the following log entry:

Error: Call to a member function getState() on null in
Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\CompletionRegister->isVisible()
(line 106 of /app/web/modules/contrib/commerce/modules/checkout/src/Plugin/Commerce/CheckoutPane/CompletionRegister.php).

Tracing the stack shows that this
line in StripePaymentElement
when using the Multistep (default) checkout flow and having the Guest registration after checkout pane enabled, leads to isVisible() being invoked before a current order is set, which violates Commerce Core’s expectations (see: https://git.drupalcode.org/project/commerce/-/blob/3.x/modules/checkout/src/Plugin/Commerce/CheckoutPane/CheckoutPaneBase.php?ref_type=heads#L58
).

The attached patch prevents the premature call and resolves the issue.

anybody’s picture

Priority: Normal » Major
Status: Active » Needs work

@ahmed eldesoky could you then please provide the patch as MR instead to be reviewed?

vmarchuk’s picture

@tomtech
We can use the same solution I used here https://git.drupalcode.org/project/commerce_authnet/-/blob/8.x-1.x/src/P... as I also faced the same issue when I tried to reuse the code from the commerce_stripe module.

anybody’s picture

@vmarchuk maybe also check commerce_paypal which is widely used, if it has the same flaw?

alanhdev’s picture

There is a CheckoutOrderManager service (commerce_checkout.checkout_order_manager) that provides a method getCheckoutFlow(OrderInterface $order). This gets the checkout flow, sets the order property and then returns it.
Maybe this service could be used instead of explicitly setting the order on the checkout flow?

jonathanshaw’s picture

#17 is also proposed in #14 by @vmarchuk from the commerce team

anybody’s picture

I can now confirm I'm also getting the same error

Call to a member function getState() on null

as in #12 in 2.1.0 without any patch.

Furthermore some payments are saved twice, so the order balance is negative!
This definitely happens since enabling the Commerce Stripe Webhook Events submodule and configuring the payment_intent.* webhooks on the Stripe side.

So I'm confirming this is a major issue with the webhooks submodule!

anybody’s picture

Static patch attached from MR!196 until this is fixed.

anybody’s picture

Status: Needs work » Needs review

As of the change after #18

anybody’s picture

Status: Needs review » Needs work

The error

Call to a member function getState() on null

is gone now, with MR!196 / #20 applied!

Sadly the payments are still created twice now!

And even worse:
Now the URL which I think is visible to the user:
https://www.example.com/checkout/50034/review/return?payment_intent=xxxx&payment_intent_client_secret=xxxxxxxxxxxxxxx&redirect_status=succeeded throws an Exception:

Drupal\commerce_order\Exception\OrderLockedSaveException: Attempted to save order 50034 that is locked for updating. Use OrderStor

jonathanshaw’s picture

I made 3 changes:
(1) Added an order lock, to handle the race condition where onReturn and processWebhook were working exactly simultaneously
(2) Invoke placeOrder() in onReturn() only if the payment is recorded there; if processWebhook() has already recorded the payment, defer to how it has left the order.
(3) Remove the change to loading the checkout flow plugin in placeOrder(). It's a valid change, but it's no longer relevant to this issue now that I've done (2), and it's better handled in #3571373: Refine the StripePaymentElement::placeOrder() logic

jonathanshaw’s picture

Status: Needs work » Needs review
tomtech’s picture

Component: Code » Payment Element
Assigned: Unassigned » tomtech
Status: Needs review » Needs work

Hmm...this change is calling loadForUpdate() in onReturn() here: https://git.drupalcode.org/project/commerce_stripe/-/merge_requests/196/...

But, loadForUpdate() is already called by commerce core returnPage(), here: https://git.drupalcode.org/project/commerce/-/blob/3.x/modules/payment/s...

just before it invokes onReturn() on this plugin here: https://git.drupalcode.org/project/commerce/-/blob/3.x/modules/payment/s...

The calls to loadForUpdate() in returnPage() and in the webhook were done precisely to handle this scenario and tested pretty extensively.

If you are experiencing this issue, something else seems be at play, causing the semaphore pattern from preventing this from occurring.

Here are some possibilities:
1. If you have any custom code at play in here, make sure that you do NOT call $order->save(), as that releases the lock. (And also causes other issues, as Drupal does not handle entities well when save() is called multiple times on a single request. See: https://www.drupal.org/project/drupal/issues/3563047)

2. If you have multiple web servers, ensure that you still have one shared lock backend. If not, then two requests handled by different servers AND have two different lock backend instances will not know about each other, and therefore won't block each other from proceeding.

3. Might you have custom code invoking onReturn() that did not come from returnPage()? In that case, that calling code would need to handle the locking, similar to returnPage().

4. Do you have a custom step in the checkout workflow between review->payment->complete? If so, you are likely preventing the order from being transitioned out of draft. The behavior at this point is undefined. If an order is not moved out of draft, after the payment is recorded in the checkout(or webhook) flow, then there are many possible side effects and undefined behavior.

Some other notes:

The call to releaseLock() in the MR is not needed. see: https://www.drupal.org/project/drupal/issues/3563047

The order lock is released when the order is saved. see: https://www.drupal.org/project/drupal/issues/3563047

(You really should only ever need to invoke releaseLock() if you called loadForUpdate(), then decide you will not be saving the order.)

IRT the comment:

Invoke placeOrder() in onReturn() only if the payment is recorded there

We shouldn't even end up in onReturn(). If the order is handled in the webhook, the code in returnPage(), after acquiring the lock, checks to see if we are on the correct step..and if not, progresses us forward. (validateStepId() right after loadForUpdate() handles this.)

Also, setting the component on this to Payment Element, since this seems to be specific to it, and not applicable to Card Element.

jonathanshaw’s picture

Ouch, apologies for the noise, you're completely right my changes in #23 are ill-conceived and failed to properly consider what returnPage() was doing.

tomtech’s picture

@jonathanshaw, your thought process makes sense! Since we are already doing the lockForUpdate(), though, seems this has a different root.

I am able to reproduce the issue when the next step (almost always completed) has a checkout pane whose isVisible() references the order.

The approach by @ahmed-eldesoky and @vmarchuk makes sense. We aren't setting the order in placeOrder(). While this isn't an issue in onReturn(), since onReturn() actually has an order in the request parameters, if the webhook fires first, it will not have the order.

The fix proposed in 14 and 17 makes sense. I've updated the MR to reflect this, with a couple tweaks.

The tests just passed, so adding it to the merge train.

tomtech’s picture

Version: 2.1.0 » 2.x-dev
Status: Needs work » Fixed

Merged. Thanks all!

(If you have a race condition that is NOT resolved by this, please open a new issue with the details, as this resolves the specific case identified multiple times in this issue.)

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

anybody’s picture

We just tried re-enabling the webhook after this is fixed now, but sadly #19 still happens despite the fix, using Express Checkout.

I now created a new issue for that: #3590889: Using the Express Checkout with Webhook enabled payments are added twice and order balance becomes negative

I also created a further META issue because it seems there are still other race conditions for things that happen in both methods: #3590914: [META] Ensure Webhooks and onReturn don't race, do the same things twice or overwrite each other (especially with Express Checkout)