Skip to content
ThemesIonic — home
WooCommerce

Running Code After a WooCommerce Order

The hook fires when someone views the order received page, which is not the same as when an order is paid. Anything important hooked here runs twice for one customer and never for another.

4 min read intermediate

This hook answers "did someone look at the receipt", not "was this order paid". woocommerce_thankyou fires while rendering the order received page. A customer who refreshes it fires the hook again. A customer whose bank redirect drops them on a blank tab never fires it at all, even though the order is paid and sitting in the admin. Using it for anything that must happen exactly once per order is the single most expensive mistake in WooCommerce integrations.

What it gives you

One argument, the order ID:

add_action( 'woocommerce_thankyou', 'ti_order_received', 10, 1 );

function ti_order_received( $order_id ) {
	if ( ! $order_id ) {
		return;
	}

	$order = wc_get_order( $order_id );

	if ( ! $order instanceof WC_Order ) {
		return;
	}

	echo '<p class="ti-note">' . esc_html__( 'A confirmation is on its way.', 'your-textdomain' ) . '</p>';
}

Both guards earn their place. The argument can arrive as 0 when a gateway returns the shopper to the endpoint without a resolvable order, and wc_get_order() returns false for an ID that no longer exists — a real case on stores that prune cancelled orders.

Making it run once

If the work must not repeat, the order itself is the only reliable place to record that it happened. Order meta survives page reloads, sessions and caches:

add_action( 'woocommerce_thankyou', 'ti_notify_once', 10, 1 );

function ti_notify_once( $order_id ) {
	$order = wc_get_order( $order_id );

	if ( ! $order instanceof WC_Order ) {
		return;
	}

	if ( $order->get_meta( '_ti_notified' ) ) {
		return;
	}

	$order->update_meta_data( '_ti_notified', current_time( 'mysql' ) );
	$order->save();
}

Note the order of operations: read the flag, write the flag, then do the work. Writing it afterwards leaves a window in which two near-simultaneous requests both pass the check — which a customer double-clicking a reload button produces more often than seems plausible.

A leading underscore keeps the meta key out of the custom fields box on the order screen. Without it the flag becomes visible and editable to anyone with order access.

What the status is when it fires

The order is not necessarily paid at this moment. A bank transfer order lands on the thank you page with status on-hold, because payment has not happened and is not expected to for days. A cheque payment does the same. Code that assumes processing will behave incorrectly for every offline method the store offers.

Read the status rather than assuming it:

if ( ! $order->has_status( array( 'processing', 'completed' ) ) ) {
	return;
}

Where this hook is the right tool

It is genuinely good for anything cosmetic or analytical that belongs to the page view:

  • Confirmation messaging beyond the default template, including delivery expectations that depend on the shipping method chosen.
  • Conversion tracking, because a pixel has to run in the browser and this is the page where the browser is. Accept that the count will be slightly low, since not every payer reaches the page.
  • Order-specific instructions, such as the bank details for an offline method or a download note for a digital product.

It is the wrong tool for sending data to a warehouse, issuing a licence key, posting to an external system, or anything else where a missed run or a double run costs money. Those belong on the order lifecycle, where the WooCommerce REST API and status transitions operate without a browser in the loop.

Caching turns the problem up

A page cache that stores the order received page will serve one customer's receipt to another, and it will also stop the hook running on subsequent views. Most caching plugins exclude WooCommerce endpoints by default, but a custom rule or an aggressive edge cache can undo that.

Confirm the exclusion is in place before trusting anything printed here, following clearing the WordPress cache. The symptom is unmistakable once seen: a thank you page showing the wrong name.

Verify with a deliberately awkward test

Place one test order and then, before doing anything else, reload the order received page three times. Whatever your callback does should have happened exactly once. Then place a second order with an offline payment method and confirm the status guard behaved.

Finally, place an order and close the tab at the payment step without returning. The order should still be correct in the admin. If your integration depends on the thank you page, this is the order it will silently miss, and the gap only becomes visible when a customer asks where their purchase went — the same class of silent failure as emails not sending.

Frequently asked

Because the customer reloaded the order received page. The hook fires on every view of that page, not once per order. Store a flag in order meta on first run and return early when it is present.
Because the customer never loaded the page. Off-site payment methods return the shopper through a redirect that can fail, and some simply close the tab after paying. The order exists and is paid, but the thank you page was never viewed.
A status hook such as woocommerce_order_status_processing or woocommerce_payment_complete. Those fire from the order lifecycle itself, including when payment is confirmed by a gateway callback with no browser involved.

Related guides