WordPress Plugin Auto-Update via Polar — and the 3 Bugs I Shipped in 24 Hours

WordPress Plugin Auto-Update via Polar — architecture diagram showing plugin, license server, and signed download URL flow
TL;DR
  • Built Auto Updater for WP Multitool on top of Polar licensing. Plugin polls a /update-check endpoint, server validates the license, returns a HMAC-signed download URL.
  • Within 24 hours of shipping 1.2.0, three separate bugs hit the auto-update chain. All three came from cache TTL mismatches between layers I'd reasoned about in isolation:
    • Bug 1: zips never copied to the on-disk path the download handler reads from.
    • Bug 2: signed URL TTL was 30 minutes, but WP caches the URL in a transient that lives 12 hours.
    • Bug 3: my injector's 6-hour cache kept showing "update available" for hours after a successful upgrade.
  • Fixed each on the day, shipped 1.2.1 and 1.2.2 within the same 24h. Lesson: when you stack caches, write down every TTL and every invalidation trigger on one page. Look for the gaps.

Until last week, customers of my WordPress plugin WP Multitool had to download zips from their Polar customer portal and upload them to wp-admin by hand. Multiple people complained. Reasonably so.

So I shipped an Auto Updater module in 1.2.0. Plugin polls our /update-check endpoint with the customer's license key, our server validates it against Polar's API, returns a HMAC-signed download URL pointing at the right zip. WordPress's native plugin updater handles the rest. Clean architecture. Tested locally. Looked great.

Then within 24 hours three separate bugs hit production, all in the auto-update chain. By the end of the day I'd shipped 1.2.0, 1.2.1 and 1.2.2. This is what each one was, why I missed it, and what they share.

The Architecture (so the bugs make sense)

Three components matter for what comes next:

  1. Plugin side: an Auto Updater module that hooks pre_set_site_transient_update_plugins, calls our update server with the stored license key, caches the response in its own site transient for 6 hours, and injects an update entry into WP's update_plugins transient when one's available.
  2. Server side: a small WordPress site running on the marketing domain (wpmultitool.com) with custom REST routes. POST /update-check takes a license key and current version, validates against Polar (with its own caching), looks up the latest published changelog post for the canonical version, and mints a signed download URL. GET /downloads/wp-multitool-{edition}-{version}.zip validates the HMAC, checks the license is still granted, and serves the zip via X-Accel-Redirect.
  3. Storage: zips live on disk at /var/www/wpmultitool.com/private/releases/wp-multitool-{edition}-{version}.zip. Polar also hosts copies for the customer portal Download benefit, but the auto-updater pulls from disk on the marketing site, not from Polar.

Three layers, each with its own state, each with its own cache. That's where the trouble lives.

What's the plugin side built on?

Honest answer: I wrote the plugin-side code from scratch using WordPress's filter API directly, not on top of an existing library. The hooks involved are all documented core WP: pre_set_site_transient_update_plugins to inject our update entry, plugins_api to feed the "View details" modal, upgrader_pre_download to validate or refresh the package URL right before WP fetches it, upgrader_process_complete to clean up after a successful install. That's the standard pattern any custom updater uses.

