How to Configure Self-Hosted Updates for Your Private Plugins
In this tutorial I will show you how to develop a custom update repository for your private plugin. It will be helpful if you’re developing plugins for sale.
We are not going to use any ready or bloated PHP-libraries for that, only two filter hooks plugins_api
and site_transient_update_plugins
and transient cache, that’s all.
You can also download the full code as a ready plugin from my GitHub.

And remember if you need some custom coding help, you can always get in touch with me and my team.
And now let’s go do it!
1. Prepare your Custom Update Server
Update server could be anything, it can be a subdomain or just a directory on your website.
The long story short, here is the screenshot of the files on my update server.

Let’s configure our info.json
file now.
{
"name" : "Misha Update Checker",
"slug" : "misha-update-checker",
"author" : "<a href='https://rudrastyh.com'>Misha Rudrastyh</a>",
"author_profile" : "http://profiles.wordpress.org/rudrastyh",
"version" : "2.0",
"download_url" : "https://rudrastyh.com/wp-content/uploads/updater/misha-update-checker.zip",
"requires" : "3.0",
"tested" : "5.8",
"requires_php" : "5.3",
"last_updated" : "2021-01-30 02:10:00",
"sections" : {
"description" : "This simple plugin does nothing, only gets updates from a custom server",
"installation" : "Click the activate button and that's it.",
"changelog" : "<h4>1.0 – 1 august 2021</h4><ul><li>Bug fixes.</li><li>Initital release.</li></ul>"
},
"banners" : {
"low" : "https://rudrastyh.com/wp-content/uploads/updater/banner-772x250.jpg",
"high" : "https://rudrastyh.com/wp-content/uploads/updater/banner-1544x500.jpg"
}
}
Also do not forget to check if info.json
is valid, you can do it with any json validator you find in google, I used this one for example.
Another way is to generate info.json
via PHP, here is how you can do it.
$update = array(
'name' => 'Misha Update Checker',
'slug' => 'misha-update-checker',
...
);
header( 'Content-Type: application/json' );
echo json_encode( $update );
The advantage of PHP method is that you can pass some get parameters to it like info.php?plugin_id=5
or check plugin license as well.
Please note, that this json-file could have any parameter names, it doesn’t matter in this step. It matters below, when we pass these parameters into WordPress Plugins API.
Also an important note about the files and folders structure inside a zip archive.
No 👿 | Yes ✔️ |
---|---|
misha-update-checker.zip ––misha-update-checker.php ––readme.txt | misha-update-checker.zip ––misha-update-checker/ ––––misha-update-checker.php ––––readme.txt |
Here is what happens if you skip this advise: after you click the “Update” button, your old version of the plugin will be removed, but the new one won’t replace it but will be created in a randomly generated folder like misha-update-checker-xhfuif/
as a result you will get a notice: “The plugin has been deactivated due to an error: Plugin file does not exist.”

2. Plugin Information Modal
Usually when you click “View Details”, WordPress shows you the appropriate plugin information from its own wordpress.org update server. And the most interesting thing is that this info can be hooked before WordPress connects to wordpress.org server.
Here is what I am talking about:

