Running Code When a WooCommerce Payment Is Confirmed
The hook fires when a gateway reports a successful payment, which is not the same as when an order is placed. Offline methods never fire it, and that is the difference between a fulfilment script that works and one that quietly skips a third of orders.
woocommerce_payment_complete fires when a gateway says the money has arrived, not when the order is placed. It is the last statement of the success branch of WC_Order::payment_complete(), the method that card and wallet gateways call once a charge succeeds, and it carries the transaction ID as a second argument. That makes it precise for things that should happen only after real payment — and useless for anything that must happen for every order, because a large class of orders never passes through that method at all.
What triggers it
| Payment method | Calls payment_complete()? |
Status afterwards |
|---|---|---|
| Card, wallet, hosted checkout gateways | Yes, on success | Processing, or completed for virtual + downloadable |
| Bank transfer (BACS) | Only for a zero total | On hold |
| Cheque | Only for a zero total | On hold |
| Cash on delivery | Only for a zero total | Processing, or on hold with a downloadable item |
| Free order, no payment needed | Yes | Processing or completed |
| Admin marks an on-hold order as processing | No | Processing |
The last two rows are the surprises. A zero-total order does go through payment_complete(), so a hook that assumes a charge happened will run for it. And an administrator moving a bank-transfer order to processing after seeing the money does not fire the hook, so a fulfilment script keyed to it misses every manually confirmed order.
The working shape
add_action( 'woocommerce_payment_complete', 'ti_after_payment', 10, 2 );
function ti_after_payment( $order_id, $transaction_id ) {
$order = wc_get_order( $order_id );
if ( ! $order || $order->get_meta( '_ti_provisioned' ) ) {
return;
}
// Provision access, notify a warehouse, push to an ERP — $transaction_id is the gateway's reference.
$order->update_meta_data( '_ti_provisioned', time() );
$order->save();
}
The meta flag is not decoration. payment_complete() itself refuses to run twice for an order that already left the payable statuses, but your own code may be reached from other paths later — a retry, a status hook, a manual trigger — and an idempotency check on the order is the only guard that covers all of them. Use update_meta_data() and save() rather than update_post_meta(): with high-performance order storage the order is not a post, and the post meta call writes to a table nobody reads, the same trap behind many cases of the database update not working.
Choosing between this and the status hooks
Three hooks cover the "order is paid" moment, and they overlap without being equivalent:
woocommerce_payment_complete— real payment confirmed by a gateway. Fires after the order has been saved, which means after the status transition hooks for the same change have already run.woocommerce_order_status_processing— the order entered processing by any route, including cash on delivery and manual changes.woocommerce_order_status_completed— the order entered completed, which for physical goods means someone fulfilled it, and for all-virtual orders happens automatically at payment.
A fulfilment or provisioning action that must happen for every paid order belongs on the status hooks, as covered in woocommerce_order_status_completed. woocommerce_payment_complete is right when the action is specifically about the payment — recording a transaction with an accounting system, or releasing something that a bank-transfer order should not get until a human confirms the money.
Ordering and the webhook race
Gateways call payment_complete() from a webhook or a return URL, sometimes both. WooCommerce serialises the result — the first caller wins and the second finds the order no longer payable — but your callback runs during that first call, which may be a webhook request with no logged-in user, no session and no cart. Code that reads WC()->cart or wp_get_current_user() here gets nothing useful. Read everything from the order object.
Anything slow does not belong in the callback either. The gateway is waiting for an HTTP response, and a timeout makes it retry — and because the retry finds the order already processing, your slow callback does not get a second chance. Queue the work with Action Scheduler and return.
The status filter next to it
woocommerce_payment_complete_order_status is a filter, not an action, and it runs inside the same method to decide which status the order moves to after payment. The default is processing, or completed when every item is virtual and downloadable. Filtering it to completed for all orders is a common shortcut for stores with no physical fulfilment, and a common mistake for stores that have some: completed triggers the customer's completed-order email, which stops making sense when the parcel has not been packed. The email side of that is covered in emails not sending, where the question is usually that the wrong status was reached, not that mail failed.
Verify
Place three test orders: one with a card gateway in test mode, one with bank transfer, and one with cash on delivery. The hook should fire for the first only. Then mark the bank-transfer order as processing in the admin and confirm it still does not fire. If your logic needs to cover all three, you are on the wrong hook, and the test has told you so before a customer did.
Frequently asked
- Because those orders were paid by a method that does not call payment_complete() for a non-zero total. Bank transfer, cheque and cash on delivery set the order to on-hold or processing directly and skip it. Only gateways that confirm payment programmatically — card, wallet and hosted checkout gateways — trigger it.
- No. payment_complete() checks that the order is still in a payable status — pending, on-hold, failed or cancelled by default — before doing anything. A second webhook for an order that is already processing returns early and the hook stays silent.
- woocommerce_order_status_processing or woocommerce_order_status_completed, depending on which status means 'ready' in your workflow. They fire regardless of payment method, including for manual status changes in the admin.