Index: /trunk/src/wp-admin/includes/class-wp-plugin-install-list-table.php
===================================================================
--- /trunk/src/wp-admin/includes/class-wp-plugin-install-list-table.php	(revision 50920)
+++ /trunk/src/wp-admin/includes/class-wp-plugin-install-list-table.php	(revision 50921)
@@ -48,6 +48,8 @@
 		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;
+				}
 			}
 		}
@@ -55,6 +57,8 @@
 		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;
+				}
 			}
 		}
Index: /trunk/src/wp-admin/includes/plugin.php
===================================================================
--- /trunk/src/wp-admin/includes/plugin.php	(revision 50920)
+++ /trunk/src/wp-admin/includes/plugin.php	(revision 50921)
@@ -45,4 +45,5 @@
  * @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.
@@ -64,4 +65,5 @@
  *     @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.
  * }
  */
@@ -80,4 +82,5 @@
 		'RequiresWP'  => 'Requires at least',
 		'RequiresPHP' => 'Requires PHP',
+		'UpdateURI'   => 'Update URI',
 		// Site Wide Only is deprecated in favor of Network.
 		'_sitewide'   => 'Site Wide Only',
Index: /trunk/src/wp-admin/includes/update.php
===================================================================
--- /trunk/src/wp-admin/includes/update.php	(revision 50920)
+++ /trunk/src/wp-admin/includes/update.php	(revision 50921)
@@ -436,5 +436,22 @@
 
 	$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 */
@@ -462,6 +479,6 @@
 			'<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() ),
Index: /trunk/src/wp-includes/update.php
===================================================================
--- /trunk/src/wp-includes/update.php	(revision 50920)
+++ /trunk/src/wp-includes/update.php	(revision 50921)
@@ -297,6 +297,9 @@
 	}
 
-	$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();
@@ -328,5 +331,5 @@
 
 		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'] ) {
@@ -419,36 +422,112 @@
 	$response = json_decode( wp_remote_retrieve_body( $raw_response ), true );
 
-	foreach ( $response['plugins'] as &$plugin ) {
-		$plugin = (object) $plugin;
-
-		if ( isset( $plugin->compatibility ) ) {
-			$plugin->compatibility = (object) $plugin->compatibility;
-
-			foreach ( $plugin->compatibility as &$data ) {
-				$data = (object) $data;
+	if ( $response && is_array( $response ) ) {
+		$updates->response     = $response['plugins'];
+		$updates->translations = $response['translations'];
+		$updates->no_update    = $response['no_update'];
+	}
+
+	// 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;
+		}
+
+		$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( $plugin, $data );
-
-	foreach ( $response['no_update'] as &$plugin ) {
-		$plugin = (object) $plugin;
-	}
-
-	unset( $plugin );
-
-	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();
-	}
-
-	set_site_transient( 'update_plugins', $new_option );
+
+		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;
+		}
+	}
+
+	$sanitize_plugin_update_payload = function( &$item ) {
+		$item = (object) $item;
+
+		unset( $item->translations, $item->compatibility );
+
+		return $item;
+	};
+
+	array_walk( $updates->response, $sanitize_plugin_update_payload );
+	array_walk( $updates->no_update, $sanitize_plugin_update_payload );
+
+	set_site_transient( 'update_plugins', $updates );
 }
 
