Commit 6b7da2a944d for woocommerce

commit 6b7da2a944de38e2a9f59aad1326038463fc33b2
Author: Francesco <frosso@users.noreply.github.com>
Date:   Fri Sep 11 13:17:19 2026 +0200

    feat: add raw_key to cart item_data in the Store API (#68388)

diff --git a/docs/apis/store-api/resources-endpoints/cart-items.md b/docs/apis/store-api/resources-endpoints/cart-items.md
index 987219fabf9..bb049916c14 100644
--- a/docs/apis/store-api/resources-endpoints/cart-items.md
+++ b/docs/apis/store-api/resources-endpoints/cart-items.md
@@ -190,6 +190,35 @@ curl "https://example-store.com/wp-json/wc/store/v1/cart/items"
 ]
 ```

+## Item data
+
+Each cart item carries display metadata in `item_data`, a list of entries built by callbacks on the `woocommerce_get_item_data` filter:
+
+| Property | Type | Description |
+| --- | --- | --- |
+| `raw_key` | string | Machine-readable name for the entry, set by the extension that added it. Never translated, so clients can match on it. |
+| `name` | string | Name of the metadata, for display. Some extensions send `key` instead. |
+| `value` | string | Value of the metadata. |
+| `display` | string | Optionally, how the value should be displayed. |
+
+`raw_key` was added in WooCommerce 11.2.0. Entries that omit it are unchanged.
+
+Most stores return an empty list. Entries appear when an extension adds them, as Product Add-Ons, Bookings, Deposits and Gift Cards do.
+
+Both `name` and `key` hold a label meant for a shopper to read, so both can be translated and neither is safe to match on. Use `raw_key` to find your own entry.
+
+Every value in an entry, `raw_key` included, is passed through [`wp_kses_post()`](https://developer.wordpress.org/reference/functions/wp_kses_post/). That rewrites some characters — a bare `&` arrives as `&amp;`, so an equality check against the value you set will not match. Keep `raw_key` to lowercase letters, digits, `_`, `-` and `/`.
+
+Extensions can add properties beyond the four above through the same filter. Note that the endpoint checks each entry as a whole: if any one property holds a value that is not a scalar (an array, an object, `null`), the **entire entry** is dropped, not just that property.
+
+The order endpoint has an `item_data` list too, and it uses the opposite convention: there `key` is the raw metadata key and `display_key` is the label. See [Order](./order.md#item-data).
+
+### Data you do not want displayed
+
+`item_data` is for metadata shown next to the cart item. Anything else belongs in your `extensions` namespace on the cart item, which the Store API is built to carry. See [Cart Items](../extending-store-api/available-endpoints-to-extend.md#cart-items).
+
+Setting `hidden` on an entry keeps it out of the cart, the mini-cart and the classic templates, so this is not broken — but it leaves the data identified only by a label field, which is a poor fit for something never shown, and any client written against the schema still receives it.
+
 ## Single Cart Item

 Get a single cart item by its key.
diff --git a/plugins/woocommerce/changelog/add-cart-item-data-raw-key b/plugins/woocommerce/changelog/add-cart-item-data-raw-key
new file mode 100644
index 00000000000..889daf981a2
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-cart-item-data-raw-key
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Store API: add `raw_key` to cart item `item_data` so extensions can identify their own entries without matching a translated label.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/utils/item-data.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/utils/item-data.ts
index c8d0bc62484..b677041b1aa 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/utils/item-data.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/utils/item-data.ts
@@ -17,6 +17,8 @@
 export type ItemData = {
 	/** Raw (non-display) attribute name, used by variation entries. */
 	raw_attribute?: string | undefined;
+	/** Raw (non-display) name, used by item_data entries. */
+	raw_key?: string | undefined;
 	/** Raw (non-display) value. */
 	value?: string | undefined;
 	/** Display-ready value; preferred over `value` when present. */
diff --git a/plugins/woocommerce/client/blocks/changelog/add-cart-item-data-raw-key b/plugins/woocommerce/client/blocks/changelog/add-cart-item-data-raw-key
new file mode 100644
index 00000000000..889daf981a2
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/changelog/add-cart-item-data-raw-key
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Store API: add `raw_key` to cart item `item_data` so extensions can identify their own entries without matching a translated label.
diff --git a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
index 2b5c2d9ea91..254b3643d37 100644
--- a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
+++ b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
@@ -1096,6 +1096,8 @@ apply_filters( 'woocommerce_get_item_data', array $item_data, array $cart_item )

 Allows extensions to attach their own name/value pairs to a cart item, which the Store API returns in the item's `item_data` field.

+Set `raw_key` so clients can find your entry without matching a translated label. Data you do not intend to display belongs in your `extensions` namespace.
+
 ### Parameters

 | Argument | Type | Description |
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/types/type-defs/product-response.ts b/plugins/woocommerce/client/blocks/packages/public-api/types/type-defs/product-response.ts
index b6562c9d871..2847d20d4d8 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/types/type-defs/product-response.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/types/type-defs/product-response.ts
@@ -13,6 +13,12 @@ export interface ProductResponseItemPrices extends CurrencyResponse {
 export interface ProductResponseItemBaseData {
 	value: string;
 	display?: string;
+	/**
+	 * Machine-readable name for the entry, never translated, so it is safe to
+	 * match on. Entity-encoded like every other value here, so a bare `&`
+	 * arrives as `&amp;`. Added in WooCommerce 11.2.0.
+	 */
+	raw_key?: string;
 	/**
 	 * Truthy marks the entry hidden. Always a string: the Store API runs every
 	 * `item_data` value through `wp_kses_post()`, which string-coerces, so a
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/CartItemSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/CartItemSchema.php
index 92a82df602a..d01650dd22f 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/CartItemSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/CartItemSchema.php
@@ -160,6 +160,9 @@ class CartItemSchema extends ItemSchema {
 		 * Allows extensions to attach their own name/value pairs to a cart item, which the Store
 		 * API returns in the item's `item_data` field.
 		 *
+		 * Set `raw_key` so clients can find your entry without matching a translated label.
+		 * Data you do not intend to display belongs in your `extensions` namespace.
+		 *
 		 * @since 4.3.0
 		 *
 		 * @internal Matches filter name in WooCommerce core.
@@ -171,9 +174,7 @@ class CartItemSchema extends ItemSchema {
 		$item_data       = apply_filters( 'woocommerce_get_item_data', array(), $cart_item );
 		$clean_item_data = [];
 		foreach ( $item_data as $data ) {
-			// We will check each piece of data in the item data element to ensure it is scalar. Extensions could add arrays
-			// to this, which would cause a fatal in wp_strip_all_tags. If it is not scalar, we will return an empty array,
-			// which will be filtered out in get_item_data (after this function has run).
+			// A non-scalar value would fatal in wp_strip_all_tags, so drop the whole entry.
 			foreach ( $data as $data_value ) {
 				if ( ! is_scalar( $data_value ) ) {
 					continue 2;
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/ItemSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/ItemSchema.php
index 97dd79acaa2..aaf253aee5f 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/ItemSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/ItemSchema.php
@@ -175,6 +175,12 @@ abstract class ItemSchema extends ProductSchema {
 				'items'       => [
 					'type'       => 'object',
 					'properties' => [
+						'raw_key' => [
+							'description' => __( 'Machine-readable name for the metadata, set by the extension that added it. Never translated, so clients can match on it.', 'woocommerce' ),
+							'type'        => 'string',
+							'context'     => [ 'view', 'edit' ],
+							'readonly'    => true,
+						],
 						'name'    => [
 							'description' => __( 'Name of the metadata.', 'woocommerce' ),
 							'type'        => 'string',
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartItems.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartItems.php
index 9b26bbc7aa5..46a6931a026 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartItems.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartItems.php
@@ -381,6 +381,108 @@ class CartItems extends ControllerTestCase {
 		$this->assertArrayHasKey( 'catalog_visibility', $data );
 	}

+	/**
+	 * `raw_key` gives extensions a name to match on that is never translated.
+	 *
+	 * @testdox Cart item_data publishes raw_key and it matches the schema.
+	 */
+	public function test_item_data_raw_key_is_published_and_matches_the_schema() {
+		$filter = function ( $item_data ) {
+			$item_data[] = array(
+				'raw_key' => 'gift_message',
+				'name'    => 'Gift message',
+				'value'   => 'Happy birthday',
+				'display' => 'Happy birthday!',
+			);
+
+			return $item_data;
+		};
+		add_filter( 'woocommerce_get_item_data', $filter );
+
+		try {
+			$routes     = new \Automattic\WooCommerce\StoreApi\RoutesController( new \Automattic\WooCommerce\StoreApi\SchemaController( $this->mock_extend ) );
+			$controller = $routes->get( 'cart-items', 'v1' );
+			$cart       = WC()->cart->get_cart();
+			$response   = $controller->prepare_item_for_response( current( $cart ), new \WP_REST_Request() );
+
+			$entry = $response->get_data()['item_data'][0];
+			$this->assertSame( 'gift_message', $entry['raw_key'] );
+			$this->assertSame( 'Gift message', $entry['name'], 'The display label must be left alone.' );
+
+			$validate = new ValidateSchema( $controller->get_item_schema() );
+			$diff     = $validate->get_diff_from_object( $response->get_data() );
+			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
+			$this->assertEmpty( $diff, print_r( $diff, true ) );
+		} finally {
+			remove_filter( 'woocommerce_get_item_data', $filter );
+		}
+	}
+
+	/**
+	 * Extensions are told to keep `raw_key` to lowercase letters, digits, `_`, `-` and `/`.
+	 * This pins what they get if they do not.
+	 *
+	 * @testdox Cart item_data raw_key is entity-encoded like every other value.
+	 */
+	public function test_item_data_raw_key_is_entity_encoded() {
+		$filter = function ( $item_data ) {
+			$item_data[] = array(
+				'raw_key' => 'acme&co_gift',
+				'name'    => 'Gift message',
+				'value'   => 'Happy birthday',
+				'display' => 'Happy birthday!',
+			);
+
+			return $item_data;
+		};
+		add_filter( 'woocommerce_get_item_data', $filter );
+
+		try {
+			$routes     = new \Automattic\WooCommerce\StoreApi\RoutesController( new \Automattic\WooCommerce\StoreApi\SchemaController( $this->mock_extend ) );
+			$controller = $routes->get( 'cart-items', 'v1' );
+			$cart       = WC()->cart->get_cart();
+			$response   = $controller->prepare_item_for_response( current( $cart ), new \WP_REST_Request() );
+
+			$this->assertSame( 'acme&amp;co_gift', $response->get_data()['item_data'][0]['raw_key'] );
+		} finally {
+			remove_filter( 'woocommerce_get_item_data', $filter );
+		}
+	}
+
+	/**
+	 * Entries written before `raw_key` existed must keep working. No schema validation
+	 * here on purpose: the validator reports the fields such an entry omits.
+	 *
+	 * @testdox Cart item_data still passes through entries that set no raw_key.
+	 */
+	public function test_item_data_without_raw_key_is_unchanged() {
+		$filter = function ( $item_data ) {
+			$item_data[] = array(
+				'name'   => 'gifting_to_hidden',
+				'value'  => 'recipient@example.com',
+				'hidden' => true,
+			);
+
+			return $item_data;
+		};
+		add_filter( 'woocommerce_get_item_data', $filter );
+
+		try {
+			$routes     = new \Automattic\WooCommerce\StoreApi\RoutesController( new \Automattic\WooCommerce\StoreApi\SchemaController( $this->mock_extend ) );
+			$controller = $routes->get( 'cart-items', 'v1' );
+			$cart       = WC()->cart->get_cart();
+			$response   = $controller->prepare_item_for_response( current( $cart ), new \WP_REST_Request() );
+
+			$entry = $response->get_data()['item_data'][0];
+			$this->assertArrayNotHasKey( 'raw_key', $entry, 'raw_key must not be invented.' );
+			$this->assertSame( 'gifting_to_hidden', $entry['name'] );
+			$this->assertSame( 'recipient@example.com', $entry['value'] );
+			$this->assertSame( '1', $entry['hidden'], 'hidden is string-coerced by wp_kses_post().' );
+		} finally {
+			remove_filter( 'woocommerce_get_item_data', $filter );
+		}
+	}
+
 	/**
 	 * Test schema matches responses.
 	 *