Commit 7ebb4338f26 for woocommerce

commit 7ebb4338f26b8361f0411516a4736b9b831f04c6
Author: Néstor Soriano <konamiman@konamiman.com>
Date:   Wed Apr 15 11:22:54 2026 +0200

    Fix: missing handling for null meta keys/values in POST/PUT endpoints (#63971)

    * Fix: missing handling for null meta keys/values in POST/PUT endpoints.

    - Meta entries with a missing key were creating an entry with an empty
      key. Now these are discarded.
    - Meta entries with a missing value were throwing an "undefined array
      key" warning in PHP 8, these are now explicitly converted to null.

    Unit tests are added too.

    Affected endpoints:

    PUT /wc/v2/products/{id}
    PUT /wc/v2/products/{parent_id}/variations/{id}
    PUT /wc/v2/coupons/{id} (via V3 which extends V2)
    PUT /wc/v2/customers/{id}
    POST /wc/v2/orders/{order_id}/refunds
    PUT /wc/v2/orders/{id}
      (line items meta; already had the fix, just cleaned up to use ??)
    PUT /wc/v3/products/{id}
    PUT /wc/v3/products/{parent_id}/variations/{id}
    PUT /wc/v3/orders/{id}
    POST /wc/v3/orders/{order_id}/refunds
    PUT /wc/v3/coupons/{id} (inherits from V2)
    POST /wc/v4/refunds
    PUT /wc/v4/products/{id}
    PUT /wc/v4/orders/{id} (via UpdateUtils)
    PATCH /wc/v3/orders/{order_id}/fulfillments/{fulfillment_id}
    PATCH /wc/v3/orders/{order_id}/fulfillments/{fulfillment_id}/metadata

diff --git a/plugins/woocommerce/changelog/pr-63971 b/plugins/woocommerce/changelog/pr-63971
new file mode 100644
index 00000000000..320f6829838
--- /dev/null
+++ b/plugins/woocommerce/changelog/pr-63971
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix: missing handling for null meta keys/values in POST/PUT endpoints
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-coupons-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-coupons-v2-controller.php
index a7f8a98eeea..d98bf5d73ce 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-coupons-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-coupons-v2-controller.php
@@ -9,6 +9,7 @@
  * @since   2.6.0
  */

+use Automattic\WooCommerce\Utilities\MetaDataUtil;
 use Automattic\WooCommerce\Utilities\StringUtil;

 defined( 'ABSPATH' ) || exit;
@@ -309,11 +310,7 @@ class WC_REST_Coupons_V2_Controller extends WC_REST_CRUD_Controller {
 						$coupon->set_code( $coupon_code );
 						break;
 					case 'meta_data':
-						if ( is_array( $value ) ) {
-							foreach ( $value as $meta ) {
-								$coupon->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
-							}
-						}
+						MetaDataUtil::update( $value, $coupon );
 						break;
 					case 'description':
 						$coupon->set_description( wp_filter_post_kses( $value ) );
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-customers-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-customers-v2-controller.php
index 22d9f8d9f36..fda9febe41d 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-customers-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-customers-v2-controller.php
@@ -8,6 +8,8 @@
  * @since   2.6.0
  */

+use Automattic\WooCommerce\Utilities\MetaDataUtil;
+
 defined( 'ABSPATH' ) || exit;

 /**
@@ -130,14 +132,11 @@ class WC_REST_Customers_V2_Controller extends WC_REST_Customers_V1_Controller {

 		// Meta data.
 		if ( isset( $request['meta_data'] ) ) {
-			if ( is_array( $request['meta_data'] ) ) {
-				foreach ( $request['meta_data'] as $meta ) {
-					if ( is_protected_meta( $meta['key'], 'user' ) ) { // bypass internal keys.
-						continue;
-					}
-					$customer->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
-				}
-			}
+			$meta_data = array_filter(
+				MetaDataUtil::normalize( $request['meta_data'] ),
+				fn( $meta ) => ! is_protected_meta( $meta['key'], 'user' )
+			);
+			MetaDataUtil::update( $meta_data, $customer );
 		}
 	}

diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-order-refunds-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-order-refunds-v2-controller.php
index 0f9320876df..0ad619622e1 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-order-refunds-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-order-refunds-v2-controller.php
@@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit;

 use Automattic\WooCommerce\Enums\ProductTaxStatus;
 use Automattic\WooCommerce\Internal\Utilities\Types;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;

 /**
  * REST API Order Refunds controller class.
@@ -306,10 +307,8 @@ class WC_REST_Order_Refunds_V2_Controller extends WC_REST_Orders_V2_Controller {
 			return new WP_Error( 'woocommerce_rest_cannot_create_order_refund', __( 'Cannot create order refund, please try again.', 'woocommerce' ), 500 );
 		}

-		if ( ! empty( $request['meta_data'] ) && is_array( $request['meta_data'] ) ) {
-			foreach ( $request['meta_data'] as $meta ) {
-				$refund->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
-			}
+		if ( ! empty( $request['meta_data'] ) ) {
+			MetaDataUtil::update( $request['meta_data'], $refund );
 			$refund->save_meta_data();
 		}

diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
index 024b0566782..742e1c83b08 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Internal\Utilities\Users;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Utilities\ArrayUtil;
 use Automattic\WooCommerce\Utilities\OrderUtil;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
 use Automattic\WooCommerce\Utilities\StringUtil;

 // phpcs:disable Squiz.Classes.ClassFileName.NoMatch, Squiz.Classes.ValidClassName.NotCamelCaps -- Legacy class name, can't change without breaking backward compat.
@@ -924,14 +925,7 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
 	 * @param array         $posted Request data.
 	 */
 	protected function maybe_set_item_meta_data( $item, $posted ) {
-		if ( ! empty( $posted['meta_data'] ) && is_array( $posted['meta_data'] ) ) {
-			foreach ( $posted['meta_data'] as $meta ) {
-				if ( isset( $meta['key'] ) ) {
-					$value = isset( $meta['value'] ) ? $meta['value'] : null;
-					$item->update_meta_data( $meta['key'], $value, isset( $meta['id'] ) ? $meta['id'] : '' );
-				}
-			}
-		}
+		MetaDataUtil::update( $posted['meta_data'] ?? null, $item );
 	}

 	/**
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-variations-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-variations-v2-controller.php
index 1159329d4b7..98e8a499f81 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-variations-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-variations-v2-controller.php
@@ -12,6 +12,7 @@ use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\Enums\ProductTaxStatus;
 use Automattic\WooCommerce\Utilities\I18nUtil;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;

 defined( 'ABSPATH' ) || exit;

@@ -561,11 +562,7 @@ class WC_REST_Product_Variations_V2_Controller extends WC_REST_Products_V2_Contr
 		}

 		// Meta data.
-		if ( is_array( $request['meta_data'] ) ) {
-			foreach ( $request['meta_data'] as $meta ) {
-				$variation->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
-			}
-		}
+		MetaDataUtil::update( $request['meta_data'], $variation );

 		/**
 		 * Filters an object before it is inserted via the REST API.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
index ebc70f0de0f..5fb0e0ed512 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Enums\CatalogVisibility;
 use Automattic\WooCommerce\Internal\Traits\RestApiCache;
 use Automattic\WooCommerce\Utilities\I18nUtil;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;

 defined( 'ABSPATH' ) || exit;

@@ -1424,11 +1425,7 @@ class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller {
 		}

 		// Allow set meta_data.
-		if ( is_array( $request['meta_data'] ) ) {
-			foreach ( $request['meta_data'] as $meta ) {
-				$product->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
-			}
-		}
+		MetaDataUtil::update( $request['meta_data'], $product );

 		/**
 		 * Filters an object before it is inserted via the REST API.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller.php
index bf224ae7f3b..b43ff4c7c2f 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller.php
@@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit;

 use Automattic\WooCommerce\Internal\RestApiParameterUtil;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;

 /**
  * REST API Order Refunds controller class.
@@ -70,10 +71,8 @@ class WC_REST_Order_Refunds_Controller extends WC_REST_Order_Refunds_V2_Controll
 			return new WP_Error( 'woocommerce_rest_cannot_create_order_refund', __( 'Cannot create order refund, please try again.', 'woocommerce' ), 500 );
 		}

-		if ( ! empty( $request['meta_data'] ) && is_array( $request['meta_data'] ) ) {
-			foreach ( $request['meta_data'] as $meta ) {
-				$refund->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
-			}
+		if ( ! empty( $request['meta_data'] ) ) {
+			MetaDataUtil::update( $request['meta_data'], $refund );
 			$refund->save_meta_data();
 		}

diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller.php
index f8f3f326528..4387452878a 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller.php
@@ -13,6 +13,7 @@ use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
 use Automattic\WooCommerce\Internal\Utilities\Users;
 use Automattic\WooCommerce\Utilities\ArrayUtil;
 use Automattic\WooCommerce\Utilities\OrderUtil;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
 use Automattic\WooCommerce\Utilities\StringUtil;

 defined( 'ABSPATH' ) || exit;
@@ -150,11 +151,7 @@ class WC_REST_Orders_Controller extends WC_REST_Orders_V2_Controller {
 						}
 						break;
 					case 'meta_data':
-						if ( is_array( $value ) ) {
-							foreach ( $value as $meta ) {
-								$order->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
-							}
-						}
+						MetaDataUtil::update( $value, $order );
 						break;
 					default:
 						if ( is_callable( array( $order, "set_{$key}" ) ) ) {
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
index 89ea057c8b5..1582a04d0b9 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
@@ -13,6 +13,7 @@ use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareRestControllerTrait;
 use Automattic\WooCommerce\Utilities\I18nUtil;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;

 defined( 'ABSPATH' ) || exit;

@@ -389,11 +390,7 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
 		}

 		// Meta data.
-		if ( is_array( $request['meta_data'] ) ) {
-			foreach ( $request['meta_data'] as $meta ) {
-				$variation->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
-			}
-		}
+		MetaDataUtil::update( $request['meta_data'], $variation );

 		if ( $this->cogs_is_enabled() ) {
 			$this->set_cogs_info_in_product_object( $request, $variation );
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
index 8941ec00ec1..7abd1ac3518 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Enums\CatalogVisibility;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareRestControllerTrait;
 use Automattic\WooCommerce\Utilities\I18nUtil;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;

 defined( 'ABSPATH' ) || exit;

@@ -1134,11 +1135,7 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 		}

 		// Allow set meta_data.
-		if ( is_array( $request['meta_data'] ) ) {
-			foreach ( $request['meta_data'] as $meta ) {
-				$product->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
-			}
-		}
+		MetaDataUtil::update( $request['meta_data'], $product );

 		if ( ! empty( $request['date_created'] ) ) {
 			$date = rest_parse_date( $request['date_created'] );
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 4ca0a17cf95..aab8436e6d3 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -68376,12 +68376,6 @@ parameters:
 			count: 1
 			path: src/Internal/RestApi/Routes/V4/Products/Controller.php

-		-
-			message: '#^Call to method update_meta_data\(\) on an unknown class Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Products\\WC_Product\.$#'
-			identifier: class.notFound
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
 		-
 			message: '#^Cannot access property \$public on WP_Post_Type\|null\.$#'
 			identifier: property.nonObject
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
index 0d3a3916fc3..430c34ffb03 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
@@ -8,6 +8,7 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\Admin\Features\Fulfillments;

 use Automattic\WooCommerce\Internal\Admin\Settings\Exceptions\ApiException;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
 use Automattic\WooCommerce\Internal\RestApiControllerBase;
 use WC_Order;
 use WP_Http;
@@ -385,17 +386,19 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 			$fulfillment->set_props( $request->get_json_params() );
 			$next_state = $fulfillment->get_is_fulfilled();

-			if ( isset( $request->get_json_params()['meta_data'] ) && is_array( $request->get_json_params()['meta_data'] ) ) {
-				// Update the meta data keys that exist in the request.
-				foreach ( $request->get_json_params()['meta_data'] as $meta ) {
-					$fulfillment->update_meta_data( $meta['key'], $meta['value'], $meta['id'] ?? 0 );
-				}
-
-				// Remove the meta data keys that don't exist in the request, by matching their keys.
-				$existing_meta_data = $fulfillment->get_meta_data();
-				foreach ( $existing_meta_data as $meta ) {
-					if ( ! in_array( $meta->key, array_column( $request->get_json_params()['meta_data'], 'key' ), true ) ) {
-						$fulfillment->delete_meta_data( $meta->key );
+			if ( isset( $request->get_json_params()['meta_data'] ) ) {
+				$meta_data       = $request->get_json_params()['meta_data'];
+				$normalized_keys = is_array( $meta_data ) ? array_column( MetaDataUtil::normalize( $meta_data, 0 ), 'key' ) : array();
+				MetaDataUtil::update( $meta_data, $fulfillment, 0 );
+
+				// Remove meta keys not in the request. Skip if all entries were malformed
+				// (non-empty input but no valid keys), to avoid accidental data loss.
+				if ( empty( $meta_data ) || ! empty( $normalized_keys ) ) {
+					$existing_meta_data = $fulfillment->get_meta_data();
+					foreach ( $existing_meta_data as $meta ) {
+						if ( ! in_array( $meta->key, $normalized_keys, true ) ) {
+							$fulfillment->delete_meta_data( $meta->key );
+						}
 					}
 				}
 			}
@@ -560,15 +563,18 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 			$this->validate_fulfillment( $fulfillment, $fulfillment_id, $order_id );

 			// Update the meta data keys that exist in the request.
-			foreach ( $request->get_json_params()['meta_data'] as $meta ) {
-				$fulfillment->update_meta_data( $meta['key'], $meta['value'], $meta['id'] ?? 0 );
-			}
+			$meta_data       = $request->get_json_params()['meta_data'];
+			$normalized_keys = is_array( $meta_data ) ? array_column( MetaDataUtil::normalize( $meta_data, 0 ), 'key' ) : array();
+			MetaDataUtil::update( $meta_data, $fulfillment, 0 );

-			// Remove the meta data keys that don't exist in the request, by matching their keys.
-			$existing_meta_data = $fulfillment->get_meta_data();
-			foreach ( $existing_meta_data as $meta ) {
-				if ( ! in_array( $meta->key, array_column( $request->get_json_params()['meta_data'], 'key' ), true ) ) {
-					$fulfillment->delete_meta_data( $meta->key );
+			// Remove meta keys not in the request. Skip if all entries were malformed
+			// (non-empty input but no valid keys), to avoid accidental data loss.
+			if ( empty( $meta_data ) || ! empty( $normalized_keys ) ) {
+				$existing_meta_data = $fulfillment->get_meta_data();
+				foreach ( $existing_meta_data as $meta ) {
+					if ( ! in_array( $meta->key, $normalized_keys, true ) ) {
+						$fulfillment->delete_meta_data( $meta->key );
+					}
 				}
 			}
 			$fulfillment->save();
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/UpdateUtils.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/UpdateUtils.php
index 3489f01ce5c..69eb0a49a50 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/UpdateUtils.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/UpdateUtils.php
@@ -17,6 +17,7 @@ use Automattic\WooCommerce\Enums\OrderItemType;
 use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
 use Automattic\WooCommerce\Utilities\ArrayUtil;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
 use Automattic\WooCommerce\Utilities\StringUtil;
 use Automattic\WooCommerce\Internal\Utilities\Users;
 use WC_REST_Exception;
@@ -147,9 +148,7 @@ class UpdateUtils {
 	 * @param array    $meta_data Posted data.
 	 */
 	protected function update_meta_data( WC_Order $order, array $meta_data ) {
-		foreach ( $meta_data as $meta ) {
-			$order->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
-		}
+		MetaDataUtil::update( $meta_data, $order );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
index ff2cb711175..dd0d89107c5 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
@@ -19,6 +19,7 @@ use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Enums\CatalogVisibility;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareRestControllerTrait;
 use Automattic\WooCommerce\Utilities\I18nUtil;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
 use WC_REST_Products_V2_Controller;
 use WP_REST_Server;
 use WP_REST_Request;
@@ -1215,11 +1216,7 @@ class Controller extends WC_REST_Products_V2_Controller {
 		}

 		// Allow set meta_data.
-		if ( is_array( $request['meta_data'] ) ) {
-			foreach ( $request['meta_data'] as $meta ) {
-				$product->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
-			}
-		}
+		MetaDataUtil::update( $request['meta_data'], $product ); // @phpstan-ignore argument.type (missing `use WC_Product` causes phantom namespace-local type)

 		if ( ! empty( $request['date_created'] ) ) {
 			$date = rest_parse_date( $request['date_created'] );
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
index 01b6a786419..37a8ce3d5ca 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
@@ -16,6 +16,7 @@ defined( 'ABSPATH' ) || exit;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
 use Automattic\WooCommerce\StoreApi\Utilities\Pagination;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundSchema;
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
 use Automattic\WooCommerce\Utilities\NumberUtil;
 use WP_Http;
 use WP_Error;
@@ -341,10 +342,8 @@ class Controller extends AbstractController {
 				return $this->get_route_error_response( 'cannot_create_refund', $refund->get_error_message() );
 			}

-			if ( ! empty( $request['meta_data'] ) && is_array( $request['meta_data'] ) ) {
-				foreach ( $request['meta_data'] as $meta ) {
-					$refund->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
-				}
+			if ( ! empty( $request['meta_data'] ) ) {
+				MetaDataUtil::update( $request['meta_data'], $refund );
 				$refund->save_meta_data();
 			}

diff --git a/plugins/woocommerce/src/Utilities/MetaDataUtil.php b/plugins/woocommerce/src/Utilities/MetaDataUtil.php
new file mode 100644
index 00000000000..11685bdfe0e
--- /dev/null
+++ b/plugins/woocommerce/src/Utilities/MetaDataUtil.php
@@ -0,0 +1,64 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Utilities;
+
+use WC_Data;
+
+/**
+ * Utility methods for handling meta data in REST API requests.
+ *
+ * @since 10.8.0
+ */
+class MetaDataUtil {
+
+	/**
+	 * Normalize and process meta data entries from a REST API request.
+	 *
+	 * Skips entries without a key, applies defaults for missing 'value' and 'id'
+	 * fields, then calls update_meta_data on the given WC_Data object
+	 * for each valid entry.
+	 *
+	 * @since 10.8.0
+	 *
+	 * @param mixed   $meta_data  Raw meta data from the request (non-array values are ignored).
+	 * @param WC_Data $target     A WC_Data object to call update_meta_data on.
+	 * @param mixed   $default_id Default value for 'id' when not provided (default '').
+	 */
+	public static function update( $meta_data, WC_Data $target, $default_id = '' ): void {
+		if ( ! is_array( $meta_data ) ) {
+			return;
+		}
+
+		foreach ( self::normalize( $meta_data, $default_id ) as $meta ) {
+			$target->update_meta_data( $meta['key'], $meta['value'], $meta['id'] );
+		}
+	}
+
+	/**
+	 * Normalize an array of raw meta data entries from a REST API request.
+	 *
+	 * Filters out entries without a key and applies default values for
+	 * missing 'value' and 'id' fields. Each returned entry is guaranteed
+	 * to have 'key', 'value', and 'id' set.
+	 *
+	 * @since 10.8.0
+	 *
+	 * @param array $meta_data Raw meta data array from the request.
+	 * @param mixed $default_id Default value for 'id' when not provided (default '').
+	 * @return array[] Normalized meta data entries.
+	 */
+	public static function normalize( array $meta_data, $default_id = '' ): array {
+		$normalized = array();
+		foreach ( $meta_data as $meta ) {
+			if ( is_array( $meta ) && isset( $meta['key'] ) ) {
+				$normalized[] = array(
+					'key'   => $meta['key'],
+					'value' => $meta['value'] ?? null,
+					'id'    => $meta['id'] ?? $default_id,
+				);
+			}
+		}
+		return $normalized;
+	}
+}
diff --git a/plugins/woocommerce/tests/legacy/bootstrap.php b/plugins/woocommerce/tests/legacy/bootstrap.php
index a8ede904145..32d2fa0d9cc 100644
--- a/plugins/woocommerce/tests/legacy/bootstrap.php
+++ b/plugins/woocommerce/tests/legacy/bootstrap.php
@@ -298,6 +298,7 @@ class WC_Unit_Tests_Bootstrap {
 		require_once dirname( $this->tests_dir ) . '/php/helpers/HPOSToggleTrait.php';
 		require_once dirname( $this->tests_dir ) . '/php/helpers/SerializingCacheTrait.php';
 		require_once dirname( $this->tests_dir ) . '/php/helpers/LoggerSpyTrait.php';
+		require_once dirname( $this->tests_dir ) . '/php/helpers/MetaDataAssertionTrait.php';
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/helpers/MetaDataAssertionTrait.php b/plugins/woocommerce/tests/php/helpers/MetaDataAssertionTrait.php
new file mode 100644
index 00000000000..81a0443993d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/helpers/MetaDataAssertionTrait.php
@@ -0,0 +1,59 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Helpers;
+
+use WC_Data;
+
+/**
+ * Trait MetaDataAssertionTrait.
+ *
+ * Provides shared test input and assertions for verifying that REST API
+ * controllers handle incomplete meta_data entries correctly.
+ */
+trait MetaDataAssertionTrait {
+
+	/**
+	 * Returns meta_data input covering all incomplete-entry cases.
+	 *
+	 * @return array[]
+	 */
+	private function get_incomplete_meta_data_input(): array {
+		return array(
+			array( 'value' => 'orphan_value' ),
+			array( 'key' => 'key_missing_value' ),
+			array(
+				'key'   => 'key_explicit_null',
+				'value' => null,
+			),
+			array(),
+			array(
+				'key'   => 'complete_key',
+				'value' => 'complete_value',
+			),
+		);
+	}
+
+	/**
+	 * Asserts that a WC_Data object processed incomplete meta_data entries correctly:
+	 * - Complete entries are saved.
+	 * - Entries without a key are not processed.
+	 * - Entries with a missing value behave the same as passing null explicitly.
+	 *
+	 * @param WC_Data $wc_data The object whose meta data to check.
+	 */
+	private function assert_incomplete_meta_data_handled_correctly( WC_Data $wc_data ): void {
+		$meta_by_key = array();
+		foreach ( $wc_data->get_meta_data() as $meta ) {
+			$meta_by_key[ $meta->key ] = $meta->value;
+		}
+
+		$this->assertEquals( 'complete_value', $meta_by_key['complete_key'] ?? null, 'Complete entry should be saved' );
+		$this->assertArrayNotHasKey( '', $meta_by_key, 'Entry without key should not create a meta data row' );
+		$this->assertSame(
+			$meta_by_key['key_missing_value'] ?? 'NOT_FOUND',
+			$meta_by_key['key_explicit_null'] ?? 'NOT_FOUND',
+			'Missing value should be equivalent to explicit null'
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-customers-v2-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-customers-v2-controller-tests.php
index 443809a0f56..4f5a2533e86 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-customers-v2-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-customers-v2-controller-tests.php
@@ -2,10 +2,14 @@
 declare( strict_types = 1 );

 // phpcs:disable Squiz.Classes.ClassFileName.NoMatch, Squiz.Classes.ValidClassName.NotCamelCaps -- legacy conventions.
+
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
+
 /**
  * Tests relating to WC_REST_Customers_V2_Controller.
  */
 class WC_REST_Customers_V2_Controller_Tests extends WC_Unit_Test_Case {
+	use MetaDataAssertionTrait;

 	/**
 	 * @var WC_REST_Customers_V2_Controller System under test.
@@ -193,4 +197,24 @@ class WC_REST_Customers_V2_Controller_Tests extends WC_Unit_Test_Case {
 		$this->assertEquals( 'test_value', $customer->get_meta( 'test_key' ) );
 		$this->assertEmpty( $customer->get_meta( '_internal_test_key' ) );
 	}
+
+	/**
+	 * @testdox Updating a customer with incomplete meta_data entries does not cause errors.
+	 */
+	public function test_update_meta_data_with_incomplete_entries(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$api_request = new WP_REST_Request( 'PUT', '/wc/v2/customers/' );
+		$api_request->set_body_params(
+			array(
+				'id'        => $this->admin_id,
+				'meta_data' => $this->get_incomplete_meta_data_input(),
+			)
+		);
+
+		$response = $this->sut->update_item( $api_request );
+		$this->assertEquals( 200, $response->get_status() );
+
+		$this->assert_incomplete_meta_data_handled_correctly( new \WC_Customer( $this->admin_id ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-order-refunds-v2-controller-test.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-order-refunds-v2-controller-test.php
index 27fbc017dac..267e817d4bf 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-order-refunds-v2-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-order-refunds-v2-controller-test.php
@@ -3,7 +3,14 @@
 /**
  * Class WC_REST_Order_Refunds_Controller_Test.
  */
+
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
+
+/**
+ * Tests for the V2 Order Refunds REST API controller.
+ */
 class WC_REST_Order_Refunds_V2_Controller_Test extends WC_REST_Unit_Test_Case {
+	use MetaDataAssertionTrait;

 	/**
 	 * Test if line, fees and shipping items are all included in refund response.
@@ -56,4 +63,31 @@ class WC_REST_Order_Refunds_V2_Controller_Test extends WC_REST_Unit_Test_Case {
 		$this->assertContains( 'shipping_lines', array_keys( $data ) );
 		$this->assertEquals( -20, $data['shipping_lines'][0]['total'] );
 	}
+
+	/**
+	 * @testdox Creating a V2 refund with incomplete meta_data entries does not cause errors.
+	 */
+	public function test_create_refund_meta_data_with_incomplete_entries(): void {
+		wp_set_current_user( 1 );
+		$order = WC_Helper_Order::create_order();
+		$order->set_status( 'completed' );
+		$order->save();
+
+		$request = new WP_REST_Request( 'POST', '/wc/v2/orders/' . $order->get_id() . '/refunds' );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'amount'     => '1.00',
+					'api_refund' => false,
+					'meta_data'  => $this->get_incomplete_meta_data_input(),
+				)
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$this->assertEquals( 201, $response->get_status() );
+
+		$this->assert_incomplete_meta_data_handled_correctly( wc_get_order( $response->get_data()['id'] ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
index ee019e04905..bf072c76013 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
@@ -1,12 +1,15 @@
 <?php

 use Automattic\WooCommerce\Utilities\ArrayUtil;
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;

 /**
  * class WC_REST_Products_Controller_Tests.
  * Product Controller tests for V2 REST API.
  */
 class WC_REST_Products_V2_Controller_Test extends WC_REST_Unit_Test_Case {
+	use MetaDataAssertionTrait;
+
 	/**
 	 * @var WC_Product_Simple[]
 	 */
@@ -423,4 +426,20 @@ class WC_REST_Products_V2_Controller_Test extends WC_REST_Unit_Test_Case {

 		$this->assertEqualsCanonicalizing( $expected_obtained_data, $actual_data );
 	}
+
+	/**
+	 * @testdox Updating a product via V2 with incomplete meta_data entries does not cause errors.
+	 */
+	public function test_update_meta_data_with_incomplete_entries(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$request = new WP_REST_Request( 'POST', '/wc/v2/products/' . $product->get_id() );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body( wp_json_encode( array( 'meta_data' => $this->get_incomplete_meta_data_input() ) ) );
+
+		$response = $this->server->dispatch( $request );
+		$this->assertEquals( 200, $response->get_status() );
+
+		$this->assert_incomplete_meta_data_handled_correctly( wc_get_product( $product->get_id() ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-coupons-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-coupons-controller-tests.php
index 370e3496a69..b8d1b89218b 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-coupons-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-coupons-controller-tests.php
@@ -4,7 +4,14 @@
  * class WC_REST_Coupons_Controller_Tests.
  * Coupons Controller tests for V3 REST API.
  */
+
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
+
+/**
+ * Coupons Controller tests for V3 REST API.
+ */
 class WC_REST_Coupons_Controller_Tests extends WC_REST_Unit_Test_Case {
+	use MetaDataAssertionTrait;



@@ -320,4 +327,21 @@ class WC_REST_Coupons_Controller_Tests extends WC_REST_Unit_Test_Case {
 		$coupon = new WC_Coupon( $data['id'] );
 		$this->assertEquals( 'draft', $coupon->get_status() );
 	}
+
+	/**
+	 * @testdox Updating a coupon with incomplete meta_data entries does not cause errors.
+	 */
+	public function test_update_meta_data_with_incomplete_entries(): void {
+		wp_set_current_user( $this->user );
+		$coupon = \WC_Helper_Coupon::create_coupon();
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/coupons/' . $coupon->get_id() );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body( wp_json_encode( array( 'meta_data' => $this->get_incomplete_meta_data_input() ) ) );
+
+		$response = $this->server->dispatch( $request );
+		$this->assertEquals( 200, $response->get_status() );
+
+		$this->assert_incomplete_meta_data_handled_correctly( new \WC_Coupon( $coupon->get_id() ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller-test.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller-test.php
index c306efcfa71..723e8a0cbb1 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller-test.php
@@ -3,7 +3,14 @@
 /**
  * Class WC_REST_Order_Refunds_Controller_Test.
  */
+
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
+
+/**
+ * Tests for the V3 Order Refunds REST API controller.
+ */
 class WC_REST_Order_Refunds_Controller_Test extends WC_REST_Unit_Test_Case {
+	use MetaDataAssertionTrait;

 	/**
 	 * Test if line, fees and shipping items are all included in refund response.
@@ -56,4 +63,31 @@ class WC_REST_Order_Refunds_Controller_Test extends WC_REST_Unit_Test_Case {
 		$this->assertContains( 'shipping_lines', array_keys( $data ) );
 		$this->assertEquals( -20, $data['shipping_lines'][0]['total'] );
 	}
+
+	/**
+	 * @testdox Creating a refund with incomplete meta_data entries does not cause errors.
+	 */
+	public function test_create_refund_meta_data_with_incomplete_entries(): void {
+		wp_set_current_user( 1 );
+		$order = WC_Helper_Order::create_order();
+		$order->set_status( 'completed' );
+		$order->save();
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/orders/' . $order->get_id() . '/refunds' );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'amount'     => '1.00',
+					'api_refund' => false,
+					'meta_data'  => $this->get_incomplete_meta_data_input(),
+				)
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$this->assertEquals( 201, $response->get_status() );
+
+		$this->assert_incomplete_meta_data_handled_correctly( wc_get_order( $response->get_data()['id'] ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php
index 58fea1ad66b..eed4b0bf2eb 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php
@@ -6,6 +6,7 @@ use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableControlle
 use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
 use Automattic\WooCommerce\Proxies\LegacyProxy;
 use Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper;
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;

 /**
  * class WC_REST_Orders_Controller_Tests.
@@ -14,6 +15,7 @@ use Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper;
 class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
 	use HPOSToggleTrait;
 	use CogsAwareUnitTestSuiteTrait;
+	use MetaDataAssertionTrait;

 	/**
 	 * Setup our test server, endpoints, and user info.
@@ -922,4 +924,20 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
 		$item->save();
 		$order->add_item( $item );
 	}
+
+	/**
+	 * @testdox Updating an order with incomplete meta_data entries does not cause errors.
+	 */
+	public function test_update_meta_data_with_incomplete_entries(): void {
+		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order( $this->user );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order->get_id() );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body( wp_json_encode( array( 'meta_data' => $this->get_incomplete_meta_data_input() ) ) );
+
+		$response = $this->server->dispatch( $request );
+		$this->assertEquals( 200, $response->get_status() );
+
+		$this->assert_incomplete_meta_data_handled_correctly( wc_get_order( $order->get_id() ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
index b9a964662b7..3482165fc2e 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
@@ -2,12 +2,14 @@
 declare( strict_types=1 );

 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareUnitTestSuiteTrait;
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;

 /**
  * Variations Controller tests for V3 REST API.
  */
 class WC_REST_Product_Variations_Controller_Tests extends WC_REST_Unit_Test_Case {
 	use CogsAwareUnitTestSuiteTrait;
+	use MetaDataAssertionTrait;

 	/**
 	 * Runs before each test.
@@ -840,4 +842,21 @@ class WC_REST_Product_Variations_Controller_Tests extends WC_REST_Unit_Test_Case
 		$this->assertContains( $variations[0]->get_id(), $variation_ids );
 		$this->assertContains( $variations[1]->get_id(), $variation_ids );
 	}
+
+	/**
+	 * @testdox Updating a variation with incomplete meta_data entries does not cause errors.
+	 */
+	public function test_update_meta_data_with_incomplete_entries(): void {
+		$parent    = WC_Helper_Product::create_variation_product();
+		$variation = wc_get_product( $parent->get_children()[0] );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $parent->get_id() . '/variations/' . $variation->get_id() );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body( wp_json_encode( array( 'meta_data' => $this->get_incomplete_meta_data_input() ) ) );
+
+		$response = $this->server->dispatch( $request );
+		$this->assertEquals( 200, $response->get_status() );
+
+		$this->assert_incomplete_meta_data_handled_correctly( wc_get_product( $variation->get_id() ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
index 3decf623f2b..896dd63fa95 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
@@ -3,6 +3,7 @@
 use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareUnitTestSuiteTrait;
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;

 /**
  * class WC_REST_Products_Controller_Tests.
@@ -10,6 +11,7 @@ use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareUnitTestSuiteTrait;
  */
 class WC_REST_Products_Controller_Tests extends WC_REST_Unit_Test_Case {
 	use CogsAwareUnitTestSuiteTrait;
+	use MetaDataAssertionTrait;

 	/**
 	 * Saves the `woocommerce_hide_out_of_stock_items` option value for restoration after tests that modify it.
@@ -2114,4 +2116,20 @@ class WC_REST_Products_Controller_Tests extends WC_REST_Unit_Test_Case {
 		$this->assertContains( $visible_product->get_id(), $product_ids );
 		$this->assertContains( $hidden_product->get_id(), $product_ids );
 	}
+
+	/**
+	 * @testdox Updating a product with incomplete meta_data entries does not cause errors.
+	 */
+	public function test_update_meta_data_with_incomplete_entries(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/products/' . $product->get_id() );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body( wp_json_encode( array( 'meta_data' => $this->get_incomplete_meta_data_input() ) ) );
+
+		$response = $this->server->dispatch( $request );
+		$this->assertEquals( 200, $response->get_status() );
+
+		$this->assert_incomplete_meta_data_handled_correctly( wc_get_product( $product->get_id() ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php
index 738f48ffe7e..5b68c8f1fd5 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php
@@ -3,6 +3,7 @@ declare( strict_types=1 );

 use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Controller as RefundsController;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundSchema;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\CollectionQuery;
@@ -15,6 +16,7 @@ use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils;
  */
 class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 	use HPOSToggleTrait;
+	use MetaDataAssertionTrait;

 	/**
 	 * Endpoint instance.
@@ -1310,4 +1312,28 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 		// Clean up product.
 		$product->delete( true );
 	}
+
+	/**
+	 * @testdox Creating a V4 refund with incomplete meta_data entries does not cause errors.
+	 */
+	public function test_create_refund_meta_data_with_incomplete_entries(): void {
+		$order = $this->create_test_order();
+
+		$request = new \WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$request->set_body_params(
+			array(
+				'order_id'  => $order->get_id(),
+				'amount'    => 1.00,
+				'meta_data' => $this->get_incomplete_meta_data_input(),
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$this->assertEquals( 201, $response->get_status() );
+
+		$refund                  = wc_get_order( $response->get_data()['id'] );
+		$this->created_refunds[] = $refund->get_id();
+
+		$this->assert_incomplete_meta_data_handled_correctly( $refund );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Orders/UpdateUtilsTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Orders/UpdateUtilsTest.php
new file mode 100644
index 00000000000..881aaf99cc2
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Orders/UpdateUtilsTest.php
@@ -0,0 +1,91 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\RestApi\Routes\V4\Orders;
+
+use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\UpdateUtils;
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
+use WC_Order;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the UpdateUtils class.
+ */
+class UpdateUtilsTest extends WC_Unit_Test_Case {
+	use MetaDataAssertionTrait;
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var TestableUpdateUtils
+	 */
+	private $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new TestableUpdateUtils();
+	}
+
+	/**
+	 * @testdox Should handle incomplete meta_data entries without errors.
+	 */
+	public function test_update_meta_data_with_incomplete_entries(): void {
+		$order = wc_create_order();
+
+		$this->sut->call_update_meta_data( $order, $this->get_incomplete_meta_data_input() );
+
+		$this->assert_incomplete_meta_data_handled_correctly( $order );
+	}
+
+	/**
+	 * @testdox Should skip meta entries where key is explicitly null.
+	 */
+	public function test_update_meta_data_with_explicit_null_key(): void {
+		$order = wc_create_order();
+
+		$this->sut->call_update_meta_data(
+			$order,
+			array(
+				array(
+					'key'   => null,
+					'value' => 'null_key_value',
+				),
+				array(
+					'key'   => 'valid_key',
+					'value' => 'valid_value',
+				),
+			)
+		);
+
+		$meta_by_key = array();
+		foreach ( $order->get_meta_data() as $meta ) {
+			$meta_by_key[ $meta->key ] = $meta->value;
+		}
+
+		$this->assertEquals( 'valid_value', $meta_by_key['valid_key'] ?? null, 'Valid entry should be saved' );
+		$this->assertArrayNotHasKey( '', $meta_by_key, 'Explicit null key should not create a meta data row' );
+	}
+}
+
+/**
+ * Testable subclass that exposes the protected update_meta_data method.
+ *
+ * phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound
+ * phpcs:disable Squiz.Classes.ClassFileName.NoMatch
+ * phpcs:disable Suin.Classes.PSR4.IncorrectClassName
+ */
+class TestableUpdateUtils extends UpdateUtils {
+
+	/**
+	 * Public wrapper for the protected update_meta_data method.
+	 *
+	 * @param WC_Order $order     Order object.
+	 * @param array    $meta_data Meta data array.
+	 */
+	public function call_update_meta_data( WC_Order $order, array $meta_data ) {
+		$this->update_meta_data( $order, $meta_data );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
index f339e591f8b..febf312589b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
@@ -7,6 +7,7 @@ use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Products\Controller as ProductsController;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareUnitTestSuiteTrait;
+use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
 use WC_Helper_Product;
 use WC_REST_Unit_Test_Case;
 use WP_REST_Request;
@@ -19,6 +20,7 @@ use WP_REST_Request;
  */
 class ProductsControllerTest extends WC_REST_Unit_Test_Case {
 	use CogsAwareUnitTestSuiteTrait;
+	use MetaDataAssertionTrait;


 	/**
@@ -2127,6 +2129,20 @@ class ProductsControllerTest extends WC_REST_Unit_Test_Case {
 		$this->assertEquals( 'woocommerce_rest_cannot_view', $response->get_data()['code'] );
 	}

+	/**
+	 * @testdox Updating a product via V4 with incomplete meta_data entries does not cause errors.
+	 */
+	public function test_update_meta_data_with_incomplete_entries(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$this->update_product_via_post_request(
+			$product,
+			array( 'meta_data' => $this->get_incomplete_meta_data_input() )
+		);
+
+		$this->assert_incomplete_meta_data_handled_correctly( wc_get_product( $product->get_id() ) );
+	}
+
 	/**
 	 * @testdox Should strip sensitive fields from response when author views a published product.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Utilities/MetaDataUtilTest.php b/plugins/woocommerce/tests/php/src/Utilities/MetaDataUtilTest.php
new file mode 100644
index 00000000000..f93463e522e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Utilities/MetaDataUtilTest.php
@@ -0,0 +1,186 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Utilities;
+
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the MetaDataUtil class.
+ */
+class MetaDataUtilTest extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox `normalize` keeps complete entries and applies all fields.
+	 */
+	public function test_normalize_keeps_complete_entries(): void {
+		$result = MetaDataUtil::normalize(
+			array(
+				array(
+					'key'   => 'color',
+					'value' => 'red',
+					'id'    => 42,
+				),
+			)
+		);
+
+		$this->assertCount( 1, $result );
+		$this->assertSame( 'color', $result[0]['key'] );
+		$this->assertSame( 'red', $result[0]['value'] );
+		$this->assertSame( 42, $result[0]['id'] );
+	}
+
+	/**
+	 * @testdox `normalize` filters out entries without a key.
+	 */
+	public function test_normalize_filters_entries_without_key(): void {
+		$result = MetaDataUtil::normalize(
+			array(
+				array( 'value' => 'orphan' ),
+				array(),
+				array(
+					'key'   => 'valid',
+					'value' => 'val',
+				),
+			)
+		);
+
+		$this->assertCount( 1, $result );
+		$this->assertSame( 'valid', $result[0]['key'] );
+	}
+
+	/**
+	 * @testdox `normalize` filters out entries where key is explicitly null.
+	 */
+	public function test_normalize_filters_entries_with_null_key(): void {
+		$result = MetaDataUtil::normalize(
+			array(
+				array(
+					'key'   => null,
+					'value' => 'val',
+				),
+			)
+		);
+
+		$this->assertCount( 0, $result );
+	}
+
+	/**
+	 * @testdox `normalize` defaults missing value to null.
+	 */
+	public function test_normalize_defaults_missing_value_to_null(): void {
+		$result = MetaDataUtil::normalize(
+			array(
+				array( 'key' => 'flag' ),
+			)
+		);
+
+		$this->assertCount( 1, $result );
+		$this->assertNull( $result[0]['value'] );
+	}
+
+	/**
+	 * @testdox `normalize` defaults missing id to empty string.
+	 */
+	public function test_normalize_defaults_missing_id_to_empty_string(): void {
+		$result = MetaDataUtil::normalize(
+			array(
+				array(
+					'key'   => 'k',
+					'value' => 'v',
+				),
+			)
+		);
+
+		$this->assertSame( '', $result[0]['id'] );
+	}
+
+	/**
+	 * @testdox `normalize` uses the provided default_id.
+	 */
+	public function test_normalize_uses_custom_default_id(): void {
+		$result = MetaDataUtil::normalize(
+			array(
+				array(
+					'key'   => 'k',
+					'value' => 'v',
+				),
+			),
+			0
+		);
+
+		$this->assertSame( 0, $result[0]['id'] );
+	}
+
+	/**
+	 * @testdox `update` calls update_meta_data on a WC_Data object for each valid entry.
+	 */
+	public function test_update_with_wc_data_object(): void {
+		$order = wc_create_order();
+
+		MetaDataUtil::update(
+			array(
+				array( 'value' => 'orphan' ),
+				array(
+					'key'   => 'color',
+					'value' => 'blue',
+				),
+			),
+			$order
+		);
+
+		$meta_by_key = array();
+		foreach ( $order->get_meta_data() as $meta ) {
+			$meta_by_key[ $meta->key ] = $meta->value;
+		}
+
+		$this->assertArrayHasKey( 'color', $meta_by_key );
+		$this->assertSame( 'blue', $meta_by_key['color'] );
+		$this->assertArrayNotHasKey( '', $meta_by_key, 'Keyless entry should not be processed' );
+	}
+
+	/**
+	 * @testdox `update` does nothing when meta_data is not an array.
+	 */
+	public function test_update_ignores_non_array_meta_data(): void {
+		$order = wc_create_order();
+
+		MetaDataUtil::update( null, $order );
+		MetaDataUtil::update( 'string', $order );
+
+		$this->assertEmpty( $order->get_meta_data(), 'No meta should be added for non-array meta_data' );
+	}
+
+	/**
+	 * @testdox `update` throws TypeError when target is not a WC_Data instance.
+	 */
+	public function test_update_throws_for_invalid_target(): void {
+		$this->expectException( \TypeError::class );
+
+		MetaDataUtil::update( array(), 'not_a_wc_data_object' );
+	}
+
+	/**
+	 * @testdox `update` passes custom default_id through to normalize.
+	 */
+	public function test_update_passes_default_id(): void {
+		$order = wc_create_order();
+
+		MetaDataUtil::update(
+			array(
+				array(
+					'key'   => 'k',
+					'value' => 'v',
+				),
+			),
+			$order,
+			99
+		);
+
+		$meta_data = $order->get_meta_data();
+		$this->assertCount( 1, $meta_data );
+		$this->assertSame( 'k', $meta_data[0]->key );
+		$this->assertSame( 'v', $meta_data[0]->value );
+	}
+}