Commit 55477aa43e for woocommerce

commit 55477aa43e6fd5646156d996d4c99b6ffcbe4eb2
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date:   Fri Feb 13 15:29:30 2026 +0000

    Persist device_locale and metadata through the full pipeline. (#63246)

    * Persist device_locale and metadata through the full pipeline.

    - Add device_locale and metadata to PushToken entity (properties, getters, constructor, required params).
    - Pass both fields from REST controller to data store in create and update paths.
    - Add both fields to data store SUPPORTED_META, read, create, and lookup methods.
    - Default to en_US locale and empty JSON for legacy tokens without the new fields.

diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
index a1d7db69ee..b308fec69d 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
@@ -27,8 +27,6 @@ use WP_Http;
  * tokens.
  *
  * @since 10.6.0
- *
- * @internal
  */
 class PushTokenRestController extends RestApiControllerBase {
 	/**
@@ -105,11 +103,13 @@ class PushTokenRestController extends RestApiControllerBase {
 	public function create( WP_REST_Request $request ) {
 		try {
 			$data = array(
-				'user_id'     => get_current_user_id(),
-				'token'       => $request->get_param( 'token' ),
-				'platform'    => $request->get_param( 'platform' ),
-				'device_uuid' => $request->get_param( 'device_uuid' ),
-				'origin'      => $request->get_param( 'origin' ),
+				'user_id'       => get_current_user_id(),
+				'token'         => $request->get_param( 'token' ),
+				'platform'      => $request->get_param( 'platform' ),
+				'device_uuid'   => $request->get_param( 'device_uuid' ),
+				'origin'        => $request->get_param( 'origin' ),
+				'device_locale' => $request->get_param( 'device_locale' ),
+				'metadata'      => $request->get_param( 'metadata' ),
 			);

 			$data_store = wc_get_container()->get( PushTokensDataStore::class );
@@ -118,6 +118,8 @@ class PushTokenRestController extends RestApiControllerBase {
 			if ( $push_token ) {
 				$push_token->set_token( $data['token'] );
 				$push_token->set_device_uuid( $data['device_uuid'] );
+				$push_token->set_device_locale( $data['device_locale'] );
+				$push_token->set_metadata( $data['metadata'] );
 				$data_store->update( $push_token );
 			} else {
 				$push_token = $data_store->create( $data );
@@ -296,7 +298,7 @@ class PushTokenRestController extends RestApiControllerBase {
 	 */
 	private function get_args( ?string $context = null ): array {
 		$args = array(
-			'id'          => array(
+			'id'            => array(
 				'description'       => __( 'Push Token ID', 'woocommerce' ),
 				'type'              => 'integer',
 				'required'          => true,
@@ -305,7 +307,7 @@ class PushTokenRestController extends RestApiControllerBase {
 				'sanitize_callback' => 'absint',
 				'validate_callback' => array( $this, 'validate_argument' ),
 			),
-			'origin'      => array(
+			'origin'        => array(
 				'description'       => __( 'Origin', 'woocommerce' ),
 				'type'              => 'string',
 				'required'          => true,
@@ -313,7 +315,7 @@ class PushTokenRestController extends RestApiControllerBase {
 				'enum'              => PushToken::ORIGINS,
 				'validate_callback' => array( $this, 'validate_argument' ),
 			),
-			'device_uuid' => array(
+			'device_uuid'   => array(
 				'description'       => __( 'Device UUID', 'woocommerce' ),
 				'default'           => '',
 				'type'              => 'string',
@@ -321,7 +323,15 @@ class PushTokenRestController extends RestApiControllerBase {
 				'validate_callback' => array( $this, 'validate_argument' ),
 				'sanitize_callback' => 'sanitize_text_field',
 			),
-			'platform'    => array(
+			'device_locale' => array(
+				'description'       => __( 'Device Locale', 'woocommerce' ),
+				'type'              => 'string',
+				'required'          => true,
+				'context'           => array( 'create' ),
+				'validate_callback' => array( $this, 'validate_argument' ),
+				'sanitize_callback' => 'sanitize_text_field',
+			),
+			'platform'      => array(
 				'description'       => __( 'Platform', 'woocommerce' ),
 				'type'              => 'string',
 				'required'          => true,
@@ -329,7 +339,7 @@ class PushTokenRestController extends RestApiControllerBase {
 				'enum'              => PushToken::PLATFORMS,
 				'validate_callback' => array( $this, 'validate_argument' ),
 			),
-			'token'       => array(
+			'token'         => array(
 				'description'       => __( 'Push Token', 'woocommerce' ),
 				'type'              => 'string',
 				'required'          => true,
@@ -337,6 +347,13 @@ class PushTokenRestController extends RestApiControllerBase {
 				'validate_callback' => array( $this, 'validate_argument' ),
 				'sanitize_callback' => 'wp_unslash',
 			),
+			'metadata'      => array(
+				'description'       => __( 'Metadata', 'woocommerce' ),
+				'type'              => 'object',
+				'context'           => array( 'create' ),
+				'validate_callback' => array( $this, 'validate_argument' ),
+				'sanitize_callback' => 'wp_unslash',
+			),
 		);

 		if ( $context ) {
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
index 5b6e242d9a..85b38b4873 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
@@ -28,6 +28,8 @@ class PushTokensDataStore {
 		'device_uuid',
 		'token',
 		'platform',
+		'device_locale',
+		'metadata',
 	);

 	/**
@@ -112,6 +114,13 @@ class PushTokensDataStore {
 		$push_token->set_platform( $meta['platform'] );
 		$push_token->set_origin( $meta['origin'] );

+		/**
+		 * These meta items were added after the ability to store tokens, so may
+		 * not be available for older tokens. Use sensible defaults.
+		 */
+		$push_token->set_device_locale( $meta['device_locale'] ?? PushToken::DEFAULT_DEVICE_LOCALE );
+		$push_token->set_metadata( $meta['metadata'] ?? array() );
+
 		return $push_token;
 	}

@@ -272,12 +281,19 @@ class PushTokensDataStore {
 			) {
 				return new PushToken(
 					array(
-						'id'          => $post_id,
-						'user_id'     => $user_id,
-						'token'       => $meta['token'],
-						'device_uuid' => $meta['device_uuid'] ?? null,
-						'platform'    => $meta['platform'],
-						'origin'      => $meta['origin'],
+						'id'            => $post_id,
+						'user_id'       => $user_id,
+						'token'         => $meta['token'],
+						'device_uuid'   => $meta['device_uuid'] ?? null,
+						'platform'      => $meta['platform'],
+						'origin'        => $meta['origin'],
+						/**
+						 * These meta items were added after the ability to store
+						 * tokens, so may not be available for older tokens. Use
+						 * sensible defaults.
+						 */
+						'device_locale' => $meta['device_locale'] ?? PushToken::DEFAULT_DEVICE_LOCALE,
+						'metadata'      => $meta['metadata'] ?? array(),
 					)
 				);
 			}
@@ -288,23 +304,23 @@ class PushTokensDataStore {

 	/**
 	 * Returns an associative array of post meta as key => value pairs for the
-	 * keys defined in SUPPORTED_META; missing keys return null.
+	 * keys defined in SUPPORTED_META; missing keys return null. Use
+	 * `update_meta_cache` with `get_post_meta` to allow reading the meta as
+	 * single values which automatically unserialize when requires,
+	 * rather than nested arrays that don't.
 	 *
 	 * @since 10.5.0
 	 * @param int $id The push token ID.
 	 * @return array
 	 */
 	private function build_meta_array_from_database( int $id ): array {
-		$meta        = (array) get_post_meta( $id );
-		$meta_by_key = (array) array_combine( static::SUPPORTED_META, static::SUPPORTED_META );
+		$meta_by_key = array_fill_keys( static::SUPPORTED_META, null );

 		foreach ( static::SUPPORTED_META as $key ) {
-			if ( ! isset( $meta[ $key ] ) ) {
-				$meta_by_key[ $key ] = null;
-			} elseif ( is_array( $meta[ $key ] ) ) {
-				$meta_by_key[ $key ] = $meta[ $key ][0];
-			} else {
-				$meta_by_key[ $key ] = $meta[ $key ];
+			$meta = get_post_meta( $id, $key, true );
+
+			if ( '' !== $meta ) {
+				$meta_by_key[ $key ] = $meta;
 			}
 		}

@@ -322,11 +338,14 @@ class PushTokensDataStore {
 	private function build_meta_array_from_token( PushToken $push_token ) {
 		return array_filter(
 			array(
-				'platform'    => $push_token->get_platform(),
-				'token'       => $push_token->get_token(),
-				'device_uuid' => $push_token->get_device_uuid(),
-				'origin'      => $push_token->get_origin(),
-			)
+				'platform'      => $push_token->get_platform(),
+				'token'         => $push_token->get_token(),
+				'device_uuid'   => $push_token->get_device_uuid(),
+				'origin'        => $push_token->get_origin(),
+				'device_locale' => $push_token->get_device_locale(),
+				'metadata'      => $push_token->get_metadata(),
+			),
+			fn ( $value ) => null !== $value && '' !== $value
 		);
 	}
 }
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php b/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php
index aaf2e0063f..5fe3d62035 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php
@@ -20,6 +20,12 @@ class PushToken {
 	 */
 	const POST_TYPE = 'wc_push_token';

+	/**
+	 * The locale to use for tokens when the locale is not set, e.g. for tokens
+	 * created before `device_locale` was added.
+	 */
+	const DEFAULT_DEVICE_LOCALE = 'en_US';
+
 	/**
 	 * Platform identifier for Apple devices.
 	 */
@@ -122,6 +128,20 @@ class PushToken {
 	 */
 	private ?string $origin = null;

+	/**
+	 * The locale of the device the token belongs to.
+	 *
+	 * @var string|null
+	 */
+	private ?string $device_locale = null;
+
+	/**
+	 * An array of metadata for the token.
+	 *
+	 * @var array|null
+	 */
+	private ?array $metadata = null;
+
 	/**
 	 * Creates a new PushToken instance with the given data.
 	 *
@@ -154,6 +174,14 @@ class PushToken {
 		if ( array_key_exists( 'origin', $data ) ) {
 			$this->set_origin( (string) $data['origin'] );
 		}
+
+		if ( array_key_exists( 'device_locale', $data ) ) {
+			$this->set_device_locale( (string) $data['device_locale'] );
+		}
+
+		if ( array_key_exists( 'metadata', $data ) ) {
+			$this->set_metadata( (array) $data['metadata'] );
+		}
 	}

 	/**
@@ -240,6 +268,26 @@ class PushToken {
 		$this->device_uuid = $device_uuid ? $device_uuid : null;
 	}

+	/**
+	 * Validates and sets the device locale.
+	 *
+	 * @param string $device_locale The locale of the device the token belongs to.
+	 * @throws PushTokenInvalidDataException If device locale is not valid.
+	 * @return void
+	 *
+	 * @since 10.6.0
+	 */
+	public function set_device_locale( string $device_locale ): void {
+		$result = PushTokenValidator::validate( compact( 'device_locale' ), array( 'device_locale' ) );
+
+		if ( is_wp_error( $result ) ) {
+			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+			throw new PushTokenInvalidDataException( $result->get_error_message() );
+		}
+
+		$this->device_locale = trim( $device_locale );
+	}
+
 	/**
 	 * Validates and sets the platform.
 	 *
@@ -280,6 +328,39 @@ class PushToken {
 		$this->origin = trim( $origin );
 	}

+	/**
+	 * Validates and sets the metadata.
+	 *
+	 * @param array $metadata An array of metadata for the token, e.g. the app version, device OS etc.
+	 * @throws PushTokenInvalidDataException If metadata is not valid.
+	 * @return void
+	 *
+	 * @since 10.6.0
+	 */
+	public function set_metadata( array $metadata ): void {
+		$result = PushTokenValidator::validate( compact( 'metadata' ), array( 'metadata' ) );
+
+		if ( is_wp_error( $result ) ) {
+			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+			throw new PushTokenInvalidDataException( $result->get_error_message() );
+		}
+
+		if ( ! empty( $metadata ) ) {
+			$keys   = array_map( 'sanitize_key', array_keys( $metadata ) );
+			$values = array_map( 'sanitize_text_field', array_values( $metadata ) );
+
+			/**
+			 * Typehint for PHPStan, as it can't infer the $keys and $values are
+			 * the same length therefore array_combine won't return false.
+			 *
+			 * @var array<string, string> $metadata
+			 */
+			$metadata = array_combine( $keys, $values );
+		}
+
+		$this->metadata = $metadata;
+	}
+
 	/**
 	 * Gets the ID.
 	 *
@@ -346,6 +427,28 @@ class PushToken {
 		return $this->origin;
 	}

+	/**
+	 * Gets the device locale.
+	 *
+	 * @return string|null
+	 *
+	 * @since 10.6.0
+	 */
+	public function get_device_locale(): ?string {
+		return $this->device_locale;
+	}
+
+	/**
+	 * Gets the metadata.
+	 *
+	 * @return array|null
+	 *
+	 * @since 10.6.0
+	 */
+	public function get_metadata(): ?array {
+		return $this->metadata;
+	}
+
 	/**
 	 * Determines whether this token can be created.
 	 *
@@ -402,6 +505,7 @@ class PushToken {
 			&& $this->get_token()
 			&& $this->get_platform()
 			&& $this->get_origin()
+			&& $this->get_device_locale()
 			&& (
 				$this->get_device_uuid()
 				|| $this->get_platform() === self::PLATFORM_BROWSER
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Validators/PushTokenValidator.php b/plugins/woocommerce/src/Internal/PushNotifications/Validators/PushTokenValidator.php
index da3b27d90c..1bee511fa0 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Validators/PushTokenValidator.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Validators/PushTokenValidator.php
@@ -12,6 +12,8 @@ defined( 'ABSPATH' ) || exit;
 use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
 use WP_Error;

+// phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+
 /**
  * Validator class for push tokens.
  *
@@ -23,8 +25,10 @@ class PushTokenValidator {
 		'user_id',
 		'origin',
 		'device_uuid',
+		'device_locale',
 		'platform',
 		'token',
+		'metadata',
 	);

 	/**
@@ -34,6 +38,14 @@ class PushTokenValidator {
 	 */
 	const ERROR_CODE = 'woocommerce_invalid_data';

+	/**
+	 * Validates device locale format:
+	 * - language code (2–3 lowercase letters)
+	 * - underscore
+	 * - region code (2 uppercase letters).
+	 */
+	const DEVICE_LOCALE_FORMAT = '/^(?<language>[a-z]{2,3})_(?<region>[A-Z]{2})$/';
+
 	/**
 	 * The regex to use when validating device UUID format.
 	 *
@@ -107,17 +119,11 @@ class PushTokenValidator {
 	 *
 	 * @since 10.6.0
 	 *
-	 * @param mixed $value The value to validate.
-	 * @param array $context An array of other values included as context for the validation.
+	 * @param mixed      $value The value to validate.
+	 * @param array|null $context An array of other values included as context for the validation.
 	 * @return bool|WP_Error
 	 */
-	private static function validate_id( $value, array $context ) {
-		/**
-		 * Context is unused in this specific method, but required by the
-		 * validate() dispatch signature.
-		 */
-		unset( $context );
-
+	private static function validate_id( $value, ?array $context = array() ) {
 		if ( is_null( $value ) ) {
 			return new WP_Error( self::ERROR_CODE, 'ID is required.' );
 		}
@@ -138,17 +144,11 @@ class PushTokenValidator {
 	 *
 	 * @since 10.6.0
 	 *
-	 * @param mixed $value The value to validate.
-	 * @param array $context An array of other values included as context for the validation.
+	 * @param mixed      $value The value to validate.
+	 * @param array|null $context An array of other values included as context for the validation.
 	 * @return bool|WP_Error
 	 */
-	private static function validate_user_id( $value, array $context ) {
-		/**
-		 * Context is unused in this specific method, but required by the
-		 * validate() dispatch signature.
-		 */
-		unset( $context );
-
+	private static function validate_user_id( $value, ?array $context = array() ) {
 		if ( is_null( $value ) ) {
 			return new WP_Error( self::ERROR_CODE, 'User ID is required.' );
 		}
@@ -169,17 +169,11 @@ class PushTokenValidator {
 	 *
 	 * @since 10.6.0
 	 *
-	 * @param mixed $value The value to validate.
-	 * @param array $context An array of other values included as context for the validation.
+	 * @param mixed      $value The value to validate.
+	 * @param array|null $context An array of other values included as context for the validation.
 	 * @return bool|WP_Error
 	 */
-	private static function validate_origin( $value, array $context ) {
-		/**
-		 * Context is unused in this specific method, but required by the
-		 * validate() dispatch signature.
-		 */
-		unset( $context );
-
+	private static function validate_origin( $value, ?array $context = array() ) {
 		if ( is_null( $value ) ) {
 			return new WP_Error( self::ERROR_CODE, 'Origin is required.' );
 		}
@@ -209,11 +203,11 @@ class PushTokenValidator {
 	 *
 	 * @since 10.6.0
 	 *
-	 * @param mixed $value The value to validate.
-	 * @param array $context An array of other values included as context for the validation.
+	 * @param mixed      $value The value to validate.
+	 * @param array|null $context An array of other values included as context for the validation.
 	 * @return bool|WP_Error
 	 */
-	private static function validate_device_uuid( $value, array $context ) {
+	private static function validate_device_uuid( $value, ?array $context = array() ) {
 		/**
 		 * We may or may not have platform; if we don't have it, we can skip the
 		 * platform-specific checks and allow the platform validation to trigger
@@ -266,22 +260,47 @@ class PushTokenValidator {
 		return true;
 	}

+	/**
+	 * Validates device locale.
+	 *
+	 * @param mixed      $value The value to validate.
+	 * @param array|null $context An array of other values included as context for the validation.
+	 * @return bool|WP_Error
+	 *
+	 * @since 10.6.0
+	 */
+	private static function validate_device_locale( $value, ?array $context = array() ) {
+		if ( ! isset( $value ) ) {
+			return new WP_Error( self::ERROR_CODE, 'Device locale is required.' );
+		}
+
+		if ( ! is_string( $value ) ) {
+			return new WP_Error( self::ERROR_CODE, 'Device locale must be a string.' );
+		}
+
+		$value = trim( $value );
+
+		if ( '' === $value ) {
+			return new WP_Error( self::ERROR_CODE, 'Device locale cannot be empty.' );
+		}
+
+		if ( ! preg_match( self::DEVICE_LOCALE_FORMAT, $value ) ) {
+			return new WP_Error( self::ERROR_CODE, 'Device locale is an invalid format.' );
+		}
+
+		return true;
+	}
+
 	/**
 	 * Validates platform.
 	 *
 	 * @since 10.6.0
 	 *
-	 * @param mixed $value The value to validate.
-	 * @param array $context An array of other values included as context for the validation.
+	 * @param mixed      $value The value to validate.
+	 * @param array|null $context An array of other values included as context for the validation.
 	 * @return bool|WP_Error
 	 */
-	private static function validate_platform( $value, array $context ) {
-		/**
-		 * Context is unused in this specific method, but required by the
-		 * validate() dispatch signature.
-		 */
-		unset( $context );
-
+	private static function validate_platform( $value, ?array $context = array() ) {
 		if ( is_null( $value ) ) {
 			return new WP_Error( self::ERROR_CODE, 'Platform is required.' );
 		}
@@ -311,11 +330,11 @@ class PushTokenValidator {
 	 *
 	 * @since 10.6.0
 	 *
-	 * @param mixed $value The value to validate.
-	 * @param array $context An array of other values included as context for the validation.
+	 * @param mixed      $value The value to validate.
+	 * @param array|null $context An array of other values included as context for the validation.
 	 * @return bool|WP_Error
 	 */
-	private static function validate_token( $value, array $context ) {
+	private static function validate_token( $value, ?array $context = array() ) {
 		if ( is_null( $value ) ) {
 			return new WP_Error( self::ERROR_CODE, 'Token is required.' );
 		}
@@ -378,4 +397,33 @@ class PushTokenValidator {

 		return true;
 	}
+
+	/**
+	 * Validates metadata.
+	 *
+	 * @param mixed      $value The value to validate.
+	 * @param array|null $context An array of other values included as context for the validation.
+	 * @return bool|WP_Error
+	 *
+	 * @since 10.6.0
+	 */
+	private static function validate_metadata( $value, ?array $context = array() ) {
+		if ( ! isset( $value ) ) {
+			return new WP_Error( self::ERROR_CODE, 'Metadata is required.' );
+		}
+
+		if ( ! is_array( $value ) ) {
+			return new WP_Error( self::ERROR_CODE, 'Metadata must be an array.' );
+		}
+
+		foreach ( $value as $key => $item ) {
+			if ( ! is_scalar( $item ) ) {
+				return new WP_Error( self::ERROR_CODE, 'Metadata items must be scalar values.' );
+			}
+		}
+
+		return true;
+	}
 }
+
+// phpcs:enable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
index 865e566e03..5f823cc835 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
@@ -117,6 +117,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
 		$request->set_param( 'device_uuid', $device_uuid );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -154,6 +156,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'platform', PushToken::PLATFORM_ANDROID );
 		$request->set_param( 'device_uuid', $device_uuid );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_ANDROID );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -193,6 +197,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
 		$request->set_param( 'device_uuid', 'device-1' );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -208,6 +214,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
 		$request->set_param( 'device_uuid', 'device-2' );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -249,6 +257,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
 		$request->set_param( 'device_uuid', $device_uuid );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -264,6 +274,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
 		$request->set_param( 'device_uuid', $device_uuid );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -286,6 +298,66 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Test it updates a legacy token that was created without
+	 * device_locale and metadata.
+	 */
+	public function test_it_updates_legacy_token_without_device_locale_and_metadata() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$token_value = str_repeat( 'a', 64 );
+		$device_uuid = 'legacy-device-uuid';
+
+		/**
+		 * Insert a legacy token directly into the database without
+		 * device_locale and metadata meta.
+		 */
+		$post_id = wp_insert_post(
+			array(
+				'post_author' => $this->user_id,
+				'post_type'   => PushToken::POST_TYPE,
+				'post_status' => 'private',
+				'meta_input'  => array(
+					'platform'    => PushToken::PLATFORM_APPLE,
+					'token'       => $token_value,
+					'device_uuid' => $device_uuid,
+					'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				),
+			)
+		);
+
+		/**
+		 * Re-register the same token via the REST API, which should find the
+		 * legacy token and update it.
+		 */
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'token', $token_value );
+		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
+		$request->set_param( 'device_uuid', $device_uuid );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'fr_FR' );
+		$request->set_param( 'metadata', array( 'app_version' => '2.0' ) );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::CREATED, $response->get_status() );
+
+		$data = $response->get_data();
+
+		$this->assertEquals( $post_id, $data['id'] );
+
+		/**
+		 * Verify the legacy token was updated with the new fields.
+		 */
+		$device_locale = get_post_meta( $post_id, 'device_locale', true );
+		$metadata      = get_post_meta( $post_id, 'metadata', true );
+
+		$this->assertEquals( 'fr_FR', $device_locale );
+		$this->assertEquals( array( 'app_version' => '2.0' ), $metadata );
+	}
+
 	/**
 	 * @testdox Test it cannot create a push token without authentication.
 	 */
@@ -295,6 +367,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
 		$request->set_param( 'device_uuid', 'test-device-uuid' );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -318,6 +392,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
 		$request->set_param( 'device_uuid', 'test-device-uuid' );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -338,6 +414,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
 		$request->set_param( 'device_uuid', 'test-device-uuid' );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -363,6 +441,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
 		$request->set_param( 'device_uuid', 'test-device-uuid-nonhex' );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -386,6 +466,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
 		$request->set_param( 'device_uuid', 'test-device-uuid-short' );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -410,6 +492,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'platform', PushToken::PLATFORM_ANDROID );
 		$request->set_param( 'device_uuid', 'test-device-uuid' );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_ANDROID );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -434,6 +518,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'platform', PushToken::PLATFORM_ANDROID );
 		$request->set_param( 'device_uuid', 'test-device-uuid' );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_ANDROID );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -457,6 +543,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'token', 'not-valid-json' );
 		$request->set_param( 'platform', PushToken::PLATFORM_BROWSER );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -487,6 +575,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'token', $token );
 		$request->set_param( 'platform', PushToken::PLATFORM_BROWSER );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -520,6 +610,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'token', $token );
 		$request->set_param( 'platform', PushToken::PLATFORM_BROWSER );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -543,6 +635,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
 		$request->set_param( 'device_uuid', 'test-device-uuid' );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -566,6 +660,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'token', str_repeat( 'a', 64 ) );
 		$request->set_param( 'device_uuid', 'test-device-uuid' );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -589,6 +685,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'token', str_repeat( 'a', 64 ) );
 		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -613,6 +711,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'platform', 'windows' );
 		$request->set_param( 'device_uuid', 'test-device-uuid' );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -636,6 +736,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'token', str_repeat( 'a', 64 ) );
 		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
 		$request->set_param( 'device_uuid', 'test-device-uuid' );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -659,6 +761,115 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
 		$request->set_param( 'device_uuid', 'test-device-uuid' );
 		$request->set_param( 'origin', 'development' );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::BAD_REQUEST, $response->get_status() );
+
+		$data = $response->get_data();
+
+		$this->assertEquals( 'rest_invalid_param', $data['code'] );
+	}
+
+	/**
+	 * @testdox Test it cannot create a push token without required device_locale
+	 * parameter.
+	 */
+	public function test_it_cannot_create_push_token_with_a_missing_device_locale() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'token', str_repeat( 'a', 64 ) );
+		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
+		$request->set_param( 'device_uuid', 'test-device-uuid' );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'metadata', array() );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::BAD_REQUEST, $response->get_status() );
+
+		$data = $response->get_data();
+
+		$this->assertEquals( 'rest_missing_callback_param', $data['code'] );
+	}
+
+	/**
+	 * @testdox Test it cannot create a push token with invalid device_locale
+	 * format.
+	 */
+	public function test_it_cannot_create_push_token_with_invalid_device_locale_format() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'token', str_repeat( 'a', 64 ) );
+		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
+		$request->set_param( 'device_uuid', 'test-device-uuid' );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'invalid-locale' );
+		$request->set_param( 'metadata', array() );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::BAD_REQUEST, $response->get_status() );
+
+		$data = $response->get_data();
+
+		$this->assertEquals( 'rest_invalid_param', $data['code'] );
+		$this->assertStringContainsString( 'Invalid parameter(s): device_locale', $data['message'] );
+	}
+
+	/**
+	 * @testdox Test it can create a push token without required metadata
+	 * parameter.
+	 */
+	public function test_it_can_create_push_token_with_a_missing_metadata() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$token_value = str_repeat( 'a', 64 );
+		$device_uuid = 'test-device-uuid';
+
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'token', $token_value );
+		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
+		$request->set_param( 'device_uuid', $device_uuid );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::CREATED, $response->get_status() );
+
+		$data = $response->get_data();
+
+		$this->assertArrayHasKey( 'id', $data );
+		$this->assertIsInt( $data['id'] );
+		$this->assertGreaterThan( 0, $data['id'] );
+	}
+
+	/**
+	 * @testdox Test it cannot create a push token with non-array metadata.
+	 */
+	public function test_it_cannot_create_push_token_with_non_array_metadata() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'token', str_repeat( 'a', 64 ) );
+		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
+		$request->set_param( 'device_uuid', 'test-device-uuid' );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', 'not an array' );

 		$response = $this->server->dispatch( $request );

@@ -667,6 +878,7 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$data = $response->get_data();

 		$this->assertEquals( 'rest_invalid_param', $data['code'] );
+		$this->assertStringContainsString( 'Invalid parameter(s): metadata', $data['message'] );
 	}

 	/**
@@ -681,11 +893,13 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		 * Create a token first.
 		 */
 		$data = array(
-			'user_id'     => $this->user_id,
-			'token'       => str_repeat( 'a', 64 ),
-			'platform'    => PushToken::PLATFORM_APPLE,
-			'device_uuid' => 'device-to-delete',
-			'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+			'user_id'       => $this->user_id,
+			'token'         => str_repeat( 'a', 64 ),
+			'platform'      => PushToken::PLATFORM_APPLE,
+			'device_uuid'   => 'device-to-delete',
+			'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+			'device_locale' => 'en_US',
+			'metadata'      => array( 'app_version' => '1.0' ),
 		);

 		$data_store = wc_get_container()->get( PushTokensDataStore::class );
@@ -740,11 +954,13 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		 * Create a token for another shop manager.
 		 */
 		$data = array(
-			'user_id'     => $this->other_shop_manager_id,
-			'token'       => str_repeat( 'a', 64 ),
-			'platform'    => PushToken::PLATFORM_APPLE,
-			'device_uuid' => 'device-other-user',
-			'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+			'user_id'       => $this->other_shop_manager_id,
+			'token'         => str_repeat( 'a', 64 ),
+			'platform'      => PushToken::PLATFORM_APPLE,
+			'device_uuid'   => 'device-other-user',
+			'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+			'device_locale' => 'en_US',
+			'metadata'      => array( 'app_version' => '1.0' ),
 		);

 		$data_store = wc_get_container()->get( PushTokensDataStore::class );
@@ -794,11 +1010,13 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 	 */
 	public function test_it_returns_500_when_wp_delete_post_fails() {
 		$data = array(
-			'user_id'     => $this->user_id,
-			'token'       => str_repeat( 'a', 64 ),
-			'platform'    => PushToken::PLATFORM_APPLE,
-			'device_uuid' => 'device-delete-fail',
-			'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+			'user_id'       => $this->user_id,
+			'token'         => str_repeat( 'a', 64 ),
+			'platform'      => PushToken::PLATFORM_APPLE,
+			'device_uuid'   => 'device-delete-fail',
+			'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+			'device_locale' => 'en_US',
+			'metadata'      => array( 'app_version' => '1.0' ),
 		);

 		$data_store = wc_get_container()->get( PushTokensDataStore::class );
@@ -907,6 +1125,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
 		$request->set_param( 'device_uuid', str_repeat( 'a', 256 ) );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -932,6 +1152,8 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
 		$request->set_param( 'device_uuid', 'invalid device uuid with spaces' );
 		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request->set_param( 'device_locale', 'en_US' );
+		$request->set_param( 'metadata', array( 'app_version' => '1.0' ) );

 		$response = $this->server->dispatch( $request );

@@ -1191,5 +1413,9 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$this->assertEquals( $platform, $meta['platform'] );
 		$this->assertEquals( $device_uuid, $meta['device_uuid'] );
 		$this->assertEquals( $origin, $meta['origin'] );
+		$this->assertArrayHasKey( 'device_locale', $meta );
+		$this->assertEquals( 'en_US', $meta['device_locale'] );
+		$this->assertArrayHasKey( 'metadata', $meta );
+		$this->assertEquals( array( 'app_version' => '1.0' ), maybe_unserialize( $meta['metadata'] ) );
 	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
index 31af803d1d..18eabd82d4 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
@@ -48,11 +48,13 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		$data_store = new PushTokensDataStore();

 		$data = array(
-			'user_id'     => 1,
-			'token'       => 'test_token_12345',
-			'platform'    => PushToken::PLATFORM_APPLE,
-			'device_uuid' => 'device-uuid-123',
-			'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+			'user_id'       => 1,
+			'token'         => 'test_token_12345',
+			'platform'      => PushToken::PLATFORM_APPLE,
+			'device_uuid'   => 'device-uuid-123',
+			'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+			'device_locale' => 'en_US',
+			'metadata'      => array( 'app_version' => '1.0' ),
 		);

 		$push_token = $data_store->create( $data );
@@ -79,6 +81,8 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		$this->assertEquals( $original_push_token->get_token(), $read_push_token->get_token() );
 		$this->assertEquals( $original_push_token->get_device_uuid(), $read_push_token->get_device_uuid() );
 		$this->assertEquals( $original_push_token->get_origin(), $read_push_token->get_origin() );
+		$this->assertEquals( $original_push_token->get_device_locale(), $read_push_token->get_device_locale() );
+		$this->assertEquals( $original_push_token->get_metadata(), $read_push_token->get_metadata() );
 	}

 	/**
@@ -421,11 +425,13 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		 */
 		$browser_token_1 = $data_store->create(
 			array(
-				'user_id'     => 1,
-				'token'       => 'browser_token_1_' . wp_rand(),
-				'platform'    => PushToken::PLATFORM_BROWSER,
-				'device_uuid' => null,
-				'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'user_id'       => 1,
+				'token'         => 'browser_token_1_' . wp_rand(),
+				'platform'      => PushToken::PLATFORM_BROWSER,
+				'device_uuid'   => null,
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
 			)
 		);

@@ -434,11 +440,13 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		 */
 		$browser_token_2 = $data_store->create(
 			array(
-				'user_id'     => 1,
-				'token'       => 'browser_token_2_' . wp_rand(),
-				'platform'    => PushToken::PLATFORM_BROWSER,
-				'device_uuid' => null,
-				'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'user_id'       => 1,
+				'token'         => 'browser_token_2_' . wp_rand(),
+				'platform'      => PushToken::PLATFORM_BROWSER,
+				'device_uuid'   => null,
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
 			)
 		);

@@ -571,10 +579,12 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		$data_store = new PushTokensDataStore();

 		$data = array(
-			'user_id'  => 1,
-			'token'    => '{"endpoint":"https://example.com/push","keys":{"auth":"test","p256dh":"test"}}',
-			'platform' => PushToken::PLATFORM_BROWSER,
-			'origin'   => PushToken::ORIGIN_BROWSER,
+			'user_id'       => 1,
+			'token'         => '{"endpoint":"https://example.com/push","keys":{"auth":"test","p256dh":"test"}}',
+			'platform'      => PushToken::PLATFORM_BROWSER,
+			'origin'        => PushToken::ORIGIN_BROWSER,
+			'device_locale' => 'en_US',
+			'metadata'      => array( 'app_version' => '1.0' ),
 		);

 		$push_token = $data_store->create( $data );
@@ -591,6 +601,108 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		$this->assertNull( $read_token->get_device_uuid() );
 	}

