Magento 2 orders stuck in pending payment: why we inverted our order flow

Home / Blog / Magento 2 orders stuck in pending payment: why we inverted our order flow


If you have Magento 2 orders sitting in `pending_payment` forever, the cause is almost always when your redirect payment method creates the order, not a bug in the gateway. There are only two places to put that decision, both are defensible, and we've shipped both in the same module over three years. Here's what each one costs you, which one we landed on, and the cron you need either way. If you're the person who has to explain a customer's missing order on Monday morning, this is for you.

Why "pending payment" exists at all

A redirect payment method has a gap in the middle of it. Your shopper clicks Place Order, leaves your store for the provider's page, does something there, and comes back. Except sometimes they don't come back. They close the tab. Their phone loses signal in a lift. They pay successfully and then their browser eats the return redirect. Meanwhile the provider is sending you a webhook that may arrive before, after, or instead of your customer.

Magento's `pending_payment` state is the name for that gap. The question every payment module has to answer is what exists in your database during it, and the answer determines which of two failure modes you get to live with.

Attempt one: create the order when the payment confirms

This is the design we shipped first, back in 2022. The customer's quote sits in the cart, they go off to the provider, and only when the confirmation webhook lands do we turn that quote into an order.

The appeal is obvious and it's a real benefit: your order table only ever contains orders that got paid. No pending clutter, no cancelled noise, no abandoned rows. Your reports are clean by construction, and every increment ID corresponds to money.

The problem is what lives in the gap: nothing. Between the redirect and the webhook the purchase exists nowhere in your system. No order, no increment ID, no reservation. Which means:

  • If the webhook is delayed, a paying customer sees no order and contacts you. You have nothing to search for.
  • If the webhook fails validation, or your endpoint is down, or the quote has been touched in the meantime, the payment has succeeded and there is no order to attach it to. That reconciliation is manual, and it happens on your highest-traffic day.
  • Support can't answer "did my order go through?" because the honest answer is "there is no record either way."

A payment with no order behind it is the worst state in ecommerce. Everything else is recoverable.

Attempt two: create the order before the redirect

So we inverted it. As of 1.0.5, the module creates the Magento order before the customer ever leaves for the provider, in `pending_payment`, with the quote reserving its order ID up front:

$cart = $this->cartRepository->getActive($cartId);
$cart->reserveOrderId();

$result = $this->makePaymentCheckoutRequest($cart);

// Stash the provider's response on the quote payment before we redirect
$cart->getPayment()->setAdditionalInformation(self::PAYMENT_ADD_INFO_KEY, $floatInfo);
$this->cartRepository->save($cart);

return $result['data']['payment_url'];

Now every attempt is a real, findable record. Support can search an increment ID. The webhook's job shrinks from "construct an order from a quote" to "move state on an order that already exists," which is a much smaller thing to get right. And when a payment succeeds and something else goes wrong, you have a row to attach the money to.

You've traded a clean order table for a complete one. That's the right trade every time. You can filter a report. You cannot recover a payment with no order behind it.

Now sweep the ones that never finished

The cost of inverting is that abandoned checkouts now leave real orders behind, stuck in `pending_payment` because nobody ever paid them. This is the state most people arrive at this article searching for, and the fix is a cron:

$orderCollection = $this->orderCollectionFactory->create();
$orderCollection->join(
    ['sales_order_payment' => $orderCollection->getTable('sales_order_payment')],
    'main_table.entity_id = sales_order_payment.parent_id',
    ['method']
);
$orderCollection->addFieldToFilter('state', ['in' => [Order::STATE_NEW, Order::STATE_PENDING_PAYMENT]]);
$orderCollection->addFieldToFilter('method', ['eq' => self::METHOD_CODE]);
$orderCollection->addFieldToFilter('created_at', ['lteq' => $date]); // 24 hours ago

Four things in there are deliberate, and if you copy this, copy all four:

  1. Join to `sales_order_payment` and filter on `method`. Your sweeper must only ever touch orders belonging to your payment method. A cron that cancels every stale `pending_payment` order will eventually cancel someone else's bank-transfer order that was legitimately waiting three days, and you will not enjoy that conversation.
  2. Cancel, don't delete. "This customer tried and didn't finish" is information. Cancelled orders restore stock, keep the audit trail, and tell you something about your checkout. Deleting sales records to keep a table tidy is how you fail an audit.
  3. Give it a real window. 24 hours, not 20 minutes. Some providers settle slowly, some customers genuinely come back later, and a webhook that arrives against a cancelled order is a new problem you just created.
  4. Wrap each cancel in its own try/catch. We learned this the boring way: one order that refuses to cancel should not stop the other forty from being swept. Log it and keep going.

The quote is the other half of the job

Creating the order early has a second consequence that's easy to miss: when a payment fails, the customer comes back to an empty cart, because their quote was converted. From their side they just lost everything they'd assembled, and they blame you, not the gateway.

So the failure path has to restore the quote. Keep the last increment ID on the checkout session, and when a payment is invalid or cancelled, put the cart back the way they left it. Watch the minicart specifically, since it's cached separately and will happily show a stale empty state after a redirect even once the quote is restored. That particular bug took us two passes to kill properly.

The rule of thumb: a failed payment should cost your customer nothing except the payment. Not their cart, not their address, not their patience.

What to build, in order

If you're implementing a redirect payment method on Magento 2 today:

  • Create the order before the redirect, in `pending_payment`, reserving the order ID on the quote.
  • Make the webhook idempotent by guarding on order state. Anything not `new` or `pending_payment` gets logged and ignored, so a provider's retries are harmless.
  • Validate the webhook payload against the order before you act on it, and cancel rather than trust when it doesn't match.
  • Sweep abandoned orders on cron, filtered to your method, cancelling rather than deleting.
  • Restore the quote on every failure path, and check the minicart.
  • Send the confirmation email off the invoice, not off the redirect.
  • Log both sides of every provider call to your own log file, with a debug toggle in admin. When this goes wrong you'll be reading that file at an unsociable hour.

Does this apply to Adobe Commerce and Mage-OS too?

Yes, identically. Order state, quote conversion and the payment framework are core, so Magento Open Source, Adobe Commerce and Mage-OS all behave the same way here. The module we took through both designs targets Magento 2.4.6 and PHP 8.1, and the flow logic is the same on all three distributions.

The only Adobe Commerce wrinkle worth flagging: if your merchants use B2B negotiable quotes, that's an extra path into order creation, so exercise your payment method against it rather than assuming the standard checkout is the only entry point.

We do this for a living

Getting this wrong is expensive and getting it right is unglamorous, which is a bad combination for a team doing it once. We build and maintain payment gateway modules for Magento, Adobe Commerce and Mage-OS, including this one, which we've shipped updates for since 2021.

If you've got orders stuck in `pending_payment` and would rather someone who's already made both mistakes look at it, get in touch.


Written by X2Y.DEV
Magento Adobe Commerce Mage-OS Payments Checkout Order Management
Back to Blog

0%