Article WooCommerce

The Abilities API in practice: a safe WooCommerce ability for order status

The Abilities API lets AI tools and scripts discover and run what your plugin can do. Here we build a real ability that reads a WooCommerce order's status, and obsess over the part that matters most: the permission check.

Hamid Aarab Senior WordPress and WooCommerce Backend Developer
Published
Reading time
5 min read

Hamid Aarab October 19, 2026 5 min read

Pixel cover: a shield with a green check and an ability run output
Contents (7)

Since WordPress 7 arrived, I keep hearing the same question: “Is the Abilities API useful for my store, or is it just for AI?” The short answer: the Abilities API is a contract. You declare what your plugin can do, what input it takes, what it returns and who may run it. An AI assistant calling it over MCP, a simple script and your own mobile app all use that same contract.

In this note we build a real ability, “order status”. It is a small example, but it needs every important security decision.

First decide what you will not return

Before writing a line of code, list exactly what the output contains. For order status I return only the ID, status, total, currency and creation date. No customer name, no email, no address.

The reason is simple: whatever an ability returns will sooner or later end up somewhere you did not expect, such as a tool’s log, a language model’s context or a bug report screenshot. Data minimisation is a design requirement here, not legal advice.

Register the category and the ability

Every ability belongs to exactly one category, and the category must be registered first:

add_action( 'wp_abilities_api_categories_init', function () {
	wp_register_ability_category( 'store-orders', array(
		'label'       => __( 'Store orders', 'hamid-store' ),
		'description' => __( 'Read-only information about WooCommerce orders.', 'hamid-store' ),
	) );
} );

add_action( 'wp_abilities_api_init', function () {
	if ( ! function_exists( 'wc_get_order' ) ) {
		return; // WooCommerce is not active.
	}

	wp_register_ability( 'hamid-store/order-status', array(
		'label'               => __( 'Order status', 'hamid-store' ),
		'description'         => __( 'Returns the status, total and date of one order. Read-only.', 'hamid-store' ),
		'category'            => 'store-orders',
		'input_schema'        => array(
			'type'                 => 'object',
			'properties'           => array(
				'order_id' => array( 'type' => 'integer', 'minimum' => 1 ),
			),
			'required'             => array( 'order_id' ),
			'additionalProperties' => false,
		),
		'output_schema'       => array(
			'type'       => 'object',
			'properties' => array(
				'id'       => array( 'type' => 'integer' ),
				'status'   => array( 'type' => 'string' ),
				'total'    => array( 'type' => 'string' ),
				'currency' => array( 'type' => 'string' ),
				'created'  => array( 'type' => 'string' ),
			),
		),
		'execute_callback'    => 'hamid_store_order_status',
		'permission_callback' => 'hamid_store_can_read_order',
		'meta'                => array(
			'show_in_rest' => true,
			'annotations'  => array(
				'readonly'    => true,
				'destructive' => false,
				'idempotent'  => true,
			),
		),
	) );
} );

A few notes on this part:

  • The ability name: it must be namespaced, using only lowercase letters, digits, dashes and slashes.
  • **additionalProperties => false:** it rejects unexpected input. An ability that needs one number should not accept anything else.
  • Annotations: they are hints and enforce nothing. They do tell tools that this ability changes nothing, and the REST route expects GET for read-only abilities.
  • **show_in_rest:** it is off by default. Leave it off and the ability can only be run from PHP.

The part that matters: the permission check

permission_callback runs before every execution and receives the same input as execute_callback. This is where most security bugs happen:

function hamid_store_can_read_order( $input ) {
	// Shop managers and administrators can read any order.
	if ( current_user_can( 'edit_shop_orders' ) ) {
		return true;
	}

	// A logged-in customer can read their own orders only.
	$user_id = get_current_user_id();
	if ( $user_id <= 0 ) {
		return false;
	}

	$order = wc_get_order( absint( $input['order_id'] ?? 0 ) );

	return $order instanceof WC_Order && (int) $order->get_customer_id() === $user_id;
}

Do not underestimate if ( $user_id <= 0 ). Guest orders have a customer ID of zero, and so does a visitor who is not logged in. Without that line, 0 === 0 is true and any anonymous visitor can read every guest order. That is exactly the kind of broken access control that was the most exploited vulnerability class in WordPress security reports.

One more subtle point: when the order does not exist I return false, not “order not found”. A different message tells an attacker which numbers are real orders.

Running the ability

The execution itself is simple, because the permission check did the hard work. Just use WooCommerce’s CRUD layer, not get_post_meta, so the code keeps working with HPOS and whatever storage comes next:

function hamid_store_order_status( $input ) {
	$order = wc_get_order( absint( $input['order_id'] ?? 0 ) );

	if ( ! $order instanceof WC_Order ) {
		return new WP_Error( 'hamid_store_no_order', __( 'Order not available.', 'hamid-store' ) );
	}

	$created = $order->get_date_created();

	return array(
		'id'       => $order->get_id(),
		'status'   => $order->get_status(),
		'total'    => $order->get_total(),
		'currency' => $order->get_currency(),
		'created'  => $created ? $created->date( 'c' ) : '',
	);
}

Even though the input schema already says order_id is an integer, I still absint it in both callbacks. Defence in depth costs nothing here.

Calling it from PHP and REST

Inside WordPress:

$ability = wp_get_ability( 'hamid-store/order-status' );
$result  = $ability ? $ability->execute( array( 'order_id' => 1042 ) ) : null;

From outside, with an authenticated user (for example an Application Password). Because the ability is read-only, use GET with the JSON input in the input parameter:

curl -u 'shop-manager:xxxx xxxx xxxx xxxx' \
  "https://example.com/wp-json/wp-abilities/v1/hamid-store/order-status/run?input=%7B%22order_id%22%3A1042%7D"

Every wp-abilities/v1 route requires a logged-in user. If permission_callback refuses, the response is ability_invalid_permissions with status 403.

A checklist before you publish an ability

  1. Limit the output to what is needed and keep personal data out.
  2. Test permission_callback with three users: an admin, the customer who owns the order and a different customer. Test once logged out as well.
  3. Test a guest order separately, the case where the customer ID is zero.
  4. For anything that changes data, set readonly to false (and destructive to true where it applies), and make its permission check stricter than the read-only one.
  5. If it does not need to be called from outside, leave show_in_rest off.

The Abilities API is one of the best additions to WordPress in years, because we finally have a standard way to say “this is what my plugin can do”. But being standard also means any mistake in a permission check can be exploited with one simple request from outside. Treat abilities like REST endpoints, because in practice that is what they are.

Sources

$ ./request_review

Facing the same problem in your own store?

Senior WordPress and WooCommerce backend engineering for complex production problems.

Request a Technical Assessment

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *