Shipping a Commercial WordPress Theme: Updates, Licensing and Support Load
What it takes to sell a WordPress theme in 2026: building a theme update server, GPL licensing limits, fail-open licence checks and cutting support load.
The theme itself is maybe a third of the work. The rest is the update endpoint, the license API, the demo importer, the refund policy and an inbox that never empties. Developers who decide to sell a WordPress theme usually budget for the first part and get ambushed by the second.
We’ve been shipping commercial themes and templates since 2012, through Canvas on ThemeForest and CanvasWP as a standalone product. The pattern that keeps repeating: a team builds something genuinely good, launches, and then discovers that version 1.1 is harder than version 1.0 because now there are 4,000 live sites that can break.
This is the operational side. Update delivery, what licensing can and cannot enforce under the GPL, and where support tickets actually come from.
- WordPress 6.1 added the
Update URIheader for themes and theupdatethemes{$hostname}filter, which is a cleaner hook than filteringpresetsitetransientupdate_themesand avoids clobbering other themes’ update data. - Under the GPL you cannot legally stop redistribution of your PHP. What you sell is updates, support and the download channel. License keys gate those services, they do not gate the code.
- A license check must fail open. Cache the last good response for at least 7 days and never disable theme functionality when your API is unreachable, or an outage on your side becomes thousands of broken client sites.
- Roughly 60% of our support volume historically came from three things: demo content import failures, third party plugin conflicts, and users editing parent theme files instead of using a child theme.
- Staged rollouts matter more once auto-updates are enabled. Bucket sites by a hash of the site URL and serve the new version to 5% first, because a bad release reaches every auto-updating install within about 12 hours.
What it really costs to sell a WordPress theme
Price your time honestly before you start. A theme that sells 2,000 copies a year at $59 looks like a decent business until you count the hours.
Per release you’re looking at: a compatibility pass across the PHP and WordPress versions you claim to support, regression testing on the demo imports, changelog writing, documentation updates, the release itself, and then the support spike that follows every release for roughly 10 days. Our observed ratio at steady state is about one ticket per 25 to 35 sales. In the two weeks after a major version it jumps closer to one per 10. Minor patch releases barely move it.
That spike is predictable, so plan around it. Never ship a major version the day before a holiday weekend. We did that once. Once was enough.

