Updates and Downloads
Once a customer's license is valid, Code Heaven also serves your plugin's updates. There are two endpoints: one asks whether a newer version exists, the other hands back a short-lived signed URL to download it. Together they replace the update server you would otherwise have to run.
Step 1 — Check for an update
Call GET /licenses/{licenseKey}/updates with the product, the version currently installed, and the domain. Code Heaven compares the installed version against the latest published release for that product.
curl -G https://api.code-heaven.com/v1/licenses/CH-7K2P-9XQ4-LM83/updates \
-H "X-CH-Vendor-Key: $CH_VENDOR_KEY" \
--data-urlencode "product=acme-forms-pro" \
--data-urlencode "installedVersion=2.4.1" \
--data-urlencode "domain=example.com"
When a newer version exists, hasUpdate is true and you get the changelog for everything between the installed version and the latest:
{
"product": "acme-forms-pro",
"latestVersion": "2.6.0",
"hasUpdate": true,
"changelog": [
{
"version": "2.6.0",
"date": "2026-05-28",
"notes": "New conditional-logic builder. Fixes reCAPTCHA v3 edge case."
},
{
"version": "2.5.0",
"date": "2026-04-30",
"notes": "Multi-step forms. Performance: 40% faster render on large forms."
}
]
}
When the customer is already current, hasUpdate is false and latestVersion equals what they have installed:
{
"product": "acme-forms-pro",
"latestVersion": "2.4.1",
"hasUpdate": true,
"changelog": []
}
Poll this daily, not on every page load. A once-a-day check is the WordPress norm and keeps you well inside the rate limit. Cache
latestVersionand the changelog between checks.
The domain parameter matters: updates are gated by the license, so a 403 domain_not_activated here means the customer needs to activate this site before they can pull updates.
Step 2 — Get the signed download URL
When the customer chooses to update, call GET /licenses/{licenseKey}/download to mint a signed package URL. Pass the exact version you want (usually latestVersion from the updates call) and the domain.
curl -G https://api.code-heaven.com/v1/licenses/CH-7K2P-9XQ4-LM83/download \
-H "X-CH-Vendor-Key: $CH_VENDOR_KEY" \
--data-urlencode "product=acme-forms-pro" \
--data-urlencode "version=2.6.0" \
--data-urlencode "domain=example.com"
{
"url": "https://dl.code-heaven.com/pkg/acme-forms-pro/2.6.0/CH-7K2P...sig=9f2c&exp=1717500000",
"version": "2.6.0",
"expiresAt": "2026-06-04T11:45:00Z"
}
The url is a short-lived signed URL. Three rules follow from that:
- Fetch it now. It expires at
expiresAt(minutes, not hours). Request it immediately before you download; never store it for later. - Do not cache or share it. The signature ties it to this license and this moment. A stale or shared URL fails.
- The vendor key is not used to fetch the package. You GET the
urldirectly — it carries its own signature. Do not attachX-CH-Vendor-Keyto the download request.
A complete update flow in PHP
Here is the whole loop: check daily, and when the customer clicks update, mint the URL, download, and hand the zip to WordPress.
<?php
// 1. Daily check — cache the result.
function ch_check_for_update(string $key, string $domain, string $installed): array
{
$query = http_build_query([
'product' => 'acme-forms-pro',
'installedVersion' => $installed,
'domain' => $domain,
]);
$resp = ch_get("/licenses/{$key}/updates?{$query}"); // sends X-CH-Vendor-Key
set_transient('acme_update_info', $resp, DAY_IN_SECONDS);
return $resp;
}
// 2. When the customer updates — mint the signed URL and pull the package.
function ch_download_package(string $key, string $domain, string $version): string
{
$query = http_build_query([
'product' => 'acme-forms-pro',
'version' => $version,
'domain' => $domain,
]);
$info = ch_get("/licenses/{$key}/download?{$query}"); // { url, version, expiresAt }
// Fetch the signed URL DIRECTLY — no vendor key on this request.
$tmp = download_url($info['url']); // WP helper; returns a temp file path
if (is_wp_error($tmp)) {
throw new RuntimeException('Package download failed: ' . $tmp->get_error_message());
}
return $tmp; // hand this zip to WP_Upgrader / Plugin_Upgrader
}
Wiring into the WordPress update UI
To make updates appear on the customer's Plugins screen, hook the transient WordPress uses to discover updates:
<?php
add_filter('pre_set_site_transient_update_plugins', function ($transient) {
$info = get_transient('acme_update_info') ?: ch_check_for_update(
get_option('acme_license_key', ''),
parse_url(home_url(), PHP_URL_HOST),
ACME_FORMS_VERSION
);
if (!empty($info['hasUpdate'])) {
$plugin = 'acme-forms-pro/acme-forms-pro.php';
$transient->response[$plugin] = (object) [
'slug' => 'acme-forms-pro',
'plugin' => $plugin,
'new_version' => $info['latestVersion'],
// package is fetched on demand via the signed URL when WP installs
'package' => '', // resolved in upgrader_pre_download
];
}
return $transient;
});
Because the package URL is short-lived, resolve it at install time (in an upgrader_pre_download filter), not when you build the transient — mint the URL only when WordPress is actually about to download.
Error handling
| Code | When | What to do |
|---|---|---|
| 403 expired | License lapsed | Block the update, prompt renewal — expired licenses don't get new versions |
| 403 domain_not_activated | Site has no seat | Activate the domain, then retry |
| 404 not_found | Unknown product or version | Verify the product slug and that the version was published |
| 429 rate_limited | Polling too often | Back off; move to a daily check |
The PHP SDK wraps both endpoints — checkUpdate() and download() — including the daily caching and the signed-URL handling shown above.