Skip to content
ThemesIonic — home
WooCommerce

Validating Checkout With woocommerce_checkout_process

The hook fires once per Place order click, before WooCommerce reads or validates the posted data. Any error notice you add stops the order. It does not run in the block checkout at all.

5 min read intermediate

woocommerce_checkout_process is the classic checkout's first validation point: any error notice added in it stops the order. It is a plain action with no arguments, fired from WC_Checkout::process_checkout() after the security nonce has been verified and the cart confirmed not empty, but before WooCommerce reads, sanitises or validates the posted fields. Later, the checkout only creates the order if wc_notice_count( 'error' ) is zero, so a single wc_add_notice( '…', 'error' ) in your callback is enough to block it. The block-based checkout never fires this hook.

The working shape

add_action( 'woocommerce_checkout_process', 'ti_require_po_number' );

function ti_require_po_number() {
	$po = isset( $_POST['ti_po_number'] ) ? wc_clean( wp_unslash( $_POST['ti_po_number'] ) ) : '';

	if ( '' === $po ) {
		wc_add_notice( __( 'Please enter your purchase order number.', 'ti' ), 'error' );
		return;
	}

	if ( ! preg_match( '/^PO-\d{6}$/', $po ) ) {
		wc_add_notice( __( 'Purchase order numbers look like PO-123456.', 'ti' ), 'error' );
	}
}

Three details matter:

  • Read $_POST yourself. The hook fires before get_posted_data(), so there is no sanitised array yet, and WC()->customer still holds the address from the previous refresh. Unslash and clean every value.
  • The nonce is already checked. process_checkout() verifies woocommerce-process-checkout-nonce before the hook fires, so you do not need your own nonce for fields inside the checkout form.
  • Use the error type. Only error notices count. A notice or success message is shown, but the order goes through.

Saving the value is a separate step. Validate here, then store the field on the order through woocommerce_checkout_update_order_meta or the order object. Adding the field itself is covered in customising checkout fields.

Where it sits in the checkout

Order Step
1 Nonce verified; if it fails and the cart is empty, "session has expired" is thrown
2 woocommerce_before_checkout_process
3 Cart re-checked for emptiness
4 woocommerce_checkout_process
5 Posted data read and sanitised; session and totals updated
6 Built-in validation: required fields, email, phone, postcode, shipping, payment method
7 woocommerce_after_checkout_validation( $data, $errors )
8 Errors converted to notices; order created only if there are no error notices

Two consequences follow from this order:

  1. Your notices and WooCommerce's are shown together. Because the order is only blocked at step 8, a customer who leaves a required field empty and fails your check sees both messages at once.
  2. Totals are not final yet. At step 4 the cart still has the totals from the last refresh, before the submitted shipping method and address are applied. A rule like "orders over 500 need a PO number" should run at step 7, where the session and totals are up to date.

If the empty-cart check at step 1 or 3 fails, your hook never runs. See session has expired.

Attaching the error to a field

WooCommerce's own field errors pass the field ID as notice data, which renders as a data-id attribute on the error. To do the same, use woocommerce_after_checkout_validation, which gives you the WP_Error object WooCommerce itself uses:

add_action( 'woocommerce_after_checkout_validation', function ( $data, $errors ) {
	if ( WC()->cart->get_total( 'edit' ) > 500 && empty( $_POST['ti_po_number'] ) ) {
		$errors->add( 'ti_po_required', __( 'Orders over 500 need a purchase order number.', 'ti' ), array( 'id' => 'ti_po_number' ) );
	}
}, 10, 2 );

$data holds the sanitised core fields. Custom fields added through woocommerce_checkout_fields are included too. A field you print by hand in a template is not, which is why the example still reads $_POST.

The block checkout

The Cart and Checkout blocks submit through the Store API, /wp-json/wc/store/v1/checkout, and process_checkout() is never called. A snippet on woocommerce_checkout_process silently stops working the day a store switches to the block checkout. This is the most common reason the hook "does nothing".

The block equivalents are:

  • Additional checkout fields. Register the field with woocommerce_register_additional_checkout_field() and validate it on woocommerce_validate_additional_field, which receives a WP_Error, the field ID and the value.
  • Whole-order rules. Hook woocommerce_store_api_checkout_update_order_from_request, which receives the draft order and the REST request, and throw a RouteException to reject it:
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;

add_action( 'woocommerce_store_api_checkout_update_order_from_request', function ( WC_Order $order, WP_REST_Request $request ) {
	if ( $order->get_total() > 500 && '' === $order->get_billing_company() ) {
		throw new RouteException( 'ti_company_required', __( 'Orders over 500 need a company name for invoicing.', 'ti' ), 400 );
	}
}, 10, 2 );

A plain Exception also stops the order, but the Store API reports it as a 500 woocommerce_rest_unknown_server_error. RouteException lets you choose the code and status.

Stores running both checkouts, for example a classic checkout on one page and a block on another, need both paths.

Common mistakes

  • Using wc_add_notice() with the default type. It defaults to success, which does not block anything.
  • Echoing output or calling wp_die(). The checkout submits via ?wc-ajax=checkout and expects JSON. Stray output breaks the response, and the customer sees a generic error.
  • Reading WC()->customer for the submitted address. At this point it still has the previous values; use $_POST or move the check to woocommerce_after_checkout_validation.
  • Validating stock or prices here. WooCommerce already checks cart items, and per-item rules belong at add-to-cart time in woocommerce_add_to_cart_validation.

Verify

  1. Submit the classic checkout with the field empty, then invalid, then valid, and confirm the order is created only in the last case.
  2. Leave a core required field empty at the same time and confirm both errors appear together.
  3. Watch the ?wc-ajax=checkout response in the Network tab: "result":"failure" with your message in messages.
  4. If the store has a block checkout anywhere, repeat the test there and confirm the Store API path rejects it too.

Frequently asked

Almost always because the store uses the block checkout, which never fires woocommerce_checkout_process. If the page is classic, check that the notice type is 'error': success and notice types do not block the order.
For new code, woocommerce_after_checkout_validation is usually better. It receives the sanitised posted data and the WP_Error object, runs after WooCommerce's own field checks, and lets you attach errors to a field. woocommerce_checkout_process is for checks that need nothing but the raw request.
Yes. process_checkout() catches exceptions, turns the message into an error notice and returns the failure response. But it stops at the first exception, so customers see only one problem at a time. Adding notices lets them see every problem at once.

Related guides