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.
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.
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.
In the common bank virtual POS models (Nestpay-based gateways, QNB's PayFor and similar), the 3D Host/3D Pay flow looks like this:
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.
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.
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:
// 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.
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”.
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.
payment_complete(): setting the status by hand skips stock reduction and emailsWe 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.
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.
XmlGate.aspx · SecureType=Inquiry · TxnType=OrderInquiryExtra → ORDERSTATUS=QUERY in the requestWith 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.
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.
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.
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.