Developers
Licensing and updates API for plugin developers
How a PluginLoom plugin activates its license key, checks status and receives WordPress updates. Endpoints, payloads, errors and a drop-in PHP updater class.
Last updated by the PluginLoom team
This page is for the developer who builds a PluginLoom plugin such as BankBridge. It describes the four endpoints the plugin calls and gives a drop-in PHP class that wires them into the WordPress updater.
In short: the plugin sends the license key and the site URL to activate once, calls check about once a day, calls deactivate when the user removes the key, and lets WordPress ask update for new versions. Downloads are signed URLs that expire.
Base URL and format#
| Item | Value |
|---|---|
| Base URL | https://pluginloom.com/api/v1 |
| Request body | JSON (Content-Type: application/json) or form encoded |
| Response | JSON, always with a boolean success |
| Rate limit | 30 requests per minute per IP for license calls, 60 for update. Over the limit returns HTTP 429 with Retry-After. |
| Authentication | The license key itself. No cookies, no API key. |
Activate a site#
POST /licenses/activate
| Field | Required | Notes |
|---|---|---|
license_key | yes | For example BB-7K2QD-9XWMA-3RTNE-H4ZPC |
site_url | yes | home_url() |
instance | no | A random ID you generate once and store in an option, so a cloned database can be told apart |
plugin_version, wp_version, php_version | no | Shown to support staff |
{
"success": true,
"status": "active",
"activated": true,
"site": "example.com",
"license": {
"status": "active",
"expires_at": "2027-10-23T14:02:11.000Z",
"site_limit": 5,
"unlimited": false,
"activations": 2,
"activations_left": 3
},
"product": "bankbridge",
"plan": "5 sites",
"message": "License activated for example.com."
}Activating a site that is already active on the key succeeds and only refreshes lastCheckAt, so it is safe to call again.
Check a license#
POST /licenses/check with license_key and site_url. Returns the same shape with activated true or false for that site. Call it at most once a day (a daily cron event or a 24 hour transient).
Deactivate a site#
POST /licenses/deactivate with license_key and site_url. Frees one activation. Customers can also do this from their account.
Errors#
| HTTP | error | Meaning | What the plugin should show |
|---|---|---|---|
| 400 | invalid_request | Missing or malformed key or site URL | Ask the user to check the key |
| 404 | invalid_key | No such key | "This license key was not found." |
| 403 | expired | Past the expiry date and grace period | Offer the renew link from renew_url |
| 403 | revoked | Revoked by staff | Point to support |
| 403 | refunded | The order was refunded | Point to the store |
| 403 | site_limit_reached | All activations are in use | Link to the account page to free a site |
| 429 | rate_limited | Too many requests | Retry after Retry-After seconds |
An expired, revoked or refunded key never stops the plugin from running. The code is GPL. Only updates and support stop.
Updates for WordPress#
GET /update?slug=bankbridge&version=1.2.0&key=LICENCE&site=example.com
The response follows the fields WordPress expects in update_plugins and plugins_api:
{
"success": true,
"slug": "bankbridge",
"name": "BankBridge",
"version": "1.3.0",
"new_version": "1.3.0",
"update_available": true,
"package": "https://pluginloom.com/api/v1/download?v=...&l=...&e=...&s=...",
"download_url": "https://pluginloom.com/api/v1/download?v=...",
"url": "https://pluginloom.com/plugins/bankbridge",
"tested": "6.9",
"requires": "6.4",
"requires_php": "8.1",
"last_updated": "2026-11-02 10:00:00",
"sections": { "description": "<p>...</p>", "installation": "<p>...</p>", "changelog": "<h4>1.3.0</h4>..." },
"license_status": "active"
}package is a signed link valid for 24 hours. It is empty when the key is missing, expired, revoked or refunded; in that case upgrade_notice explains why, and WordPress shows the update without an install button.
Drop-in updater class#
Save as includes/class-pluginloom-updater.php in the plugin and create it from the main plugin file:
new PluginLoom_Updater( __FILE__, 'bankbridge', BANKBRIDGE_VERSION, get_option( 'bankbridge_license_key', '' ) );<?php
/**
* PluginLoom license + update client. GPL-2.0-or-later.
*/
if ( ! defined( 'ABSPATH' ) ) { exit; }
class PluginLoom_Updater {
const API = 'https://pluginloom.com/api/v1';
private $file; private $slug; private $version; private $key; private $basename; private $cache_key;
public function __construct( $file, $slug, $version, $key ) {
$this->file = $file;
$this->slug = $slug;
$this->version = $version;
$this->key = trim( (string) $key );
$this->basename = plugin_basename( $file );
$this->cache_key = 'pluginloom_upd_' . md5( $slug . $this->key );
add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'check_update' ) );
add_filter( 'plugins_api', array( $this, 'plugin_info' ), 20, 3 );
add_action( 'upgrader_process_complete', array( $this, 'purge' ), 10, 0 );
}
/** Activate, check or deactivate. Returns the decoded body or WP_Error. */
public function license( $action, $key = null ) {
$res = wp_remote_post( self::API . '/licenses/' . $action, array(
'timeout' => 15,
'headers' => array( 'Content-Type' => 'application/json' ),
'body' => wp_json_encode( array(
'license_key' => $key ? $key : $this->key,
'site_url' => home_url(),
'instance' => $this->instance(),
'plugin_version' => $this->version,
'wp_version' => get_bloginfo( 'version' ),
'php_version' => PHP_VERSION,
) ),
) );
if ( is_wp_error( $res ) ) { return $res; }
return json_decode( wp_remote_retrieve_body( $res ), true );
}
private function instance() {
$id = get_option( 'pluginloom_instance_' . $this->slug );
if ( ! $id ) { $id = wp_generate_uuid4(); update_option( 'pluginloom_instance_' . $this->slug, $id, false ); }
return $id;
}
private function remote() {
$data = get_transient( $this->cache_key );
if ( false !== $data ) { return $data; }
$url = add_query_arg( array(
'slug' => $this->slug,
'version' => $this->version,
'key' => $this->key,
'site' => home_url(),
), self::API . '/update' );
$res = wp_remote_get( $url, array( 'timeout' => 15 ) );
if ( is_wp_error( $res ) || 200 !== wp_remote_retrieve_response_code( $res ) ) {
set_transient( $this->cache_key, null, HOUR_IN_SECONDS ); // back off on failure
return null;
}
$data = json_decode( wp_remote_retrieve_body( $res ) );
set_transient( $this->cache_key, $data, 12 * HOUR_IN_SECONDS );
return $data;
}
public function check_update( $transient ) {
if ( empty( $transient->checked ) ) { return $transient; }
$r = $this->remote();
if ( ! $r || empty( $r->new_version ) ) { return $transient; }
$item = (object) array(
'id' => $this->basename,
'slug' => $this->slug,
'plugin' => $this->basename,
'new_version' => $r->new_version,
'url' => $r->url,
'package' => $r->package,
'tested' => $r->tested,
'requires' => $r->requires,
'requires_php' => $r->requires_php,
'upgrade_notice'=> isset( $r->upgrade_notice ) ? $r->upgrade_notice : '',
);
if ( version_compare( $this->version, $r->new_version, '<' ) ) {
$transient->response[ $this->basename ] = $item;
} else {
$transient->no_update[ $this->basename ] = $item;
}
return $transient;
}
public function plugin_info( $result, $action, $args ) {
if ( 'plugin_information' !== $action || empty( $args->slug ) || $args->slug !== $this->slug ) { return $result; }
$r = $this->remote();
if ( ! $r ) { return $result; }
return (object) array(
'name' => $r->name,
'slug' => $r->slug,
'version' => $r->new_version,
'tested' => $r->tested,
'requires' => $r->requires,
'requires_php' => $r->requires_php,
'last_updated' => $r->last_updated,
'homepage' => $r->url,
'download_link' => $r->package,
'sections' => (array) $r->sections,
);
}
public function purge() { delete_transient( $this->cache_key ); }
}Downloads#
GET /download?v=...&l=...&e=...&s=... returns the zip. The s parameter is an HMAC of the version, license and expiry, so a link cannot be edited to fetch another version or extended. Every download is logged against the license. Links from the customer account last 10 minutes; links in update responses last 24 hours.
Was this page helpful?