Building a theme update server that doesn’t page you
WordPress checks for theme updates through wpupdatethemes(), which runs on the wpupdatethemes cron hook twice a day and on various admin page loads. If your theme isn’t in the wordpress.org directory, core won’t know about it unless you tell it.
The old approach was filtering presetsitetransientupdate_themes and injecting your theme into the response object. It works, but every theme and plugin doing it is writing into the same shared transient. We’ve debugged more than one case where two products stomped on each other’s data. Since WordPress 6.1 there’s a better route: add an Update URI header to style.css and core will call a hostname-scoped filter just for you.
<?php
/**
- style.css header:
- Update URI: https://updates.example.com/acme
*
- Core parses the hostname and fires updatethemesupdates.example.com
- only for themes declaring that host. No shared transient to corrupt.
*/
addfilter( 'updatethemesupdates.example.com', 'acmeupdate_check', 10, 4 );
function acmeupdatecheck( $update, $themedata, $themestylesheet, $locales ) {
if ( 'acme' !== $theme_stylesheet ) {
return $update;
}
$cachekey = 'acmeupdate_payload';
$cached = getsitetransient( $cache_key );
if ( is_array( $cached ) ) {
return $cached;
}
$response = wpremoteget(
addqueryarg(
array(
'license' => rawurlencode( (string) getoption( 'acmelicense_key' ) ),
'site' => rawurlencode( home_url() ),
'version' => rawurlencode( $theme_data['Version'] ),
'php' => PHP_VERSION,
),
'https://updates.example.com/acme/check'
),
array( 'timeout' => 8 ) // never block admin page loads for longer
);
if ( iswperror( $response ) || 200 !== wpremoteretrieveresponsecode( $response ) ) {
// Fail quietly. Retry in an hour, not in six hours.
setsitetransient( $cachekey, array(), HOURIN_SECONDS );
return $update;
}
$body = jsondecode( wpremoteretrievebody( $response ), true );
if ( empty( $body['version'] ) ) {
setsitetransient( $cachekey, array(), 6 * HOURIN_SECONDS );
return $update;
}
$payload = array(
'version' => $body['version'], // core copies this into new_version
'url' => $body['changelog_url'],
'package' => $body['package'], // empty string if no valid licence
'requires' => '6.4',
'requires_php' => '8.0',
);
setsitetransient( $cachekey, $payload, 6 * HOURIN_SECONDS );
return $payload;
}
Three details that matter more than the code.
Return an empty package rather than hiding the update. If the licence has lapsed, still tell the site a new version exists and point url at your changelog. The user sees the update notice, clicks through, and renews. Hiding the update entirely means expired users sit on old code forever, and when one of them gets hacked it’s your theme in the headline.
Serve the check from static JSON behind a CDN. 20,000 sites checking twice a day is 40,000 requests, which is nothing, until your licence database is doing a write on every one of them. Split it: an unauthenticated, cached version manifest, and a separate authenticated endpoint that mints a signed, short-lived download URL. S3 presigned URLs with a 15 minute expiry work fine.
Remember that cron is unreliable. On low-traffic sites WordPress’s pseudo-cron may not fire for days, so update notices lag. That’s a user-facing symptom with a server-side cause you can’t control. It’s worth understanding how WordPress cron actually behaves before you tell a customer their install is broken.
Staged rollouts
Once users enable auto-updates, a bad release propagates on its own. Bucket deterministically on the server so a given site always lands in the same group:
<?php
// 0 to 255 bucket, stable for the lifetime of the site URL.
$bucket = hexdec( substr( hash( 'sha256', $site_url ), 0, 2 ) );
$percent = (int) getrolloutpercentage( 'acme', '4.2.0' ); // 5, then 25, then 100
$serve_new = $bucket < ( $percent * 2.55 );
Go 5%, wait 24 hours, watch your error reports, then 25%, then everyone. We’ve caught two genuinely nasty regressions this way that internal testing missed, both involving plugin combinations we didn’t have in the matrix.
WordPress theme licensing: what you can actually enforce
WordPress is GPLv2 or later. A theme that uses WordPress PHP functions is a derivative work in the Foundation’s reading, and in practice every major marketplace has settled on that interpretation. Your PHP must be GPL compatible, which means your customer is legally entitled to redistribute it.
So what are you selling? A service bundle: access to the update channel, the download, documentation, demo content and support. That’s a contract, not a copyright restriction, and it’s perfectly enforceable. Split licensing (GPL PHP, proprietary CSS, JS and images) is legal and some vendors use it, but it creates ugly conversations and marketplaces like Envato require full GPL for the WordPress item anyway. We’d skip it.
Practical consequences for your WordPress theme licensing design:
- Activation limits are a service limit, not a legal one. One site, five sites, unlimited. Enforce by refusing to issue download URLs beyond the limit, not by crippling the theme.
- Normalise site URLs properly. Strip the scheme, strip
www., lowercase it. Then exempt development hosts automatically:localhost,.test,.local,.staging.,.wpengine.comstaging subdomains,.kinsta.cloud. Nothing generates angrier tickets than a developer burning their last activation on a staging copy. If your workflow involves staging and deploy pipelines, assume your customers’ does too. - Deactivation must be self-service. A “manage sites” page in the customer account where they can free a slot. Otherwise every site migration becomes a ticket.
- Sell support separately from the licence if the maths demands it. Envato’s 6 months of included support with an extension option exists for a reason.
Fail open, always
The single rule for anyone building a licence check: if your API is unreachable, the theme behaves exactly as if the licence were valid. Cache the last successful validation with a timestamp and treat it as good for 7 to 14 days. After that, show an admin notice. Never a white screen, never disabled templates, never a nag on the front end.
<?php
function acmelicenseis_active() {
$state = getoption( 'acmelicense_state', array() );
if ( empty( $state['checked_at'] ) ) {
return false; // never validated at all
}
$age = time() - (int) $state['checked_at'];
// Grace window: last known answer stands for 14 days if we can't reach home.
if ( $age < 14 * DAYINSECONDS ) {
return ! empty( $state['valid'] );
}
return false; // notice only, functionality stays intact
}
Your licence server will go down. Ours has. The only question is whether that outage is your problem or 4,000 people’s problem.
Hooks are a public API, treat them like one
The moment you publish, every filter name, every template part path and every CSS class is something a customer has built on. Rename acmeheaderclasses in a minor release and you’ll hear about it.
Adopt semantic versioning and mean it. Breaking template structure, removing a hook or changing a filter signature is a major version. Deprecate rather than delete:
<?php
// Old filter kept alive for one full major cycle.
$classes = applyfilters( 'acmeheader_classes', $classes );
if ( hasfilter( 'acmeheader_class' ) ) {
deprecatedhook( 'acmeheaderclass', '4.0.0', 'acmeheaderclasses' );
$classes = applyfilters( 'acmeheader_class', $classes );
}
Document the override contract explicitly: which files in template-parts/ are safe to copy into a child theme, which are internal. Ship a child theme in the download. Make it the default recommendation in the docs and in the setup wizard, because every customer who edits functions.php in the parent is a future ticket the day you release an update.
Build the distributable zip from a clean checkout with a script, not by hand. No node_modules, no .git, no source maps, no 40MB of demo screenshots. We’ve seen a competitor ship a 180MB theme zip that failed on every shared host’s upload limit.
#!/usr/bin/env bash
set -euo pipefail
VERSION=$(grep -m1 "^Version:" style.css | awk '{print $2}')
rm -rf build && mkdir -p build/acme
rsync -a --exclude-from=.distignore ./ build/acme/
cd build && zip -rq "acme-${VERSION}.zip" acme
shasum -a 256 "acme-${VERSION}.zip" > "acme-${VERSION}.zip.sha256"
Publish that checksum. Your update server is a supply chain target, and a compromised package URL pushes code to every install you have. Enforce 2FA on every account that can publish a release, and keep the signing step off the same machine that serves traffic.
Where the tickets actually come from
Categorise every ticket for three months and the distribution will surprise you. Ours, consistently:
- Demo import failures. The biggest single bucket, by a wide margin. PHP
maxexecutiontimeof 30 seconds, 128MB memory limits, slow remote image fetches. Fix it by chunking the import over AJAX with resumable state, importing images lazily or shipping them in the zip, and offering a WP-CLI path for developers. Also: offer a “content only, no images” option. Half of buyers don’t want your stock photos anyway. - Plugin conflicts. Page builders, caching plugins, WooCommerce extensions. You cannot test everything. You can ship a System Info panel that dumps PHP version, WP version, active plugins, memory limit and server software to a copy-paste block, which cuts the back and forth by one or two rounds per ticket.
- “I edited the parent theme and the update wiped it.” Preventable with docs and a child theme in the box, never fully eliminated.
- Genuine bugs. Usually the smallest category, and the most valuable. Triage these to the front.
- Customisation requests outside scope. Write the scope boundary into your support policy, publicly, before launch. “We fix the theme, we don’t build your site.” Then hold that line politely and consistently.
Two force multipliers. First, documentation with real screenshots and copy-pasteable snippets, kept in version control alongside the theme so it updates in the same commit. Second, a public changelog that says what changed in plain language. “Fixed mega menu z-index conflict with WPBakery 7.x” prevents more tickets than “various improvements” ever will.
Teach your users WP-CLI where you can, too. A customer who can run wp theme update acme or wp db export before an update is a customer who doesn’t need you at 2am. We wrote a gentle introduction for people who avoid the terminal partly because it kept coming up in support.
Pricing, renewals and the money plumbing
If you sell direct, you are responsible for EU VAT, UK VAT, and an expanding set of US state sales tax rules. Doing that yourself with Stripe and a plugin means registrations, filings and an accountant. A merchant of record (Paddle, FastSpring, Lemon Squeezy) takes 5% or so and absorbs all of it. For a small team, that 5% is the cheapest employee you’ll ever hire.
Marketplaces solve tax too, and they bring traffic, which is the real product. The trade-off is the cut and the loss of the customer relationship: on ThemeForest you don’t own the email list. Many vendors run both, a marketplace item for reach and a direct channel for renewals and bundles. That’s the model behind Canvas and CanvasWP and it has held up well.
On renewals: annual with a discount (30% to 50% off renewal is standard) is the healthy default, because lifetime pricing commits you to supporting a customer indefinitely off one payment. If you do offer lifetime, price it at four to five times the annual and cap activations. Be explicit about what happens when a licence lapses: the site keeps working, updates stop, support stops. Put that in the EULA in a sentence a human can parse.
Frequently Asked Questions
Do I need Easy Digital Downloads to run a theme update server?
No. EDD with Software Licensing is the most common turnkey option and it saves weeks, but the update endpoint itself is a JSON response and a signed download URL. If you already have an app, building it yourself is a few days of work. Freemius and Lemon Squeezy also provide licensing APIs with ready-made client SDKs.
Can I block people who share my theme files?
Not legally, for the PHP, because GPL grants redistribution rights. You can and should block their access to your update server, download links and support queue, since those are services you provide under your own terms. Nulled copies are mostly a marketing problem rather than a revenue problem; the people downloading them were never going to buy.
Should my theme support auto-updates?
Yes, but earn it first. Expose the standard WordPress auto-update toggle via your update payload, and only once you have a staged rollout and a tested rollback path. WordPress 6.3 added automatic rollback when an update fails mid-install, which helps with fatal errors but does nothing for a release that installs cleanly and then breaks layouts.
How often should I release?
Patch releases as needed, feature releases every 6 to 10 weeks, major versions at most once a year. Shipping weekly trains users to ignore your update notices and multiplies your regression surface. Shipping twice a year means you’re always behind on WordPress and PHP compatibility.
Block theme, classic theme, or both?
If you’re starting a new commercial theme in 2026, build block-first with a theme.json and full site editing support, and expect to keep a classic fallback for the WooCommerce and page builder audience for a while yet. Supporting both properly roughly doubles the testing matrix, so be honest about whether your buyers actually need it. Most page builder users do not want the site editor.
If you’re deciding whether to start, the useful question isn’t whether your design is good enough. It’s whether you can commit to four years of releases, because that’s the horizon on which theme businesses either compound or quietly die. Build the update server and the licence grace window before launch, not after the first outage. Write the support scope policy before the first ticket. Everything else you can fix in version 1.2.