For the simplicity of the code I skipped using Transients API to cache the remote request, but you have to use it in your code. Check the plugin code on GitHub.
add_filter( 'plugins_api', 'misha_plugin_info', 20, 3);
/*
* $res empty at this step
* $action 'plugin_information'
* $args stdClass Object ( [slug] => woocommerce [is_ssl] => [fields] => Array ( [banners] => 1 [reviews] => 1 [downloaded] => [active_installs] => 1 ) [per_page] => 24 [locale] => en_US )
*/
function misha_plugin_info( $res, $action, $args ){
// do nothing if this is not about getting plugin information
if( 'plugin_information' !== $action ) {
return $res;
}
// do nothing if it is not our plugin
if( plugin_basename( __DIR__ ) !== $args->slug ) {
return $res;
}
// info.json is the file with the actual plugin information on your server
$remote = wp_remote_get(
'https://rudrastyh.com/wp-content/uploads/updater/info.json',
array(
'timeout' => 10,
'headers' => array(
'Accept' => 'application/json'
)
)
);
// do nothing if we don't get the correct response from the server
if(
is_wp_error( $remote )
|| 200 !== wp_remote_retrieve_response_code( $remote )
|| empty( wp_remote_retrieve_body( $remote )
) {
return $res;
}
$remote = json_decode( wp_remote_retrieve_body( $remote ) );
$res = new stdClass();
$res->name = $remote->name;
$res->slug = $remote->slug;
$res->author = $remote->author;
$res->author_profile = $remote->author_profile;
$res->version = $remote->version;
$res->tested = $remote->tested;
$res->requires = $remote->requires;
$res->requires_php = $remote->requires_php;
$res->download_link = $remote->download_url;
$res->trunk = $remote->download_url;
$res->last_updated = $remote->last_updated;
$res->sections = array(
'description' => $remote->sections->description,
'installation' => $remote->sections->installation,
'changelog' => $remote->sections->changelog
// you can add your custom sections (tabs) here
);
// in case you want the screenshots tab, use the following HTML format for its content:
// <ol><li><a href="IMG_URL" target="_blank"><img src="IMG_URL" alt="CAPTION" /></a><p>CAPTION</p></li></ol>
if( ! empty( $remote->sections->screenshots ) ) {
$res->sections[ 'screenshots' ] = $remote->sections->screenshots;
}
$res->banners = array(
'low' => $remote->banners->low,
'high' => $remote->banners->high
);
return $res;
}
On line 15 we get the plugin slug using plugin_basename( __DIR__ )
, I would like to talk more on that:
plugin_basename( __DIR__ ) | misha-update-checker |
plugin_basename( __FILE__ ) | misha-update-checker/misha-update-checker.php |
And please keep in mind, that the link “View details” won’t appear until you implement the next step of this tutorial.
stdClass object properties
In the above code we created stdClass
object with some parameter to pass into WordPress API.
- name
- Plugin name.
- slug
- Plugin slug.
- author
- Plugin author.
- author_profile.
- Author profile URL on wordpress.org, example https://profiles.wordpress.org/rudrastyh/.
- contributors
- The associative array of contributors wordpress.org profile name and URLs, like
array( 'rudrastyh' => 'https://profiles.wordpress.org/rudrastyh', 'contributor' => 'https://profiles.wordpress.org/contributor', )
- version
- Current plugin version available to install.
- tested
- The latest WordPress version plugin tested with.
- requires
- The minimum WordPress version required.
- requires_php
- The minimum PHP version required.
- rating
- The rating count from 1 to 100
- ratings
- The 5-star rating, how much votes for each star, example:
array( 5 => 2104, 4 => 116, 3 => 64, 2 => 57, 1 => 175, )
- num_ratings
- The amount of overall votes
- support_threads
- The number of threads on support forum
- support_threads_resolved
- The number of resolved threads on support forum
- active_installs
- The number of active plugin installations
- added
- The date in YYYY-MM-DD format
- last updated
- The date in YYYY-MM-DD format
- homepage
- The plugin homepage URL
- reviews
- The HTML of plugin reviews.
- versions
- The array of plugin versions and their URLs, example
array( '1.0' => 'URL of zip archive of 1.0 plugin version', 'trunk' => 'URL of the latest plugin version', )
- donate_link
- The URL of donate link
Push the Update Information into WP Transients
This is the final step, after implementing it you will got this:

And again, for the simplicity of the code below I skipped using Transients API to cache the remote request, but you have to use it. Check how in the plugin code on GitHub.
add_filter( 'site_transient_update_plugins', 'misha_push_update' );
function misha_push_update( $transient ){
if ( empty( $transient->checked ) ) {
return $transient;
}
$remote = wp_remote_get(
'https://rudrastyh.com/wp-content/uploads/updater/info.json',
array(
'timeout' => 10,
'headers' => array(
'Accept' => 'application/json'
)
)
);
if(
is_wp_error( $remote )
|| 200 !== wp_remote_retrieve_response_code( $remote )
|| empty( wp_remote_retrieve_body( $remote )
) {
return $transient;
}
$remote = json_decode( wp_remote_retrieve_body( $remote ) );
// your installed plugin version should be on the line below! You can obtain it dynamically of course
if(
$remote
&& version_compare( $this->version, $remote->version, '<' )
&& version_compare( $remote->requires, get_bloginfo( 'version' ), '<' )
&& version_compare( $remote->requires_php, PHP_VERSION, '<' )
) {
$res = new stdClass();
$res->slug = $remote->slug;
$res->plugin = plugin_basename( __FILE__ ); // it could be just YOUR_PLUGIN_SLUG.php if your plugin doesn't have its own directory
$res->new_version = $remote->version;
$res->tested = $remote->tested;
$res->package = $remote->download_url;
$transient->response[ $res->plugin ] = $res;
//$transient->checked[$res->plugin] = $remote->version;
}
return $transient;
}
And remember remember, if you need custom coding help, you can always get in touch with me and my team.

Misha Rudrastyh
Hey guys and welcome to my website. For more than 10 years I've been doing my best to share with you some superb WordPress guides and tips for free.
Need some developer help? Contact me
Hi, Misha! Excellent article, I’ve been looking for this solution for a long time. There is one question – on what principle is the file json
Are there any requirements for it?
Hi,
Thank you!
There are no rules for json, because you get it in your PHP code and restructure the way you need. It just must be valid :)
I plan to publish a tutorial with tips how to generate json in PHP and return different data depending on the parameters you sent in it, so, it is the way to check license keys, update expiration date etc.
OK got it. Thank you, Misha
Searching everywhere, but
results in $actions is not defined. This is what the WP code reference says:
$action is never defined, I also checked your test plugin.
Am I missing something?
Hmmm… Never faced with this error notice.
I solved the issue above. Now I’m facing a different problem with the
plugins_api
hook. It’s firing on all plugins except mine (When I click View Details on another plugin, I see my own plugin info). No idea why it’s happening. I can also see the update notification bubble in the menu because of thesite_transient_update_plugins
but the update message doesn’t show on my plugin.More detail here: https://github.com/DevinVinson/WordPress-Plugin-Boilerplate/issues/477
Perhaps you’ve ran into this before? I know you’re busy, but I’d love to hear your thoughts 🤓
It is because the hook is fired for every plugin. You have to add some conditionals inside the callback function 👍
Example: here, lines 14-15.
Yeah I figured. And I am using conditions (see code on that Github issue), I debugged $args->slug and my own plugin slug and they both match. Still it’s not passing that condition 🤔
Great! Thanks for an article!
This is awesome. Thanks.
the test plugin does not work with WP5..
there is a fix?
Seems Ok with WP 5.0.1
I try it in more sites and servers but does not work.. not show “more details” and update message. only the badge “1” on “Plugin” admin menu label.
It is fixed.
Hi, thanks for this useful tutorial! One question, how do you generate automatically a json file? do you use svn and a script? Thanks.
Hi Sergio,
Just in PHP
Good, thanks for your reply Misha. So you have a script that generates the json one time or on the fly reading from the readme.txt?
Hello, your article has helped me a lot, but I would like to know how I do that when a new version is detected it is updated automatically, since I have created a special plugin for my clients, and these are not that they have the right knowledge, then it is created a secondary profile where you only have the necessary options as I do to launch that automatic update, or put in the options page that they have to update?
regards
Same for me “I try it in more sites and servers but does not work.. not show “more details” and update message. only the badge “1” on “Plugin” admin menu label.”
WordPress version-> 5.2.1
Thank you for your comment Sam, I will check it out.
same issue is does not show update message only shows badge
It is fixed.
Hi, thanks for this useful tutorial!
I’m using WordPress 5.2.2, I have a problem: after updated my plugin, the wordpress auto add postfix to the plugin folder. Example: my plugin: my-plugin/my-plugin.php, after updated it has been change to: my-plugin-xyz/my-plugin.php
Hi Teo,
Please check your archive folder structure, described in the first part of the tutorial.
Hello Misha, how are you? I did exactly as requested until it appears that it has an update, but the field to update does not appear.
See the attached image.
https://pointcomunicacao.ppg.br/update-plugins/point-pagina-login/print.png
Could my help please?
Resolvido… :)
Perfeito! 🙃
I’m having the same issue. How did you get it the update text to appear?
Bah… I had a typo found in $res->plugin (wrong path)
Thanks for the guide. Everything works great :D
Great 😁💪
Really helpful, after looking around for awhile I am really greatful for this solution. Thanks for the help! Working as of 07 March 2020, WP 5.3.2
You’re welcome! I am happy to help! 🔥
Hello Misha,
thanks for this nice Article.
I got it to work.
I even puted my json file behind a htaccess password protection
But i want to put my zip file behind a htaccess password protection too.
Until now i didnt found a solution.
Do you have any idea?
Hi Misha,
i found a little Bug.
The Function misha_plugin_info should everytime return $res and not false.
If you use the Code in two different Plugins on the same Website, the hook would otherwise override one of the two ‘View details’ Windows and you would get the Message ‘Plugin not found.’ instead.
Please my question!
When create 2 plugin, There will be 1 error when clicking to view plugin details
Hello, what error?
Hello, I just wanted to say that this was a very good tutorial and works without any problems.
I actually wrote an extension based on your approach which I am thinking of using as a drop-in module in one of my Pro plugins.
This still needs to be extensivly tested from my end, but seems to work very good.
I am impressed.
Please let me know I you would like to talk about plugin development; perhaps sharing some ideas and/or giving some advices.
For instance, I would like to talk about licensing and what you should and should not do =)
Regards,
Krister
Hello,
Thank you :)
Please contact me
Hello,
first of all, thank you for this article. But I have a question/an error, is it correct that the details page is only displayed when an update is available? If so, is it possible to display this page permanently?
Regards,
Till
Hey Till,
Yes, absolutely:
Great tutorial (as always), but I have a question. Do you have something like that but for self created themes? Is it possible to add something like this?
Thank you!
I don’t.. yet
Thanks for this. Kinda odd that the WordPress Codex doesn’t cover this.
Hey Misha, this is awesome, think it is going to work great for me. My issue is that the plugin updates successfully, but the notice update just pops up again, even though the installed version matches the info.json version. Any thoughts? Thanks!