Available — Local Digital Products
HCA · Studio
TR — Contact ↗
Technical Note 13 · Virtual POS

The card was charged, the order is still pending

The customer sees the charge on their card; in your admin the order still says “Pending payment”. An hour later WooCommerce cancels it. The bank is not the problem — the path the result takes back to your store is.

Topic
3D Secure return
Language
PHP
Symptom
Paid but pending
Short answer

In the hosted 3D Secure models used by most bank virtual POS systems (3D Host, 3D Pay), the payment result reaches your store through the customer's browser, as a POST to your success or fail URL. If the customer closes the tab after paying, loses connection or the redirect stalls, that request never arrives: the card is charged and the order stays pending. The fix is not to rely on the browser and to ask the bank's order inquiry service from your server (reconciliation) — a few minutes after the payment attempt and when the customer lands on the thank-you page. Never complete the order without verifying the amount and void status.

Symptom

The complaint usually arrives by phone: “My card was charged but my order was not confirmed.” In the admin the order is “Pending payment” — or it has already moved to “Cancelled”. The error log is empty, because from the server's point of view nothing went wrong: nothing reached the server at all.

Where the result comes from

In the common bank virtual POS models (Nestpay-based gateways, QNB's PayFor and similar), the 3D Host/3D Pay flow looks like this:

  1. The store posts a signed form, through the customer's browser, to the bank's payment page
  2. The customer enters card details and the 3D Secure code at the bank; the bank takes the payment
  3. The bank reports the result by making the customer's browser POST to the store's success or fail URL
  4. The store verifies the hash and completes the order

Step three is the fragile one. That request leaves the customer's device. If mobile data drops, the customer sees “payment successful” and closes the tab, the bank page is slow and they hit back, or an in-app browser swallows the redirect, the store never learns the result. The payment happened; the news was lost on the way.

Some payment providers offer a server-to-server notification URL and report the result independently of the customer. Most classic bank virtual POS platforms have no such reliable notification; the only robust way to learn the result is to ask.

The hold-stock trap

One WooCommerce setting makes this worse: Settings → Products → Inventory → Hold stock (minutes). With stock management enabled, orders created at checkout that stay in “Pending payment” for that long are cancelled automatically. So a payment whose return was lost becomes, an hour later, “a paid order that was cancelled”.

Run reconciliation before that window closes. Reconciliation should also be able to query recently cancelled orders created through your gateway; payment_complete() accepts a cancelled order as a valid status to complete from.

Reconciliation

The idea is simple: instead of waiting for the result from the browser, ask the bank's order inquiry service from your server. Two triggers are enough:

  • Scheduled check: a one-off job a few minutes after every payment attempt
  • Thank-you page: if the order still looks unpaid when the customer returns, query immediately
// When a payment attempt starts
$ids   = (array) $order->get_meta( '_pos_order_ids' );
$ids[] = $bank_order_id;                       // this attempt's bank order number
$order->update_meta_data( '_pos_order_ids', $ids );
$order->save();
wp_schedule_single_event( time() + 600, 'mypos_reconcile', array( $order->get_id() ) );

add_action( 'mypos_reconcile', 'mypos_reconcile' );
add_action( 'woocommerce_thankyou_my_gateway', 'mypos_reconcile' );

function mypos_reconcile( $order_id ) {
    $order = wc_get_order( $order_id );
    if ( ! $order || $order->is_paid() ) {
        return;
    }
    foreach ( array_reverse( (array) $order->get_meta( '_pos_order_ids' ) ) as $bank_id ) {
        $r = mypos_inquiry( $bank_id );          // server-to-server query to the bank
        if ( ! $r || '00' !== $r['code'] || $r['voided'] ) {
            continue;
        }
        if ( $r['amount'] !== wc_format_decimal( $order->get_total(), 2 ) ) {
            $order->add_order_note( 'Bank reported a different amount — check manually.' );
            return;
        }
        $order->payment_complete( $r['ref'] );
        $order->add_order_note( 'Reconciliation: the bank confirmed the payment.' );
        return;
    }
}

WP-Cron is triggered by visits; on low-traffic sites a scheduled job can run late. For reliability, run wp cron event run --due-now from the system cron.

Asking the bank

Unlike the payment form, the inquiry is made server to server and authenticates with an API-role user. On QNB's PayFor platform, for example, this request is posted to XmlGate.aspx:

<?xml version="1.0" encoding="UTF-8"?>
<PayforRequest>
  <MbrId>5</MbrId>
  <MerchantId>…</MerchantId>
  <UserCode>…</UserCode>
  <UserPass>…</UserPass>
  <OrgOrderId>ORD1234A2</OrgOrderId>
  <SecureType>Inquiry</SecureType>
  <TxnType>OrderInquiry</TxnType>
  <Lang>EN</Lang>
</PayforRequest>

If the response has ProcReturnCode 00, TxnType Auth and an empty or zero VoidDate, the payment is a valid sale; PurchAmount gives the amount. On Nestpay-based gateways the same is done by adding ORDERSTATUS=QUERY to the request's Extra field. The names differ by bank; the idea is the same.

Two practical notes: banks usually restrict this service by IP, so have your server's IP whitelisted. And with wrong credentials the service still answers HTTP 200 with an error code — an integration that does not read the response body will take that as “not paid”.

Multiple attempts

Banks reject a second transaction with the same order number, so each payment attempt gets a new bank order number (ORD1234A1, ORD1234A2…). This hides a silent double-charge risk:

The customer pays on the first attempt but the return is lost; the order looks pending, the customer tries again and the second attempt fails. A reconciliation that queries only the last number says “not paid” and the customer pays once more. So store every attempt number for the order and query all of them, newest first.

Before completing

  • Amount: the amount reported by the bank must match the order total; if it does not, do not complete automatically — add a note and leave it for a human
  • Void: a transaction voided the same day can come back with a “success” code; check the void field separately
  • Idempotency: if the order is already paid, do nothing; the return and the reconciliation can run at the same time
  • Use payment_complete(): setting the status by hand skips stock reduction and emails

We added this layer to Castor Coffee's QNB virtual POS integration: every attempt number is stored, the bank is queried ten minutes later and on the thank-you page, and an amount mismatch never completes an order automatically.

Summary

With 3D Secure, the result travels back through the least reliable link — the customer's browser. When that link breaks, the card is charged, the order hangs, and once the hold-stock window expires it is cancelled. The lasting fix: ask the bank from your server. Store every attempt number, query before the hold-stock window closes, and never complete without verifying the amount and void status. The return URL is a convenience that works on a good day; reconciliation is the insurance for the bad one.

Quick reference
Symptom
Card charged, order “Pending payment” or cancelled
Cause
The 3D result returns through the customer's browser; if the tab closes it never arrives
Fix
Server-to-server query to the bank's order inquiry service (reconciliation)
When
~10 minutes after the attempt + on the thank-you page
PayFor (QNB)
XmlGate.aspx · SecureType=Inquiry · TxnType=OrderInquiry
Nestpay
Extra → ORDERSTATUS=QUERY in the request
Trap
Pending orders are cancelled automatically when the hold-stock window expires
Rule
Query every attempt number; never complete without checking amount and void
Frequently Asked Questions

About payment reconciliation.

The customer's card was charged but the WooCommerce order is pending payment. Why?

With 3D Secure the bank reports the result to your store through the customer's browser, as a POST to your success URL. If the customer closes the tab after paying or loses connection, that request never arrives. The payment happened, but the store was never told.

Why are these orders cancelled after a while?

Because of WooCommerce's hold-stock setting. With stock management enabled, orders that stay pending for the configured number of minutes are cancelled automatically. Run reconciliation before that window closes and also query your own recently cancelled orders.

What do I need for reconciliation?

Access to the bank's order inquiry service: an API-role user and your server's IP address whitelisted by the bank. The query runs server to server and does not depend on the customer's browser.

Why query every payment attempt?

Each attempt uses a new bank order number. If the first attempt succeeded but its return was lost and the second attempt failed, a check that looks only at the last number says “not paid” and the customer pays again. Querying all numbers, newest first, prevents double charges.

Availability and Quotes

Have an idea?
Half a sentence is enough.