If you want a pre-built option instead of rolling your own, the obvious one is YahnisElsts/plugin-update-checker on GitHub. Solid, well-maintained, used by hundreds of commercial plugins. I'd genuinely recommend it if you don't want to think about the protocol. I went bespoke because I wanted edition awareness (free vs paid build of the same plugin), tighter coupling with our own license-server endpoints, and full control over the upgrade-time UX (custom error messages, the self-heal flow you'll see in Bug 2). The price was writing maybe 600 lines of PHP that the library would have given me for free, plus the three bugs this post is about. Trade-off you make consciously.

Bug #1 — The Zips Weren't Where the Server Expected Them

I caught this myself before any customer hit it, while doing end-to-end QA with a synthetic test license. Here's what was happening.

My release flow had nine steps. Bump version, build editions, push tags, upload to Polar's full benefit, upload to Polar's lite benefit, create changelog post on local, mirror to prod, regenerate static HTML, clear caches. All nine ran clean. Plugin tagged, GitHub updated, Polar customer portal showing the new files, changelog published.

I built a test license, called POST /update-check with current_version: 1.2.0. Got back a perfectly-formed signed URL pointing at https://wpmultitool.com/downloads/wp-multitool-full-1.2.1.zip?lic=10&exp=...&sig=.... Curled it. 404.

The download handler reads zips from /var/www/wpmultitool.com/private/releases/ on prod. My release flow uploaded them to Polar so customers could grab them from the portal. It never copied them to that on-disk path. The private/releases/ directory still contained wp-multitool-full-1.1.20.zip from a release weeks earlier and nothing newer.

If I hadn't run the full chain end-to-end with a real license, I'd have shipped broken updates to every paying customer. The plugin would have politely fetched the package URL, hit a 404, and reported "Update failed" to everyone trying to upgrade.

The fix was the easy part. scp the zips to the prod box, move them into private/releases/ with the correct edition-suffixed filename, chown to www-data. The harder part was admitting the documented release flow had a hole, and adding the step so it can't happen again.

If a release artifact has to land in two places (customer-facing and server-internal), the release flow needs explicit steps for both. "Customer can download it" is not the same as "the auto-updater can serve it."

Bug #2 — Signed URL TTL Shorter Than WordPress's Own Cache

This one came from a real customer. Bojan, six sites, three of them stuck on "The signed download URL has expired" when he clicked Update Plugins.

The signed URL handler's expression looked fine on its own:

$exp = time() + WPMTH_LICENSE_DOWNLOAD_TTL; // 1800 seconds = 30 min
$sig = wpmth_license_sign($row['id'], $edition, $version, $exp);

Thirty minutes felt generous. "Customer sees the update notice, clicks Update, done. Plenty of buffer." That's the framing in my head when I picked the value. The framing was wrong.

WordPress doesn't call /update-check when you click Update Plugins. It calls /update-check on its own schedule, roughly every 12 hours, and stores the response in a site transient called update_plugins. When you click Update, WP reads the package URL out of that transient and downloads it. Whatever exp was minted 11 hours ago is the exp WP is using now.

So the failure mode is: WP polls our endpoint at 9 AM, gets a URL with exp = 9:30 AM, caches the whole response. Customer sees the update notice at 2 PM. Clicks Update. URL has been expired for 4.5 hours. Plugin's upgrader_pre_download filter catches it and surfaces a clear error. Upgrade aborts.

The plugin handled the error gracefully. The architecture didn't. I'd reasoned about the URL TTL in isolation, instead of relative to the WP cache that holds the URL.

Fix on the server side: bump TTL to 24 hours. Each download is still license-checked at request time and the HMAC still binds the URL to a specific license, edition, and version. A long TTL doesn't weaken anything except an attacker's tolerance for replay attempts, and they'd need to steal a valid license + URL pair to begin with. So 24h is fine. It's now longer than the WP transient's lifetime, with headroom.

Fix on the plugin side, shipped in 1.2.2: when pre_download sees an expired exp, instead of bailing with an error, refresh the URL inline by re-calling /update-check and hand the fresh URL to download_url(). The original code had a comment saying "we can't refresh from here because WP enters maintenance mode and our update server might 503." That's only true if the customer's site IS the update server (dev self-loop). Guarded that case explicitly, and let the production case go through.

Bug #3 — The Injector Trusted Its Own Cache Blindly

This one was sneakier. Bojan reported it after the TTL fix landed. All six of his sites were now on 1.2.1. ManageWP correctly showed them all as up to date. But each site's own admin still showed "Update to 1.2.1 available" with the same version that was already installed.

Here's the injector code that was wrong:

public function inject_update($transient) {
    $response = $this->ensure_cached();  // 6h cache
    if (!is_array($response)) return $transient;

    $status = $response['license_status'] ?? '';
    $update_available = !empty($response['update_available'])
        && 'granted' === $status;

    if ($update_available) {
        $transient->response[self::PLUGIN_FILE] = $this->build_update_object($response);
    }
    return $transient;
}

See the gap? The cached /update-check response from before the upgrade said "yes, 1.2.1 is available, here's the package." That response is correct at the moment it's cached. After the customer successfully upgrades to 1.2.1, the cache still says exactly the same thing. The injector reads the cached response, sees update_available: true, marks the plugin as upgradeable. WP shows the notice. Customer clicks Update. WP downloads 1.2.1 again, overwrites the same files, success. Cache is still saying "update available." Notice never goes away. For up to six hours.

ManageWP did its own version comparison and ignored our injector's verdict. That's why it knew the truth.

Two fixes, both in the injector:

Defensive version compare. Even if the cached response says an update is available, suppress the notice when our installed version is at or above the advertised new_version. Cheap, foolproof, doesn't depend on cache invalidation working.

$new_version = $response['plugin']['new_version'] ?? '';
$update_available = !empty($response['update_available'])
    && 'granted' === $status
    && '' !== $new_version
    && version_compare($new_version, $this->current_version(), '>');

Proactive cache invalidation. Hook upgrader_process_complete, check if our plugin was the one upgraded, drop the injector cache. Next call to inject_update will refresh against the new installed version immediately.

add_action('upgrader_process_complete', [$this, 'on_upgrade_complete'], 10, 2);

public function on_upgrade_complete($upgrader, $hook_extra) {
    if (($hook_extra['type'] ?? '') !== 'plugin') return;
    if (($hook_extra['action'] ?? '') !== 'update') return;
    $plugins = $hook_extra['plugins'] ?? [];
    if (!in_array(self::PLUGIN_FILE, $plugins, true)) return;
    $this->clear_cache();
}

The version compare is the safety net. The cache clear is the actual fix. Both shipped together in 1.2.2 because I'd rather have one belt and one pair of suspenders.

The Pattern All Three Bugs Share

I reasoned about each cache layer's TTL on its own. None of the values were unreasonable in isolation. 30 minutes is a fine signed-URL lifetime if you forget WP's transient. 6 hours is a fine injector cache if you forget the upgrade lifecycle. "Upload to Polar" is a fine release step if you forget the download handler reads from disk.

What broke them is composition. Server-side URL TTL needs to outlive the WP transient that holds the URL. The plugin's injector cache needs to be invalidated when the underlying state (installed version) changes. The release flow needs to consider every place the artifact has to land, not just the customer-facing one.

None of these failure modes show up on a single dev machine where every cache starts fresh and your installed version moves forward in lockstep with what you publish. They only emerge when customers and your own infrastructure are in different states. Which is, you know, the entire production environment.

For every cache in the system, write down two things on the same page: the TTL, and what triggers invalidation. If the answer to "what triggers invalidation" is "it expires eventually," that cache is a liability waiting to bite you when its TTL doesn't match its neighbour's.

What I'd Do Differently Next Time

Map the cache layers before writing code. Three layers in this case, server URL TTL, WP transient, plugin injector cache. I should have drawn them on paper with their TTLs and asked "what happens if X expires before Y?" for every pair. I'd have caught bug 2 in the design.

End-to-end QA with a real license against a real server. This is how I caught bug 1 before customers did. The local dev environment had everything mocked and happy-pathed. The bug only existed on the gap between "release flow finished" and "download handler runs against disk." Synthetic license + real curl against the production endpoint is cheap and catches the entire deployment-path category.

I wrote it up as a separate post-release verification step. Mint a license row in the prod licenses table, hit /update-check with the previous version, follow the signed URL, check the zip downloads and the manifest reports the new version, delete the test license. Five minutes. Will run it on every release from now on.

Ship the fix the same day. Three patch releases in 24 hours looks busy on the changelog. It also looks like someone who's paying attention. The customers who care will notice the responsiveness. The customers who don't won't notice at all. Worse than three same-day releases is one quiet release that breaks things and gets fixed in three days while customers stew.

Why Polar (since people will ask)

Quick honest comparison since it's relevant to the architecture choice.

For a small WordPress plugin shop, Polar plus a few hundred lines of license-server PHP is genuinely the cheapest thing that works. The zip distribution is yours, the update protocol is yours, the customer keeps a clean Polar receipt. No middleman inside the update flow.

Open-sourcing the approach?

The plugin-side code is mostly provider-agnostic already. There's a LicenseProvider contract and the Polar implementation just fills it in. Stripping the WP Multitool branding and publishing it as a standalone "WP plugin auto-updater for Polar" is a half-day of work.

The server-side license-server is more entangled. It lives inside our marketing theme and uses our DB schema. Extracting it into a standalone WP plugin (something like "Polar License Server for WordPress") is real work, maybe 2-3 days. Worth doing if there's interest. Ping me on X or grab my email from the homepage if you'd actually use it. If a few people raise their hand I'll publish both halves under MIT and write the docs.