diff --git a/src/wp-admin/includes/class-wp-plugin-install-list-table.php b/src/wp-admin/includes/class-wp-plugin-install-list-table.php
index 3067f97674..9c5f382e9d 100644
--- a/src/wp-admin/includes/class-wp-plugin-install-list-table.php
+++ b/src/wp-admin/includes/class-wp-plugin-install-list-table.php
@@ -47,15 +47,19 @@ class WP_Plugin_Install_List_Table extends WP_List_Table {
 		$plugin_info = get_site_transient( 'update_plugins' );
 		if ( isset( $plugin_info->no_update ) ) {
 			foreach ( $plugin_info->no_update as $plugin ) {
-				$plugin->upgrade          = false;
-				$plugins[ $plugin->slug ] = $plugin;
+				if ( isset( $plugin->slug ) ) {
+					$plugin->upgrade          = false;
+					$plugins[ $plugin->slug ] = $plugin;
+				}
 			}
 		}
 
 		if ( isset( $plugin_info->response ) ) {
 			foreach ( $plugin_info->response as $plugin ) {
-				$plugin->upgrade          = true;
-				$plugins[ $plugin->slug ] = $plugin;
+				if ( isset( $plugin->slug ) ) {
+					$plugin->upgrade          = true;
+					$plugins[ $plugin->slug ] = $plugin;
+				}
 			}
 		}
 
diff --git a/src/wp-admin/includes/plugin.php b/src/wp-admin/includes/plugin.php
index 3a4b488127..55d57e31e2 100644
--- a/src/wp-admin/includes/plugin.php
+++ b/src/wp-admin/includes/plugin.php
@@ -44,6 +44,7 @@
  *
  * @since 1.5.0
  * @since 5.3.0 Added support for `Requires at least` and `Requires PHP` headers.
+ * @since 5.8.0 Added support for `Update URI` header.
  *
  * @param string $plugin_file Absolute path to the main plugin file.
  * @param bool   $markup      Optional. If the returned data should have HTML markup applied.
@@ -63,6 +64,7 @@
  *     @type bool   $Network     Whether the plugin can only be activated network-wide.
  *     @type string $RequiresWP  Minimum required version of WordPress.
  *     @type string $RequiresPHP Minimum required version of PHP.
+ *     @type string $UpdateURI   ID of the plugin for update purposes, should be a URI.
  * }
  */
 function get_plugin_data( $plugin_file, $markup = true, $translate = true ) {
@@ -79,6 +81,7 @@ function get_plugin_data( $plugin_file, $markup = true, $translate = true ) {
 		'Network'     => 'Network',
 		'RequiresWP'  => 'Requires at least',
 		'RequiresPHP' => 'Requires PHP',
+		'UpdateURI'   => 'Update URI',
 		// Site Wide Only is deprecated in favor of Network.
 		'_sitewide'   => 'Site Wide Only',
 	);
diff --git a/src/wp-admin/includes/update.php b/src/wp-admin/includes/update.php
index 956396f4c8..aeccc3e049 100644
--- a/src/wp-admin/includes/update.php
+++ b/src/wp-admin/includes/update.php
@@ -435,7 +435,24 @@ function wp_plugin_update_row( $file, $plugin_data ) {
 	);
 
 	$plugin_name = wp_kses( $plugin_data['Name'], $plugins_allowedtags );
-	$details_url = self_admin_url( 'plugin-install.php?tab=plugin-information&plugin=' . $response->slug . '&section=changelog&TB_iframe=true&width=600&height=800' );
+	$plugin_slug = isset( $response->slug ) ? $response->slug : $response->id;
+
+	if ( isset( $response->slug ) ) {
+		$details_url = self_admin_url( 'plugin-install.php?tab=plugin-information&plugin=' . $plugin_slug . '&section=changelog' );
+	} elseif ( isset( $response->url ) ) {
+		$details_url = $response->url;
+	} else {
+		$details_url = $plugin_data['PluginURI'];
+	}
+
+	$details_url = add_query_arg(
+		array(
+			'TB_iframe' => 'true',
+			'width'     => 600,
+			'height'    => 800,
+		),
+		$details_url
+	);
 
 	/** @var WP_Plugins_List_Table $wp_list_table */
 	$wp_list_table = _get_list_table(
@@ -461,8 +478,8 @@ function wp_plugin_update_row( $file, $plugin_data ) {
 			'<td colspan="%s" class="plugin-update colspanchange">' .
 			'<div class="update-message notice inline %s notice-alt"><p>',
 			$active_class,
-			esc_attr( $response->slug . '-update' ),
-			esc_attr( $response->slug ),
+			esc_attr( $plugin_slug . '-update' ),
+			esc_attr( $plugin_slug ),
 			esc_attr( $file ),
 			esc_attr( $wp_list_table->get_column_count() ),
 			$notice_type
diff --git a/src/wp-includes/update.php b/src/wp-includes/update.php
index d4b538e29d..79e3b3ea33 100644
--- a/src/wp-includes/update.php
+++ b/src/wp-includes/update.php
@@ -296,8 +296,11 @@ function wp_update_plugins( $extra_stats = array() ) {
 		$current = new stdClass;
 	}
 
-	$new_option               = new stdClass;
-	$new_option->last_checked = time();
+	$updates               = new stdClass;
+	$updates->last_checked = time();
+	$updates->response     = array();
+	$updates->translations = array();
+	$updates->no_update    = array();
 
 	$doing_cron = wp_doing_cron();
 
@@ -327,7 +330,7 @@ function wp_update_plugins( $extra_stats = array() ) {
 		$plugin_changed = false;
 
 		foreach ( $plugins as $file => $p ) {
-			$new_option->checked[ $file ] = $p['Version'];
+			$updates->checked[ $file ] = $p['Version'];
 
 			if ( ! isset( $current->checked[ $file ] ) || (string) $current->checked[ $file ] !== (string) $p['Version'] ) {
 				$plugin_changed = true;
@@ -418,38 +421,114 @@ function wp_update_plugins( $extra_stats = array() ) {
 
 	$response = json_decode( wp_remote_retrieve_body( $raw_response ), true );
 
-	foreach ( $response['plugins'] as &$plugin ) {
-		$plugin = (object) $plugin;
+	if ( $response && is_array( $response ) ) {
+		$updates->response     = $response['plugins'];
+		$updates->translations = $response['translations'];
+		$updates->no_update    = $response['no_update'];
+	}
 
-		if ( isset( $plugin->compatibility ) ) {
-			$plugin->compatibility = (object) $plugin->compatibility;
+	// Support updates for any plugins using the `Update URI` header field.
+	foreach ( $plugins as $plugin_file => $plugin_data ) {
+		if ( ! $plugin_data['UpdateURI'] || isset( $updates->response[ $plugin_file ] ) ) {
+			continue;
+		}
 
-			foreach ( $plugin->compatibility as &$data ) {
-				$data = (object) $data;
+		$hostname = wp_parse_url( esc_url_raw( $plugin_data['UpdateURI'] ), PHP_URL_HOST );
+
+		/**
+		 * Filters the update response for a given plugin hostname.
+		 *
+		 * The dynamic portion of the hook name, `$hostname`, refers to the hostname
+		 * of the URI specified in the `Update URI` header field.
+		 *
+		 * @since 5.8.0
+		 *
+		 * @param array|false $update {
+		 *     The plugin update data with the latest details. Default false.
+		 *
+		 *     @type string $id           Optional. ID of the plugin for update purposes, should be a URI
+		 *                                specified in the `Update URI` header field.
+		 *     @type string $slug         Slug of the plugin.
+		 *     @type string $version      The version of the plugin.
+		 *     @type string $url          The URL for details of the plugin.
+		 *     @type string $package      Optional. The update ZIP for the plugin.
+		 *     @type string $tested       Optional. The version of WordPress the plugin is tested against.
+		 *     @type string $requires_php Optional. The version of PHP which the plugin requires.
+		 *     @type bool   $autoupdate   Optional. Whether the plugin should automatically update.
+		 *     @type array  $icons        Optional. Array of plugin icons.
+		 *     @type array  $banners      Optional. Array of plugin banners.
+		 *     @type array  $banners_rtl  Optional. Array of plugin RTL banners.
+		 *     @type array  $translations {
+		 *         Optional. List of translation updates for the plugin.
+		 *
+		 *         @type string $language   The language the translation update is for.
+		 *         @type string $version    The version of the plugin this translation is for.
+		 *                                  This is not the version of the language file.
+		 *         @type string $updated    The update timestamp of the translation file.
+		 *                                  Should be a date in the `YYYY-MM-DD HH:MM:SS` format.
+		 *         @type string $package    The ZIP location containing the translation update.
+		 *         @type string $autoupdate Whether the translation should be automatically installed.
+		 *     }
+		 * }
+		 * @param array       $plugin_data      Plugin headers.
+		 * @param string      $plugin_file      Plugin filename.
+		 * @param array       $locales          Installed locales to look translations for.
+		 */
+		$update = apply_filters( "update_plugins_{$hostname}", false, $plugin_data, $plugin_file, $locales );
+
+		if ( ! $update ) {
+			continue;
+		}
+
+		$update = (object) $update;
+
+		// Is it valid? We require at least a version.
+		if ( ! isset( $update->version ) ) {
+			continue;
+		}
+
+		// These should remain constant.
+		$update->id     = $plugin_data['UpdateURI'];
+		$update->plugin = $plugin_file;
+
+		// WordPress needs the version field specified as 'new_version'.
+		if ( ! isset( $update->new_version ) ) {
+			$update->new_version = $update->version;
+		}
+
+		// Handle any translation updates.
+		if ( ! empty( $update->translations ) ) {
+			foreach ( $update->translations as $translation ) {
+				if ( isset( $translation['language'], $translation['package'] ) ) {
+					$translation['type'] = 'plugin';
+					$translation['slug'] = isset( $update->slug ) ? $update->slug : $update->id;
+
+					$updates->translations[] = $translation;
+				}
 			}
 		}
+
+		unset( $updates->no_update[ $plugin_file ], $updates->response[ $plugin_file ] );
+
+		if ( version_compare( $update->new_version, $plugin_data['Version'], '>' ) ) {
+			$updates->response[ $plugin_file ] = $update;
+		} else {
+			$updates->no_update[ $plugin_file ] = $update;
+		}
 	}
 
-	unset( $plugin, $data );
+	$sanitize_plugin_update_payload = function( &$item ) {
+		$item = (object) $item;
 
-	foreach ( $response['no_update'] as &$plugin ) {
-		$plugin = (object) $plugin;
-	}
+		unset( $item->translations, $item->compatibility );
 
-	unset( $plugin );
+		return $item;
+	};
 
-	if ( is_array( $response ) ) {
-		$new_option->response     = $response['plugins'];
-		$new_option->translations = $response['translations'];
-		// TODO: Perhaps better to store no_update in a separate transient with an expiry?
-		$new_option->no_update = $response['no_update'];
-	} else {
-		$new_option->response     = array();
-		$new_option->translations = array();
-		$new_option->no_update    = array();
-	}
+	array_walk( $updates->response, $sanitize_plugin_update_payload );
+	array_walk( $updates->no_update, $sanitize_plugin_update_payload );
 
-	set_site_transient( 'update_plugins', $new_option );
+	set_site_transient( 'update_plugins', $updates );
 }
 
 /**