+	/**
+	 * @testdox Tests that a legacy token without device_locale and metadata can
+	 * be read with sensible defaults applied.
+	 */
+	public function test_it_can_read_legacy_token_without_device_locale_and_metadata() {
+		$data_store = new PushTokensDataStore();
+
+		$post_id = wp_insert_post(
+			array(
+				'post_author' => 1,
+				'post_type'   => PushToken::POST_TYPE,
+				'post_status' => 'private',
+				'meta_input'  => array(
+					'platform'    => PushToken::PLATFORM_APPLE,
+					'token'       => 'legacy_token_value',
+					'device_uuid' => 'legacy-device-uuid',
+					'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				),
+			)
+		);
+
+		$push_token = $data_store->read( $post_id );
+
+		$this->assertEquals( $post_id, $push_token->get_id() );
+		$this->assertEquals( 'legacy_token_value', $push_token->get_token() );
+		$this->assertEquals( PushToken::DEFAULT_DEVICE_LOCALE, $push_token->get_device_locale() );
+		$this->assertEquals( array(), $push_token->get_metadata() );
+	}
+
+	/**
+	 * @testdox Tests that a legacy token without device_locale and metadata can
+	 * be found by get_by_token_or_device_id with defaults applied.
+	 */
+	public function test_it_can_find_legacy_token_by_token_or_device_id_with_defaults() {
+		$data_store = new PushTokensDataStore();
+
+		$post_id = wp_insert_post(
+			array(
+				'post_author' => 1,
+				'post_type'   => PushToken::POST_TYPE,
+				'post_status' => 'private',
+				'meta_input'  => array(
+					'platform'    => PushToken::PLATFORM_APPLE,
+					'token'       => 'legacy_token_value',
+					'device_uuid' => 'legacy-device-uuid',
+					'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				),
+			)
+		);
+
+		$found_token = $data_store->get_by_token_or_device_id(
+			array(
+				'user_id'     => 1,
+				'token'       => 'legacy_token_value',
+				'platform'    => PushToken::PLATFORM_APPLE,
+				'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_uuid' => 'different-device',
+			)
+		);
+
+		$this->assertNotNull( $found_token );
+		$this->assertEquals( $post_id, $found_token->get_id() );
+		$this->assertEquals( PushToken::DEFAULT_DEVICE_LOCALE, $found_token->get_device_locale() );
+		$this->assertEquals( array(), $found_token->get_metadata() );
+	}
+
+	/**
+	 * @testdox Tests that a legacy token can be updated with new device_locale
+	 * and metadata values.
+	 */
+	public function test_it_can_update_legacy_token_with_new_locale_and_metadata() {
+		$data_store = new PushTokensDataStore();
+
+		$post_id = wp_insert_post(
+			array(
+				'post_author' => 1,
+				'post_type'   => PushToken::POST_TYPE,
+				'post_status' => 'private',
+				'meta_input'  => array(
+					'platform'    => PushToken::PLATFORM_APPLE,
+					'token'       => 'legacy_token_value',
+					'device_uuid' => 'legacy-device-uuid',
+					'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				),
+			)
+		);
+
+		$push_token = $data_store->read( $post_id );
+
+		$this->assertEquals( PushToken::DEFAULT_DEVICE_LOCALE, $push_token->get_device_locale() );
+		$this->assertEquals( array(), $push_token->get_metadata() );
+
+		$push_token->set_device_locale( 'fr_FR' );
+		$push_token->set_metadata( array( 'app_version' => '2.0' ) );
+		$data_store->update( $push_token );
+
+		$updated_token = $data_store->read( $post_id );
+
+		$this->assertEquals( 'fr_FR', $updated_token->get_device_locale() );
+		$this->assertEquals( array( 'app_version' => '2.0' ), $updated_token->get_metadata() );
+	}
+
 	/**
 	 * Creates a test push token and saves it to the database.
 	 *
@@ -600,11 +712,13 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		$data_store = new PushTokensDataStore();

 		$data = array(
-			'user_id'     => 1,
-			'token'       => 'test_token_' . wp_rand(),
-			'platform'    => PushToken::PLATFORM_APPLE,
-			'device_uuid' => 'test-device-uuid-' . wp_rand(),
-			'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+			'user_id'       => 1,
+			'token'         => 'test_token_' . wp_rand(),
+			'platform'      => PushToken::PLATFORM_APPLE,
+			'device_uuid'   => 'test-device-uuid-' . wp_rand(),
+			'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+			'device_locale' => 'en_US',
+			'metadata'      => array( 'app_version' => '1.0' ),
 		);

 		return $data_store->create( $data );
@@ -622,14 +736,18 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		$this->assertEquals( PushToken::POST_TYPE, $post->post_type );
 		$this->assertEquals( $push_token->get_user_id(), $post->post_author );

-		$platform    = get_post_meta( $push_token->get_id(), 'platform', true );
-		$token       = get_post_meta( $push_token->get_id(), 'token', true );
-		$device_uuid = get_post_meta( $push_token->get_id(), 'device_uuid', true );
-		$origin      = get_post_meta( $push_token->get_id(), 'origin', true );
+		$platform      = get_post_meta( $push_token->get_id(), 'platform', true );
+		$token         = get_post_meta( $push_token->get_id(), 'token', true );
+		$device_uuid   = get_post_meta( $push_token->get_id(), 'device_uuid', true );
+		$origin        = get_post_meta( $push_token->get_id(), 'origin', true );
+		$device_locale = get_post_meta( $push_token->get_id(), 'device_locale', true );
+		$metadata      = get_post_meta( $push_token->get_id(), 'metadata', true );

 		$this->assertEquals( $push_token->get_platform(), $platform );
 		$this->assertEquals( $push_token->get_token(), $token );
 		$this->assertEquals( $push_token->get_device_uuid(), $device_uuid );
 		$this->assertEquals( $push_token->get_origin(), $origin );
+		$this->assertEquals( $push_token->get_device_locale(), $device_locale );
+		$this->assertEquals( $push_token->get_metadata(), $metadata );
 	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Entities/PushTokenTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Entities/PushTokenTest.php
index 624f01800f..dd0af6e809 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Entities/PushTokenTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Entities/PushTokenTest.php
@@ -71,11 +71,13 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	public function test_it_can_be_created_when_all_fields_are_set_except_id() {
 		$push_token = new PushToken(
 			array(
-				'user_id'     => 1,
-				'token'       => 'test_token',
-				'device_uuid' => 'test-device-uuid',
-				'platform'    => PushToken::PLATFORM_APPLE,
-				'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'user_id'       => 1,
+				'token'         => 'test_token',
+				'device_uuid'   => 'test-device-uuid',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
 			)
 		);

@@ -88,12 +90,14 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	public function test_it_cannot_be_created_when_id_is_set() {
 		$push_token = new PushToken(
 			array(
-				'id'          => 1,
-				'user_id'     => 1,
-				'token'       => 'test_token',
-				'device_uuid' => 'test-device-uuid',
-				'platform'    => PushToken::PLATFORM_APPLE,
-				'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'id'            => 1,
+				'user_id'       => 1,
+				'token'         => 'test_token',
+				'device_uuid'   => 'test-device-uuid',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
 			)
 		);

@@ -106,10 +110,12 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	public function test_it_cannot_be_created_when_user_id_is_missing() {
 		$push_token = new PushToken(
 			array(
-				'token'       => 'test_token',
-				'device_uuid' => 'test-device-uuid',
-				'platform'    => PushToken::PLATFORM_APPLE,
-				'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'token'         => 'test_token',
+				'device_uuid'   => 'test-device-uuid',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
 			)
 		);

@@ -122,10 +128,12 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	public function test_it_cannot_be_created_when_platform_is_missing() {
 		$push_token = new PushToken(
 			array(
-				'user_id'     => 1,
-				'token'       => 'test_token',
-				'device_uuid' => 'test-device-uuid',
-				'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'user_id'       => 1,
+				'token'         => 'test_token',
+				'device_uuid'   => 'test-device-uuid',
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
 			)
 		);

@@ -138,10 +146,12 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	public function test_it_cannot_be_created_when_token_is_missing() {
 		$push_token = new PushToken(
 			array(
-				'user_id'     => 1,
-				'device_uuid' => 'test-device-uuid',
-				'platform'    => PushToken::PLATFORM_APPLE,
-				'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'user_id'       => 1,
+				'device_uuid'   => 'test-device-uuid',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
 			)
 		);

@@ -154,10 +164,12 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	public function test_it_cannot_be_created_when_device_uuid_is_missing() {
 		$push_token = new PushToken(
 			array(
-				'user_id'  => 1,
-				'token'    => 'test_token',
-				'platform' => PushToken::PLATFORM_APPLE,
-				'origin'   => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'user_id'       => 1,
+				'token'         => 'test_token',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
 			)
 		);

@@ -171,12 +183,14 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	public function test_it_can_be_updated_when_all_fields_are_set() {
 		$push_token = new PushToken(
 			array(
-				'id'          => 1,
-				'user_id'     => 1,
-				'token'       => 'test_token',
-				'device_uuid' => 'test-device-uuid',
-				'platform'    => PushToken::PLATFORM_APPLE,
-				'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'id'            => 1,
+				'user_id'       => 1,
+				'token'         => 'test_token',
+				'device_uuid'   => 'test-device-uuid',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
 			)
 		);

@@ -189,11 +203,13 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	public function test_it_cannot_be_updated_when_id_is_not_set() {
 		$push_token = new PushToken(
 			array(
-				'user_id'     => 1,
-				'token'       => 'test_token',
-				'device_uuid' => 'test-device-uuid',
-				'platform'    => PushToken::PLATFORM_APPLE,
-				'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'user_id'       => 1,
+				'token'         => 'test_token',
+				'device_uuid'   => 'test-device-uuid',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
 			)
 		);

@@ -206,11 +222,13 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	public function test_it_cannot_be_updated_when_user_id_is_not_set() {
 		$push_token = new PushToken(
 			array(
-				'id'          => 1,
-				'token'       => 'test_token',
-				'device_uuid' => 'test-device-uuid',
-				'platform'    => PushToken::PLATFORM_APPLE,
-				'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'id'            => 1,
+				'token'         => 'test_token',
+				'device_uuid'   => 'test-device-uuid',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
 			)
 		);

@@ -223,11 +241,13 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	public function test_it_cannot_be_updated_when_platform_is_not_set() {
 		$push_token = new PushToken(
 			array(
-				'id'          => 1,
-				'user_id'     => 1,
-				'token'       => 'test_token',
-				'device_uuid' => 'test-device-uuid',
-				'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'id'            => 1,
+				'user_id'       => 1,
+				'token'         => 'test_token',
+				'device_uuid'   => 'test-device-uuid',
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
 			)
 		);

@@ -240,11 +260,13 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	public function test_it_cannot_be_updated_when_device_uuid_is_not_set() {
 		$push_token = new PushToken(
 			array(
-				'id'       => 1,
-				'user_id'  => 1,
-				'token'    => 'test_token',
-				'platform' => PushToken::PLATFORM_APPLE,
-				'origin'   => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'id'            => 1,
+				'user_id'       => 1,
+				'token'         => 'test_token',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
 			)
 		);

@@ -257,11 +279,13 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	public function test_it_cannot_be_updated_when_token_is_not_set() {
 		$push_token = new PushToken(
 			array(
-				'id'          => 1,
-				'user_id'     => 1,
-				'device_uuid' => 'test-device-uuid',
-				'platform'    => PushToken::PLATFORM_APPLE,
-				'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'id'            => 1,
+				'user_id'       => 1,
+				'device_uuid'   => 'test-device-uuid',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
 			)
 		);

@@ -334,10 +358,12 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	public function test_it_can_be_created_for_browser_without_device_uuid() {
 		$push_token = new PushToken(
 			array(
-				'user_id'  => 1,
-				'token'    => 'test_token',
-				'platform' => PushToken::PLATFORM_BROWSER,
-				'origin'   => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'user_id'       => 1,
+				'token'         => 'test_token',
+				'platform'      => PushToken::PLATFORM_BROWSER,
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
 			)
 		);

@@ -351,11 +377,13 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	public function test_it_can_be_updated_for_browser_without_device_uuid() {
 		$push_token = new PushToken(
 			array(
-				'id'       => 1,
-				'user_id'  => 1,
-				'token'    => 'test_token',
-				'platform' => PushToken::PLATFORM_BROWSER,
-				'origin'   => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'id'            => 1,
+				'user_id'       => 1,
+				'token'         => 'test_token',
+				'platform'      => PushToken::PLATFORM_BROWSER,
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
 			)
 		);

@@ -366,12 +394,51 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	 * @testdox Tests can_be_created returns false when origin is missing.
 	 */
 	public function test_it_cannot_be_created_when_origin_is_missing() {
+		$push_token = new PushToken(
+			array(
+				'user_id'       => 1,
+				'token'         => 'test_token',
+				'device_uuid'   => 'test-device-uuid',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
+			)
+		);
+
+		$this->assertFalse( $push_token->can_be_created() );
+	}
+
+	/**
+	 * @testdox Tests can_be_updated returns false when origin is missing.
+	 */
+	public function test_it_cannot_be_updated_when_origin_is_missing() {
+		$push_token = new PushToken(
+			array(
+				'id'            => 1,
+				'user_id'       => 1,
+				'token'         => 'test_token',
+				'device_uuid'   => 'test-device-uuid',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
+			)
+		);
+
+		$this->assertFalse( $push_token->can_be_updated() );
+	}
+
+	/**
+	 * @testdox Tests can_be_created returns false when device_locale is missing.
+	 */
+	public function test_it_cannot_be_created_when_device_locale_is_missing() {
 		$push_token = new PushToken(
 			array(
 				'user_id'     => 1,
 				'token'       => 'test_token',
 				'device_uuid' => 'test-device-uuid',
 				'platform'    => PushToken::PLATFORM_APPLE,
+				'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'metadata'    => array( 'app_version' => '1.0' ),
 			)
 		);

@@ -379,9 +446,9 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Tests can_be_updated returns false when origin is missing.
+	 * @testdox Tests can_be_updated returns false when device_locale is missing.
 	 */
-	public function test_it_cannot_be_updated_when_origin_is_missing() {
+	public function test_it_cannot_be_updated_when_device_locale_is_missing() {
 		$push_token = new PushToken(
 			array(
 				'id'          => 1,
@@ -389,12 +456,51 @@ class PushTokenTest extends WC_Unit_Test_Case {
 				'token'       => 'test_token',
 				'device_uuid' => 'test-device-uuid',
 				'platform'    => PushToken::PLATFORM_APPLE,
+				'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'metadata'    => array( 'app_version' => '1.0' ),
 			)
 		);

 		$this->assertFalse( $push_token->can_be_updated() );
 	}

+	/**
+	 * @testdox Tests can_be_created returns true when metadata is missing.
+	 */
+	public function test_it_can_be_created_when_metadata_is_missing() {
+		$push_token = new PushToken(
+			array(
+				'user_id'       => 1,
+				'token'         => 'test_token',
+				'device_uuid'   => 'test-device-uuid',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+			)
+		);
+
+		$this->assertTrue( $push_token->can_be_created() );
+	}
+
+	/**
+	 * @testdox Tests can_be_updated returns true when metadata is missing.
+	 */
+	public function test_it_can_be_updated_when_metadata_is_missing() {
+		$push_token = new PushToken(
+			array(
+				'id'            => 1,
+				'user_id'       => 1,
+				'token'         => 'test_token',
+				'device_uuid'   => 'test-device-uuid',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+			)
+		);
+
+		$this->assertTrue( $push_token->can_be_updated() );
+	}
+
 	/**
 	 * @testdox Tests set_platform throws exception with invalid platform.
 	 */
@@ -465,6 +571,54 @@ class PushTokenTest extends WC_Unit_Test_Case {
 		$push_token->set_user_id( -1 );
 	}

+	/**
+	 * @testdox Tests set_device_locale throws exception with empty string.
+	 */
+	public function test_it_throws_exception_when_setting_empty_device_locale() {
+		$push_token = new PushToken();
+
+		$this->expectException( PushTokenInvalidDataException::class );
+		$this->expectExceptionMessage( 'Device locale cannot be empty.' );
+
+		$push_token->set_device_locale( '' );
+	}
+
+	/**
+	 * @testdox Tests set_device_locale throws exception with invalid format.
+	 */
+	public function test_it_throws_exception_when_setting_invalid_device_locale_format() {
+		$push_token = new PushToken();
+
+		$this->expectException( PushTokenInvalidDataException::class );
+		$this->expectExceptionMessage( 'Device locale is an invalid format.' );
+
+		$push_token->set_device_locale( 'invalid' );
+	}
+
+	/**
+	 * @testdox Tests set_device_locale throws exception with lowercase region.
+	 */
+	public function test_it_throws_exception_when_setting_device_locale_with_lowercase_region() {
+		$push_token = new PushToken();
+
+		$this->expectException( PushTokenInvalidDataException::class );
+		$this->expectExceptionMessage( 'Device locale is an invalid format.' );
+
+		$push_token->set_device_locale( 'en_gb' );
+	}
+
+	/**
+	 * @testdox Tests set_metadata throws exception with non-scalar values.
+	 */
+	public function test_it_throws_exception_when_setting_non_scalar_metadata() {
+		$push_token = new PushToken();
+
+		$this->expectException( PushTokenInvalidDataException::class );
+		$this->expectExceptionMessage( 'Metadata items must be scalar values.' );
+
+		$push_token->set_metadata( array( 'nested' => array( 'a' => 'b' ) ) );
+	}
+
 	/**
 	 * @testdox Tests set_token throws exception with empty string.
 	 */
@@ -550,12 +704,14 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	public function test_constructor_creates_token_with_all_properties() {
 		$push_token = new PushToken(
 			array(
-				'id'          => 123,
-				'user_id'     => 456,
-				'token'       => 'test_token_value',
-				'device_uuid' => 'device-uuid-123',
-				'platform'    => PushToken::PLATFORM_APPLE,
-				'origin'      => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'id'            => 123,
+				'user_id'       => 456,
+				'token'         => 'test_token_value',
+				'device_uuid'   => 'device-uuid-123',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
 			)
 		);

@@ -565,6 +721,8 @@ class PushTokenTest extends WC_Unit_Test_Case {
 		$this->assertSame( 'device-uuid-123', $push_token->get_device_uuid() );
 		$this->assertSame( PushToken::PLATFORM_APPLE, $push_token->get_platform() );
 		$this->assertSame( PushToken::ORIGIN_WOOCOMMERCE_IOS, $push_token->get_origin() );
+		$this->assertSame( 'en_US', $push_token->get_device_locale() );
+		$this->assertSame( array( 'app_version' => '1.0' ), $push_token->get_metadata() );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Validators/PushTokenValidatorTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Validators/PushTokenValidatorTest.php
index aa96b12a2b..07f3917cca 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Validators/PushTokenValidatorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Validators/PushTokenValidatorTest.php
@@ -20,12 +20,14 @@ class PushTokenValidatorTest extends WC_Unit_Test_Case {
 	public function test_it_validates_all_keys(): void {
 		$result = PushTokenValidator::validate(
 			array(
-				'id'          => 1,
-				'user_id'     => 42,
-				'origin'      => PushToken::ORIGINS[0],
-				'platform'    => PushToken::PLATFORM_APPLE,
-				'device_uuid' => 'valid-uuid-123',
-				'token'       => null,
+				'id'            => 1,
+				'user_id'       => 42,
+				'origin'        => PushToken::ORIGINS[0],
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'device_uuid'   => 'valid-uuid-123',
+				'device_locale' => 'en_US',
+				'token'         => null,
+				'metadata'      => array(),
 			)
 		);

@@ -39,12 +41,14 @@ class PushTokenValidatorTest extends WC_Unit_Test_Case {
 	public function test_it_validates_all_keys_with_valid_data(): void {
 		$result = PushTokenValidator::validate(
 			array(
-				'id'          => 1,
-				'user_id'     => 42,
-				'origin'      => PushToken::ORIGINS[0],
-				'platform'    => PushToken::PLATFORM_APPLE,
-				'device_uuid' => 'valid-uuid-123',
-				'token'       => str_repeat( 'a', 64 ),
+				'id'            => 1,
+				'user_id'       => 42,
+				'origin'        => PushToken::ORIGINS[0],
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'device_uuid'   => 'valid-uuid-123',
+				'device_locale' => 'en_US',
+				'token'         => str_repeat( 'a', 64 ),
+				'metadata'      => array(),
 			)
 		);

@@ -478,6 +482,74 @@ class PushTokenValidatorTest extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Should return true for valid locale formats.
+	 * @dataProvider valid_locales_provider
+	 * @param string $locale The locale to test.
+	 */
+	public function test_validate_accepts_valid_device_locale_formats( string $locale ): void {
+		$this->assertTrue(
+			PushTokenValidator::validate(
+				array( 'device_locale' => $locale ),
+				array( 'device_locale' )
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should return WP_Error when device locale is missing.
+	 */
+	public function test_validate_rejects_missing_device_locale(): void {
+		$result = PushTokenValidator::validate(
+			array(),
+			array( 'device_locale' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Device locale is required.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when device locale is not a string.
+	 */
+	public function test_validate_rejects_non_string_for_device_locale(): void {
+		$result = PushTokenValidator::validate(
+			array( 'device_locale' => 123 ),
+			array( 'device_locale' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Device locale must be a string.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when device locale is empty.
+	 */
+	public function test_validate_rejects_empty_device_locale(): void {
+		$result = PushTokenValidator::validate(
+			array( 'device_locale' => '' ),
+			array( 'device_locale' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Device locale cannot be empty.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error for invalid locale formats.
+	 * @dataProvider invalid_locales_provider
+	 * @param string $locale The locale to test.
+	 */
+	public function test_validate_rejects_invalid_formats_for_device_locale( string $locale ): void {
+		$result = PushTokenValidator::validate(
+			array( 'device_locale' => $locale ),
+			array( 'device_locale' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Device locale is an invalid format.', $result->get_error_message() );
+	}
+
 	/**
 	 * @testdox Should return true for a valid Apple token.
 	 */
@@ -751,22 +823,112 @@ class PushTokenValidatorTest extends WC_Unit_Test_Case {
 		$this->assertSame( 'Token is an invalid format.', $result->get_error_message() );
 	}

+	/**
+	 * @testdox Should return true for a valid metadata array with values.
+	 */
+	public function test_validate_accepts_valid_array_for_metadata(): void {
+		$this->assertTrue(
+			PushTokenValidator::validate(
+				array( 'metadata' => array( 'app_version' => '1.0.0' ) ),
+				array( 'metadata' )
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should return true for an empty metadata array.
+	 */
+	public function test_validate_accepts_empty_array_for_metadata(): void {
+		$this->assertTrue(
+			PushTokenValidator::validate(
+				array( 'metadata' => array() ),
+				array( 'metadata' )
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should return WP_Error when metadata is missing.
+	 */
+	public function test_validate_rejects_missing_metadata(): void {
+		$result = PushTokenValidator::validate( array(), array( 'metadata' ) );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Metadata is required.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when metadata is not an array.
+	 */
+	public function test_validate_rejects_non_array_for_metadata(): void {
+		$result = PushTokenValidator::validate(
+			array( 'metadata' => 'not an array' ),
+			array( 'metadata' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Metadata must be an array.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when metadata contains non-scalar values.
+	 */
+	public function test_validate_rejects_non_scalar_metadata_items(): void {
+		$result = PushTokenValidator::validate(
+			array( 'metadata' => array( 'nested' => array( 'a' => 'b' ) ) ),
+			array( 'metadata' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Metadata items must be scalar values.', $result->get_error_message() );
+	}
+
 	/**
 	 * @testdox Should use the standard error code for all validation errors.
+	 * @dataProvider validatable_fields_provider
+	 * @param string $field The field to validate.
+	 */
+	public function test_all_errors_use_standard_error_code( string $field ): void {
+		/**
+		 * If field isn't platform, pass the platform in so we can check token
+		 * and device_uuid.
+		 */
+		$data  = 'platform' === $field ? array() : array( 'platform' => PushToken::PLATFORM_APPLE );
+		$error = PushTokenValidator::validate( $data, array( $field ) );
+
+		$this->assertInstanceOf( WP_Error::class, $error );
+		$this->assertSame( PushTokenValidator::ERROR_CODE, $error->get_error_code() );
+	}
+
+	/**
+	 * Data provider for valid locale formats.
+	 *
+	 * @return array
 	 */
-	public function test_all_errors_use_standard_error_code(): void {
-		$errors = array(
-			PushTokenValidator::validate( array(), array( 'id' ) ),
-			PushTokenValidator::validate( array(), array( 'user_id' ) ),
-			PushTokenValidator::validate( array(), array( 'origin' ) ),
-			PushTokenValidator::validate( array(), array( 'platform' ) ),
-			PushTokenValidator::validate( array(), array( 'token' ) ),
+	public function valid_locales_provider(): array {
+		return array(
+			'English US'   => array( 'en_US' ),
+			'French'       => array( 'fr_FR' ),
+			'Chinese'      => array( 'zh_CN' ),
+			'Portuguese'   => array( 'pt_BR' ),
+			'Three-letter' => array( 'ast_ES' ),
 		);
+	}

-		foreach ( $errors as $error ) {
-			$this->assertInstanceOf( WP_Error::class, $error );
-			$this->assertSame( PushTokenValidator::ERROR_CODE, $error->get_error_code() );
-		}
+	/**
+	 * Data provider for invalid locale formats.
+	 *
+	 * @return array
+	 */
+	public function invalid_locales_provider(): array {
+		return array(
+			'no underscore'    => array( 'enUS' ),
+			'lowercase region' => array( 'en_gb' ),
+			'uppercase lang'   => array( 'EN_US' ),
+			'just language'    => array( 'en' ),
+			'with hyphen'      => array( 'en-US' ),
+			'too long lang'    => array( 'engl_US' ),
+		);
 	}

 	/**
@@ -786,4 +948,13 @@ class PushTokenValidatorTest extends WC_Unit_Test_Case {
 	public function valid_origins_provider(): array {
 		return array_map( fn ( $value ) => array( $value ), PushToken::ORIGINS );
 	}
+
+	/**
+	 * Data provider for validatable fields.
+	 *
+	 * @return array
+	 */
+	public function validatable_fields_provider(): array {
+		return array_map( fn ( $value ) => array( $value ), PushTokenValidator::VALIDATABLE_FIELDS );
+	}
 }