Developer Integration Guide
How to connect WordPress plugins, PHP apps, Laravel projects, Android apps, Windows/macOS/Linux desktop apps, Electron apps, browser extensions, NPM/Python packages, Docker bundles, and generic projects to this update server.
Your API endpoint:
Table of Contents
- How It Works
- Public vs Licensed Projects
- WordPress Plugin Integration
- PHP / Laravel Integration
- Android Integration
- Windows App / UNI-SERVER Integration
- macOS, Linux, Electron, Extensions, NPM, Python, Docker & Generic
- Releasing a New Version
- API Reference
- Checksum Verification
- Rollback / Specific Version Downloads
- Admin Management Features
- Health / Status Endpoint
How It Works
This is a self-hosted update server. Instead of distributing plugins through WordPress.org or another marketplace, your apps call this server's API to check for updates and download new versions. The flow is:
- Your plugin/app calls the API with its current version and slug.
- The server compares that version against the latest stored version.
- If a newer version exists, the server returns metadata and a download URL.
- Your plugin/app downloads the release package. WordPress/PHP/Laravel usually receive generated ZIP files; Android packages receive the APK directly when an APK is uploaded; direct package projects such as Windows, macOS, Linux, Electron, browser extension, NPM, Python, Docker, and Generic receive the uploaded artifact directly and can also use a plain JSON release manifest feed.
Plugin slug rules: lowercase letters, numbers, and hyphens only (e.g. my-cool-plugin). For WordPress plugins, this must exactly match the plugin folder name and main file name.
Public vs Licensed Projects
Each project has its own access mode. A project can be Public / Free or Licensed. This is configured per project in the admin panel when creating or editing the project.
| Mode | Update checks | Downloads | License Manager needed? |
|---|---|---|---|
| Public / Free | No license key required | Anyone can download from the project page or API | No. Public projects keep working even when License Manager is disabled or unavailable. |
| Licensed | license_key required | Download is allowed only after an active key is verified | Yes. Uses the existing license-manager-laravel reseller API. |
Important: License checking is not mandatory for every project. You can host many free/public projects and only protect selected paid projects. Enabling the License Manager connection only makes licensed projects verifiable; it does not force public projects to ask for a license.
For a licensed project, set Access Mode = Licensed and enter the License Manager App ID. The server calls the reseller API with action=get_license, then confirms the license is active and its app_id matches the project App ID.
// Public / Free request — no license key
POST https://wordpress.refat.ovh/api/update.php
action=update_check
request={"slug":"free-project","version":"1.0.0"}
// Licensed request — license key is required
POST https://wordpress.refat.ovh/api/update.php
action=update_check
request={"slug":"paid-project","version":"1.0.0","license_key":"YOUR-LICENSE-KEY"}
For licensed projects, failed verification returns HTTP 403:
HTTP/1.1 403 Forbidden
{ "success": false, "message": "License key is required for this licensed project." }
WordPress Plugin Integration
1. Create the updater class
Save this as class-plugin-updater.php inside your plugin folder. For public/free projects, pass an empty license key. For licensed projects, pass the license key stored in your plugin's option to the constructor.
<?php
if ( ! defined( 'ABSPATH' ) ) exit;
class My_Plugin_Updater {
private $api_url = '', $plugin_file = '', $plugin_slug = '', $current_version = '', $license_key = '';
public function __construct( $api_url, $plugin_file, $license_key = '' ) {
$this->api_url = trailingslashit( $api_url );
$this->plugin_file = $plugin_file;
$this->plugin_slug = basename( dirname( $plugin_file ) );
$this->current_version = get_plugin_data( $plugin_file )['Version'];
$this->license_key = sanitize_text_field( $license_key );
add_filter( 'pre_set_site_transient_update_plugins', [ $this, 'check_for_update' ] );
add_filter( 'plugins_api', [ $this, 'plugin_api_call' ], 10, 3 );
add_action( 'upgrader_process_complete', [ $this, 'clear_update_transient' ], 10, 2 );
}
public function check_for_update( $transient ) {
if ( empty( $transient->checked ) ) return $transient;
$basename = plugin_basename( $this->plugin_file );
$args = [ 'slug' => $this->plugin_slug, 'version' => $this->current_version ];
if ( $this->license_key !== '' ) $args['license_key'] = $this->license_key;
$response = $this->api_request( 'update_check', $args );
if ( $response ) {
$key = version_compare( $response->new_version, $this->current_version, '>' ) ? 'response' : 'no_update';
$transient->$key[ $basename ] = $response;
}
return $transient;
}
public function plugin_api_call( $result, $action, $args ) {
if ( 'plugin_information' !== $action || ! isset( $args->slug ) || $args->slug !== $this->plugin_slug ) return $result;
$request_args = [ 'slug' => $this->plugin_slug ];
if ( $this->license_key !== '' ) $request_args['license_key'] = $this->license_key;
return $this->api_request( 'plugin_information', $request_args ) ?: $result;
}
private function api_request( $action, $args ) {
$response = wp_remote_post( $this->api_url, [
'body' => [ 'action' => $action, 'request' => wp_json_encode( $args ) ],
'timeout' => 15,
]);
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) return false;
$body = json_decode( wp_remote_retrieve_body( $response ), true );
return ( $body && ! empty( $body['success'] ) && ! empty( $body['data'] ) ) ? (object) $body['data'] : false;
}
public function clear_update_transient( $upgrader, $options ) {
if ( isset( $options['action'], $options['type'], $options['plugins'] )
&& 'update' === $options['action'] && 'plugin' === $options['type']
&& in_array( plugin_basename( $this->plugin_file ), $options['plugins'], true )
) delete_site_transient( 'update_plugins' );
}
}
2. Initialise in your main plugin file
<?php
/**
* Plugin Name: Your Plugin Name
* Version: 1.0.0
* Requires PHP: 7.4
*/
require_once __DIR__ . '/class-plugin-updater.php';
function my_plugin_start_updater() {
// Public/free project: keep this empty. Licensed project: read from your settings page.
$license_key = get_option( 'my_plugin_license_key', '' );
new My_Plugin_Updater( 'https://wordpress.refat.ovh/api/update.php', __FILE__, $license_key );
}
add_action( 'admin_init', 'my_plugin_start_updater' );
Testing tip: WordPress caches update checks for ~12 hours via its update_plugins transient. Force an immediate check via Dashboard → Updates → Check Again. The recommended updater class does not add its own cache on top of this — updates appear as soon as WordPress runs its next check.
3. Plugin metadata fields (info.json reference)
When you add a plugin via the admin panel, the server stores this JSON structure. Here's what each field does in the WordPress update dialog:
| Field | Used by WordPress for | Format |
|---|---|---|
name | Plugin name in update list | Plain text |
version | Latest version to compare against | Semver string e.g. 1.2.0 |
requires | Minimum WordPress version warning | e.g. 5.8 |
requires_php | Minimum PHP version warning | e.g. 7.4 |
tested | "Tested up to" in the plugin popup | e.g. 6.5 |
sections.description | Description tab in plugin popup | Plain text (HTML stripped) |
sections.changelog | Changelog tab in plugin popup | HTML — h4, ul, li, strong, em, a allowed |
banners.low | Banner image in plugin popup (772×250) | Image URL or uploaded path |
banners.high | Retina banner (1544×500) | Image URL or uploaded path |
icons.1x | Plugin icon in the list (128×128) | Image URL or uploaded path |
icons.2x | Retina plugin icon (256×256) | Image URL or uploaded path |
license.mode | Project access mode | public or licensed. Public is the default. |
license.app_id | License Manager App ID | Required only when license.mode is licensed. |
PHP / Laravel Integration
Generic PHP
<?php
define('MY_SLUG', 'my-custom-app');
define('MY_VERSION', '1.0.0');
// Public/free project: leave empty. Licensed project: set the customer's key.
define('MY_LICENSE_KEY', '');
$api_url = 'https://wordpress.refat.ovh/api/update.php';
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $api_url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'action' => 'update_check',
'request' => json_encode([
'slug' => MY_SLUG,
'version' => MY_VERSION,
] + (MY_LICENSE_KEY !== '' ? ['license_key' => MY_LICENSE_KEY] : [])),
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => true,
]);
$result = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http === 403) {
// Licensed project only: missing, invalid, inactive, expired, or wrong App ID
$err = json_decode($result);
die("License error: " . ($err->message ?? 'Access denied'));
}
$response = json_decode($result);
if ($response && $response->success && isset($response->data)) {
$data = $response->data;
if (version_compare($data->new_version, MY_VERSION, '>')) {
echo "Update available: v" . $data->new_version . "\n";
echo "Download: " . $data->package . "\n";
}
}
Laravel Artisan Command
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
class CheckProjectUpdate extends Command {
protected $signature = 'project:check-update';
public function handle(): int {
$licenseKey = (string) config('services.updater.license_key', ''); // empty for public/free projects
$response = Http::timeout(15)->post('https://wordpress.refat.ovh/api/update.php', [
'action' => 'update_check',
'request' => json_encode([
'slug' => 'my-laravel-app',
'version' => '1.0.0',
] + ($licenseKey !== '' ? ['license_key' => $licenseKey] : [])),
]);
if ($response->status() === 403) {
$this->error("License error: " . $response->json('message'));
return Command::FAILURE;
}
$data = $response->json('data');
if ($data && version_compare($data['new_version'], '1.0.0', '>')) {
$this->info("New version: " . $data['new_version']);
$this->line("Download: " . $data['package']);
if (!empty($data['checksum'])) $this->line("Checksum: " . $data['checksum']);
} else {
$this->info('Already up to date.');
}
return Command::SUCCESS;
}
}
Android (Kotlin) Integration
Add <uses-permission android:name="android.permission.INTERNET" /> to your manifest, then:
Recommended Android release flow: upload your signed .apk in Admin → Upload New Version. The same package URL then downloads an APK directly, so users do not have to unzip a ZIP wrapper.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
object UpdateChecker {
private const val API_URL = "https://wordpress.refat.ovh/api/update.php"
private const val SLUG = "my-android-app"
private const val VERSION = "1.0.0"
private const val LICENSE_KEY = "" // public/free: empty; licensed: store securely, e.g. BuildConfig
suspend fun checkForUpdate() = withContext(Dispatchers.IO) {
try {
val payload = "action=update_check&request=" +
java.net.URLEncoder.encode(
JSONObject().put("slug", SLUG).put("version", VERSION).apply {
if (LICENSE_KEY.isNotBlank()) put("license_key", LICENSE_KEY)
}.toString(), "UTF-8"
)
val conn = (URL(API_URL).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"; connectTimeout = 15000; readTimeout = 15000
doOutput = true; setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
}
OutputStreamWriter(conn.outputStream).use { it.write(payload) }
if (conn.responseCode != 200) return@withContext null
val json = JSONObject(conn.inputStream.bufferedReader().readText())
if (!json.optBoolean("success")) return@withContext null
val data = json.getJSONObject("data")
Pair(data.optString("new_version"), data.optString("package"))
} catch (e: Exception) { null }
}
}
Windows App / UNI-SERVER Integration
Create a project with Type = Windows App, then upload each Windows release from the project edit page. The server stores the installer/app package directly and exposes both the normal update API and a plain Windows release-feed JSON endpoint.
Supported Windows uploads: .exe, .msi, .msix, .msixbundle, .appinstaller, and .zip.
For apps like UNI-SERVER, point the app's update-feed URL to:
The manifest response is intentionally plain JSON so a desktop app can deserialize it directly:
{
"name": "UNI SERVER",
"channel": "stable",
"version": "1.0.69",
"tag": "win-v1.0.69",
"company": "Uniselv",
"developer": "Uniselv",
"website": "https://uniselv.com",
"repository": "",
"generated_at": "2026-07-07T12:00:00+06:00",
"features": ["Modern Windows app update delivery"],
"download_url": "https://your-server.com/api/update.php?action=download_plugin&slug=uni-server&version=1.0.69",
"checksum": "sha256:..."
}
If the Windows project is licensed, append &license_key=YOUR-LICENSE-KEY to the manifest URL or use the normal update_check POST API with a license key.
macOS, Linux, Electron, Extensions, NPM, Python, Docker & Generic Projects
Create the project using the matching type, upload the final release artifact from the project edit page, and use the generic release manifest endpoint when your app, installer, CLI, or deployment script needs a simple JSON feed.
Supported uploads by type:
- macOS:
.dmg,.pkg,.zip - Linux:
.AppImage,.deb,.rpm,.tar.gz,.tgz,.zip - Electron: Windows/macOS/Linux installer formats including
.exe,.msi,.dmg,.pkg,.AppImage,.deb,.rpm,.tar.gz, and.zip - Browser Extension:
.crx,.xpi,.zip - NPM:
.tgz,.tar.gz,.zip - Python:
.whl,.tar.gz,.tgz,.zip,.exe,.msi - Docker:
.tar,.tar.gz,.tgz,.zip,.yml,.yaml,.json - Generic: broad artifact support for anything that does not fit a dedicated type.
Generic release manifest URL:
{
"name": "My Desktop App",
"slug": "my-desktop-app",
"type": "macos",
"channel": "stable",
"version": "2.1.0",
"tag": "v2.1.0",
"download_url": "https://your-server.com/api/update.php?action=download_plugin&slug=my-desktop-app&version=2.1.0",
"checksum": "sha256:...",
"file_name": "my-desktop-app-2.1.0.dmg",
"features": ["One-line release highlight"]
}
Releasing a New Version
- Bump the version number in your plugin header or version constant.
- Build your release package. For WordPress/PHP/Laravel, upload a ZIP. For Android, upload the signed APK directly. For Windows/macOS/Linux/Electron/browser extension/NPM/Python/Docker/Generic projects, upload the final artifact directly so users or clients receive exactly the intended file.
- In the admin panel: go to your package → Add New Version → enter the version number → upload the package.
- The server automatically updates
info.jsonto reflect the new latest version. - Clients polling the API will receive the update on their next check.
Version comparison: The server uses PHP's version_compare(), which follows standard semver rules. 1.10.0 is correctly treated as greater than 1.9.0.
API Reference v1
All POST requests go to:
All responses are JSON and include "api_version": "v1". CORS is enabled (Access-Control-Allow-Origin: *).
Rate limits: 30 API calls / IP / minute · 10 downloads / IP / minute
POST update_check
Checks whether a newer version exists. Call this on application startup or periodically.
| POST field | Value |
|---|---|
action | update_check |
request | JSON string. Public example: {"slug":"your-slug","version":"1.0.0"}. Licensed example: {"slug":"your-slug","version":"1.0.0","license_key":"KEY"}. license_key is required only for projects whose access mode is licensed. |
Response — always returns the latest version data:
{
"success": true,
"data": {
"slug": "your-slug",
"new_version": "1.2.0",
"package": "https://your-server.com/api/update.php?action=download_plugin&slug=your-slug&version=1.2.0",
"checksum": "sha256:a3f5c8...",
"url": "https://your-server.com/plugin/your-slug",
"tested": "6.7",
"requires_php": "7.4"
}
}
The server always returns the full data object with the latest version. Version comparison is the client's responsibility — compare data.new_version against your installed version using version_compare() to decide whether an update is available.
Response when the plugin slug is not found on the server:
{ "success": true, "data": null }
POST plugin_information
Returns full plugin metadata — used by WordPress for the plugin details popup.
| POST field | Value |
|---|---|
action | plugin_information |
request | JSON string. Public example: {"slug":"your-slug"}. Licensed example: {"slug":"your-slug","license_key":"KEY"}. |
GET download_plugin
Streams the release file for a specific version. Public projects need only slug and version. Licensed projects must also include license_key=KEY. Android packages are served as direct APK downloads when an APK is available. Direct package projects are served as their uploaded artifact. WordPress, PHP, and Laravel releases are served as generated ZIP files.
// Public / Free download
GET https://wordpress.refat.ovh/api/update.php?action=download_plugin&slug=your-slug&version=1.2.0
// Licensed download
GET https://wordpress.refat.ovh/api/update.php?action=download_plugin&slug=your-slug&version=1.2.0&license_key=YOUR-LICENSE-KEY
GET windows_manifest
Returns plain Windows release-feed JSON for Windows desktop apps. Use this for UNI-SERVER style apps that expect a direct manifest URL instead of the normal wrapped API response.
// Public Windows manifest
GET https://wordpress.refat.ovh/api/update.php?action=windows_manifest&slug=uni-server
// Licensed Windows manifest
GET https://wordpress.refat.ovh/api/update.php?action=windows_manifest&slug=uni-server&license_key=YOUR-LICENSE-KEY
GET release_manifest
Returns plain release-feed JSON for manifest-enabled project types: Windows, macOS, Linux, Electron, browser extension, NPM, Python, Docker, and Generic.
// Public generic release manifest
GET https://wordpress.refat.ovh/api/update.php?action=release_manifest&slug=your-project-slug
// Licensed generic release manifest
GET https://wordpress.refat.ovh/api/update.php?action=release_manifest&slug=your-project-slug&license_key=YOUR-LICENSE-KEY
GET ping
Health check — see the Status Endpoint section below.
Error HTTP codes
| Code | Meaning |
|---|---|
400 | Bad request — missing or invalid parameters |
403 | Forbidden — license key missing, invalid, or inactive; or path traversal detected |
404 | Plugin or version not found |
405 | Method not allowed |
429 | Rate limit exceeded — back off and retry |
500 | Server error |
Checksum Verification
Every update_check response includes a checksum field (sha256:<hex>). Always verify this before installing a downloaded package to ensure the file wasn't tampered with in transit.
// PHP example
$file = '/tmp/plugin-update.zip';
file_put_contents($file, file_get_contents($data->package));
$expected = str_replace('sha256:', '', $data->checksum);
$actual = hash_file('sha256', $file);
if (!hash_equals($expected, $actual)) {
throw new RuntimeException('Checksum mismatch — aborting install.');
}
$zip = new ZipArchive();
$zip->open($file);
$zip->extractTo('/path/to/install/');
$zip->close();
Rollback / Specific Version Downloads
Every uploaded version is individually downloadable. Public projects can be downloaded directly. Licensed projects require the same license_key query parameter for rollback downloads.
GET https://wordpress.refat.ovh/api/update.php?action=download_plugin&slug=your-slug&version=1.0.0
The plugin detail page on this server lists all available versions with individual download buttons.
Admin Management Features
The admin dashboard includes scalable controls for large plugin repositories:
- Search and filters: search by name, slug, type, author, status, version, requirements, and tested version. Use the quick filter tabs for active, inactive, sync-needed, and failed-log packages.
- Active/inactive packages: inactive packages stay in admin but are hidden from the public repository and return no update/download availability through the API.
- Public/licensed access: each package can be public/free or licensed. Public packages bypass license checks; licensed packages require an active license key and matching App ID from the reseller API.
- Bulk actions: refresh update status, sync metadata to the latest local version tag, activate/deactivate, clear package cache, create backups, or delete selected plugins.
- Safety backups: backups are created before upload, delete, restore, and bulk delete actions. Backups are stored under
data/backups/{slug}/. - Rollback: open Admin → Edit Plugin → Safety Backups and restore a previous ZIP backup. The current state is backed up before restore.
- History: Admin → Logs shows release, backup, restore, delete, cache, metadata, and failed-upload history with single-delete and failed-only cleanup options.
The package status is stored in plugins/{slug}/info.json as "status": "active" or "status": "inactive". Older packages without this field are treated as active.
Health / Status Endpoint
GET https://wordpress.refat.ovh/api/update.php?action=ping
Returns HTTP 200 and a JSON body when the server is healthy:
{
"success": true,
"status": "ok",
"api_version": "v1",
"plugins": 4,
"server_time": "2025-10-15T12:00:00+00:00"
}
Compatible with UptimeRobot, Better Uptime, and any HTTP health-check service. The full visual status dashboard is available at https://wordpress.refat.ovh/api/status.