Licenses, Releases and the License API
How Vendwick’s license server works: product types, keys, activations and seats, upgrades, releases, the EDD-compatible license API and the npm registry for EmDash products. It issues keys at fulfillment, counts site activations against a seat limit, stores releases in private storage, and answers update checks with an API that EDD Software Licensing clients already speak.
This guide covers the server side. For the code you ship inside your own products, see:
- Add Licensing and Updates to a WordPress Plugin or Theme: a single-file PHP updater for WordPress plugins and themes.
- Add Licensing and Update Notices to an EmDash Plugin: a zero-dependency TypeScript license client for EmDash plugins.
Product types
| Type | Licensed | Release artifact | How updates reach the buyer |
|---|---|---|---|
plugin | Yes | WordPress plugin ZIP | Signed package URL from get_version |
theme | Yes | WordPress theme ZIP | Signed package URL from get_version |
emdash | Yes | npm tarball (.tgz or .tar.gz) | The npm registry endpoint, plus get_version |
digital | Yes | One file of any type | Download links in the portal and receipt |
service | No | None | None |
event | No | None | None |
bundle (0.2.0) | No, but each licensed product in it gets its own license | None (each item’s own) | As for each item’s own type |
physical (0.2.0) | No | None | Shipped; see Physical Products: Shipping, Stock and Shipments |
An emdash product needs its npm package name set on the product before you can publish a release. A license that came with a bundle carries the product and plan of its own item, so the license API, downloads and the npm registry treat it exactly like a license bought on its own (see Pay What You Want, Free Orders, Bundles and Order Bumps).
License keys
New keys follow the key policy you set under Licenses in the admin (commerce/key-policy/get and commerce/key-policy/save). Existing keys are never reformatted when the policy changes.
- Styles:
hex(the default),hex_upper,base32andbase62. - Default: 32 lowercase hex characters, no prefix, no grouping.
- Length is the random body only. The minimum per style is whatever gives at least 128 bits of randomness; the maximum is 128.
- Grouping: none, or groups of 4 or 8 characters joined by hyphens. Grouping doesn’t shorten the random body.
- Prefix: up to 32 letters, numbers, hyphens or underscores.
- The full key, prefix and hyphens included, must fit in 191 characters.
Key lookups are exact and case-sensitive. A key with one letter in the wrong case is an invalid key.
A license is active, disabled or expired. A license is live when it’s active and its expiry, if any, hasn’t passed. A license with no expiry is a lifetime license. A daily job marks lapsed licenses expired and writes a license_expired event.
Activations and seats
Each license has an activation limit. 0 means unlimited.
- A site is identified by a hash of its URL:
sha256(lowercase(untrailingslash(esc_url_raw(url)))), the same hash WordPress Core Commerce uses. Case and a trailing slash don’t matter. http://andhttps://are different sites, and so is a URL with no scheme. The tests check that a site activated ashttps://client.examplereads as inactive when checked ashttp://client.exampleorclient.example.- Activating the same site twice uses one seat and one row, even when the requests arrive in parallel.
- Parallel activations of different sites never go over the limit. The seat counter on the license moves with guarded single-row updates, and a test fires 12 parallel activations at a 3-seat license and gets exactly 3.
- Deactivating frees the seat. Activating that site again reuses its old row.
- A daily job recounts seat counters and repairs any that drifted.
From the admin you can list a license’s activations, deactivate one, or reset all of them.
The staging rule
With the staging_sites_free setting on (the default), staging and local sites activate without using a seat. They still count in site_count, but not in activations_left.
A site counts as staging or local when its URL matches one of these:
- a subdomain or path segment named
staging,stage,dev,test,qa,sandbox,beta,preview,uatordevelopment(sostaging.example.com,shop.dev.example.comandexample.com/staging/match, butdeveloper.example.comandexample.com/stage-doordon’t); - a host containing
localhost,.testor.local; - a host on a known staging domain:
.wpengine.com,.wpenginepowered.com,.kinsta.cloud,.sg-host.com,.cloudwaysapps.com,.flywheelsites.com,.pantheonsite.io,.dreamhostps.com,.pressable.site,.staging.wpmudev.host,.liquidweb.cloud,.runcloud.linkor.wp1.sh.
Each activation records whether it was free when it was made, so turning the setting off later doesn’t unbalance the counter. With the setting off, a staging site takes a seat like any other.
Upgrades and proration
With the License Upgrades module on, a buyer moves a license to a bigger plan of the same product (a higher price or more sites) and keeps the key. The price depends on how the plans’ billing periods compare:
- Same period: the buyer pays the difference for the time left, and the expiry stays.
- Different period: the new price minus credit for the unused time, and a new term starts on payment.
- To lifetime: the lifetime price minus credit for the unused time (lifetime to lifetime: minus the whole credit basis).
The credit is based on what the buyer actually paid for the license’s current term, never on more than the old plan’s list price:
basis = min(old plan's list price, paid for the current term)
credit = basis x fraction of the term left (lifetime: basis)
same period: price = (new price - basis) x fraction of the term left
- Paid for the current term is the most recent paid order item for the license: the purchase (or an earlier upgrade), or a later renewal. It is that item’s share of its order total, net of refunds, divided by the item’s quantity when one item bought several licenses.
- Tax is handled the way the upgrade price is: in plan-price terms. A tax-exclusive order counts its total minus tax; a tax-inclusive order counts its total. Tax is then worked out on the upgrade price as on any checkout.
- For an upgrade order item, paid is the credit that upgrade carried plus the cash paid for it.
- A coupon purchase earns credit on the discounted price, a renewal bought at a different price counts at that price, and a 100%-off purchase earns no credit. A license with no paid order on record (an imported one) is credited at the list price.
The WordPress plugin credited the old plan’s list price, so a buyer who paid a discounted price got full credit. Vendwick doesn’t.
Releases
Upload releases under Releases in the admin. An upload goes through three steps:
- Upload. The admin page posts the file to
POST /_vendwick/admin/package-upload?productId=&filename=&version=&channel=as the raw request body with aContent-Length. Only signed-in administrators can upload, and the request needs EmDash’sX-EmDash-Request: 1header. The limit is 256 MB. The file streams into private storage, which records its size and SHA-1, SHA-256 and SHA-512 on the way in. - Stage. Vendwick validates the file and keeps a stage for 48 hours. The response tells you what publishing would do, for example "Becomes the latest version for downloads and automatic updates."
- Publish.
commerce/releases/publishwith thestageIdturns the stage into a release and updates the product’s version to the highest stable release.
Uploads that are never published have their files deleted by the daily job after a day. A rejected upload’s file is deleted right away.
WordPress ZIPs
- The file name must end in
.zip. - Vendwick scans the ZIP’s central directory with ranged reads instead of reading the whole archive.
- It rejects files that aren’t readable ZIPs, empty ZIPs, ZIPs with more than 5,000 entries, and any entry with
../, a leading/or a backslash in its path. - A plugin ZIP must contain a
Plugin Name:header; a theme ZIP must contain aTheme Name:header in itsstyle.css. - If you leave the version blank, Vendwick takes it from the header’s
Version:line. If you enter one and the header says something else, the upload fails with a message naming both versions. - You can replace a published WordPress version by publishing the same version again with
replaceExistingset. The release keeps its id and original date, and the old file is deleted.
EmDash tarballs
- The file name must end in
.tgzor.tar.gz. Make it withnpm packorpnpm pack. - Vendwick reads
package/package.jsonfrom the tarball. Thenamemust match the product’s package name, and theversionmust be valid semver. If you entered a version, it must match too. - It keeps the packument fields from
package.json:dependencies,peerDependencies,optionalDependencies,binandengines. - Versions are immutable, as on npm. Publishing a version that already exists fails with
RELEASE_EXISTS, and a deleted version can never be published again.
Digital files
A digital product’s release is one file. There’s no content check, so you must enter the version.
Channels and which release is latest
A release is on the stable or beta channel. When you don’t pick one, a version made only of numbers and dots (1.2, 2.0.1) is stable and anything else (1.2.0-beta.1) is beta.
- Latest is the highest stable version, not the newest upload. An older-branch hotfix or a beta uploaded after a stable release never replaces the stable update.
- A release on the
betachannel is a pre-release even when its version is plain numbers. - When a product has only pre-releases, the highest pre-release is latest, so a product still in beta gets updates.
- Update checks that send
beta=1get the highest release of any channel.
The license API
The API is compatible with EDD Software Licensing and with WordPress Core Commerce. Existing clients work without changes.
Endpoints
Plugin routes, on any HTTP method that carries the parameters (GET query or POST body):
| Route | Action |
|---|---|
/_emdash/api/plugins/vendwick/license/check | check_license |
/_emdash/api/plugins/vendwick/license/activate | activate_license |
/_emdash/api/plugins/vendwick/license/deactivate | deactivate_license |
/_emdash/api/plugins/vendwick/license/version | get_version |
The same API answers on a store path, /_vendwick/license, in both shapes (GET query or POST body):
| Path | Action |
|---|---|
/_vendwick/license?edd_action=<action> (or edd_action in the body) | any of the four |
/_vendwick/license/check | check_license |
/_vendwick/license/activate | activate_license |
/_vendwick/license/deactivate | deactivate_license |
/_vendwick/license/version | get_version |
It goes through the same internal license route, so the answers, the license server switch and the rate limit are the same as on the plugin routes. Bodies are limited to 16 KB (413 above that), an unknown action answers 400 with {"success":false,"error":"invalid_action"}, and methods other than GET and POST answer 405. The bundled WordPress updater and EmDash license client use it as their fallback when the plugin route answers 404 or something that isn’t JSON.
The ?edd_action= form also works on any front-end URL of the site; see EDD clients on any URL.
Request parameters
- The query string and the body are merged, and body values win. The body can be JSON,
application/x-www-form-urlencodedormultipart/form-data. - Aliases:
licenseorlicense_key;item_idorproduct_id;item_nameorslug;urlorsite_url. item_idtakes priority overitem_name. A product id that doesn’t match the key’s product is an invalid license.- Products brought in by the store importer (0.2.0) also answer to their old store’s ids and slugs (
legacyItemIds,legacySlugs): anitem_idoritem_namefrom the FluentCart or EDD store finds the imported product when it owns the license, and the answer’sitem_idis the id the client sent. When a Vendwick id and an old id are the same number, the product that owns the license wins. See Import a FluentCart or EDD Store. item_nameis matched the way WordPresssanitize_titlewould turn it into a slug, soMy Pluginfindsmy-plugin.beta(any non-empty value except0) asksget_versionfor pre-releases.- PHP quirks are kept for old clients: duplicate keys keep the last value,
.and spaces in a key become_, andname[]=keys become arrays (which read as empty).
Responses
Every answer is HTTP 200 with a JSON body, encoded the way PHP’s json_encode would (slashes escaped, key order fixed). Clients read success, license and error, not the status code.
The examples below are the exact bodies the tests in tests/licensing/api.test.ts assert, for a 3-seat license on product 12 (my-plugin) that expires on 2027-10-05.
Check, no URL sent:
{"success":true,"license":"valid","item_id":12,"expires":"2027-10-05 04:03:41","site_count":0,"license_limit":3,"activations_left":3}
With a url for a site that isn’t activated, license is site_inactive.
Activate:
{"success":true,"license":"valid","item_id":12,"expires":"2027-10-05 04:03:41","site_count":1,"license_limit":3,"activations_left":2}
Activate with no URL:
{"success":false,"error":"missing_url"}
Activate past the limit:
{"success":false,"license":"site_inactive","error":"activation_limit_reached","activations_left":0}
Deactivate (always succeeds, even for a site that was never active):
{"success":true,"license":"deactivated"}
Lifetime license with unlimited seats:
{"success":true,"license":"valid","item_id":12,"expires":"lifetime","site_count":12,"license_limit":0,"activations_left":"unlimited"}
Unknown key, wrong product, or a key in the wrong case:
{"success":false,"license":"invalid","error":"invalid_license"}
Expired license (a disabled license gets the same shape, with expires set to the date or null):
{"success":false,"license":"expired","expires":"2026-01-01 00:00:00"}
License server turned off (license_server_enabled off):
{"success":false,"error":"license_server_disabled","message":"License issuing is disabled."}
Rate limited (still HTTP 200, with a Retry-After header):
{"success":false,"error":"rate_limited","message":"Too many license requests. Try again shortly.","retry_after":42}
retry_after is the number of seconds to wait; 42 is only an example.
expires uses WordPress’s Y-m-d H:i:s format in UTC, or lifetime. item_id is always an integer.
get_version
get_version answers with the latest release. Only a site that has activated the key gets a download link; any other caller gets the version and an empty package.
For a site that isn’t activated:
{"success":true,"new_version":"1.1.0","stable_version":"1.1.0","name":"My Plugin","slug":"my-plugin","package":"","download_link":"","sections":{"changelog":"Stable notes"},"sha256":"<sha256 of the release>","last_updated":"2026-10-01 10:00:00","license":"site_inactive","msg":"Activate your license on this site to download updates."}
For an activated site, package and download_link hold the same signed URL and license is valid. The keys come in this order:
success, new_version, stable_version, name, slug, package, download_link, sections, sha256,
requires, tested, requires_php, homepage, banners, icons, last_updated, license
sections.descriptionis the product description turned into paragraphs, and only appears when the product has one.sections.changelogis the release notes.requires,tested,requires_php,homepage,bannersandiconscome from the product’s update fields and only appear when set. With one banner or one icon size set, Vendwick fills the other from it.stable_versionis the highest stable release. Withbeta=1,new_versioncan be a pre-release whilestable_versionstays on stable.- A product with no releases reports its own version and an empty
sha256.
EmDash products add four fields:
{
"package_name": "@acme/widget",
"registry": "https://store.test/_vendwick/npm/",
"tarball": "https://store.test/_vendwick/npm/@acme/widget/-/widget-2.0.0.tgz",
"integrity": "sha512-<base64 of the release's SHA-512>"
}
Signed package URLs
The package link points at /_vendwick/package?license_id=&package=&expires=&signature=. The signature is an HMAC-SHA256 of license_id|package|expires with the store’s signing secret.
- Links last 15 minutes by default. Change it with
package_url_ttl_minutes(1 to 1440). - The link checks the expiry first, then the signature, then that the license is still live and the package belongs to its product. Any failure is a 403. A missing file is a 404.
- The ZIP streams from private storage as an attachment with no-cache headers and an
X-Content-SHA256header. Each download is recorded with a hashed IP. - Single byte ranges work, so interrupted downloads can resume.
Range: bytes=a-b,bytes=a-andbytes=-nanswer206 Partial ContentwithContent-Rangeand the part’sContent-Length, read from storage with a ranged read (R2, the Node directory and memory storage all support it). A range past the end of the file answers416withContent-Range: bytes */<size>. Several ranges in one header, or a header that doesn’t parse, get the whole file with a200. Every answer carriesAccept-Ranges: bytesand anETag(the file’s SHA-256), andIf-Rangewith that ETag keeps the range; any otherIf-Rangegets the whole file. Each ranged request is recorded as a download, like a full one. The same applies to digital downloads and npm tarballs. - Links that sign the license key instead of the id (
license=<key>) are still accepted, but never issued. - Changing the signing secret invalidates every link already handed out.
EDD clients on any URL
Many EDD Software Licensing clients send edd_action to the store’s home URL, often as a form POST. The companion middleware answers those on any front-end path:
- a query string with
edd_action=check_license,activate_license,deactivate_licenseorget_version; - a form, multipart or JSON POST of 16 KB or less whose body carries one of those actions.
If the companion can’t reach the plugin, this path answers {"success":false,"error":"license_server_unavailable"} with a 503 (or 404). That’s the one case where a license answer isn’t HTTP 200.
A POST without a known edd_action passes through to the page untouched, so contact forms and other posts keep working. Turn this off with eddCompat: false in vendwickSite().
Static home pages. On Cloudflare, when the site root is a static asset (a prerendered home page), the asset store answers / before the Worker runs, so /?edd_action=… never reaches Vendwick and the client gets HTML. Point clients at /_vendwick/license instead (or at the plugin routes). For existing EDD clients that only know the store’s home URL, give them https://your-site.example/_vendwick/license as the store URL: they send edd_action in the query or the form body, and this path reads both.
Rate limits
The license API allows license_rate_limit requests per IP per minute (default 60, range 5 to 6000), on every path: the plugin routes, /_vendwick/license and ?edd_action= on any page. The npm endpoint uses the same per-minute limit in its own bucket and answers a 429 when it’s exceeded.
Requests with no client IP aren’t let through unlimited. All of them share one bucket per route with ten times the per-IP limit, and at least 100 a minute, so they’re bounded without throttling normal traffic. The same rule covers the checkout, portal and access-link limits. On Workers every request has an IP (Cloudflare’s cf-connecting-ip), so this only comes up on Node.
Node deployments and proxies. On Node, the client IP is the adapter’s clientAddress (the socket’s address). When it’s missing, Vendwick reads cf-connecting-ip, x-real-ip and the first x-forwarded-for entry, in that order, and trusts them as sent. Two consequences:
- Behind a reverse proxy,
clientAddressis the proxy’s own address, so every visitor shares one per-IP bucket and busy sites get throttled. Configure the proxy and the Node adapter soclientAddressis the visitor’s address. - Without a proxy in front, don’t rely on those headers: anyone can send them. Run Node only behind a proxy that overwrites them, or make sure the adapter always provides
clientAddress.
The npm registry for EmDash products
EmDash plugins can’t replace their own code on a deployed site, so updates go through the package manager. Vendwick serves an npm-compatible registry at /_vendwick/npm/, and buyers install and update with plain pnpm add and pnpm update.
- Packuments live at
/_vendwick/npm/<name>. Scoped names work as@scope%2fname,@scope%2Fnameor@scope/name. - Tarballs live at
/_vendwick/npm/<name>/-/<base>-<version>.tgz. Tarball URLs stay under the registry prefix, so the client sends the token with them and the key never lands in a lockfile. - The packument has
dist-tags,time, and per-versiondependencies,peerDependencies,optionalDependencies,bin,enginesanddist(tarball,shasumas SHA-1,integrityas SHA-512).dist-tags.latestfollows the same rule as update checks;dist-tags.betaappears when the highest release is a newer pre-release. - The license key is the token:
Authorization: Bearer <key>, or npm’s legacyBasicform with the key as the password. - Responses carry
Cache-Control: private, no-cacheandVary: Authorization. - No token is a 401 with
WWW-Authenticate. A key that isn’t live, a key for a different product and a package name the store doesn’t sell all get the same 403 with the same body, so a key can’t be used to find out which package names exist. A version of your own package that isn’t published, and registry endpoints such as search and audit, are 404s. - Registry requests don’t use a seat. Tarball downloads are recorded.
What buyers add
In the project’s .npmrc, one line routes your scope to the store and one sends the license key:
@acme:registry=https://store.example/_vendwick/npm/
//store.example/_vendwick/npm/:_authToken=YOUR-LICENSE-KEY
Then pnpm add @acme/widget and, later, pnpm update @acme/widget.
pnpm 11 reads release-age settings from pnpm-workspace.yaml, not from .npmrc. A buyer whose pnpm holds back new releases with minimumReleaseAge won’t see your update until the hold passes, unless they exclude your scope:
# pnpm-workspace.yaml
minimumReleaseAgeExclude:
- "@acme/*"
clients/emdash/README.md has the full buyer instructions to copy into your own docs.
The registry has been exercised with a real pnpm client through Astro’s routing on a local workerd with R2. An opt-in test (VW_REAL_PNPM=1 pnpm vitest run tests/licensing/pnpm-real.test.ts) also runs real pnpm add and pnpm update against the companion’s handler on a local Node server. Neither is a deployed Worker.
Digital downloads
Licensed digital products, and the portal’s download buttons, use links of the form /_vendwick/download/<token>. A token is signed, lasts 24 hours and allows 5 downloads, after which the buyer requests a new one from their account. It points at a specific release or, by default, the latest release by the rule above. The license must still be live. Byte ranges work as for package URLs; a ranged request (a resumed download) uses one of the token’s 5 downloads, so a range can’t be used to fetch the file more often.
Settings
| Key | Default | What it does |
|---|---|---|
license_server_enabled | on | Off answers every license request with license_server_disabled. |
license_rate_limit | 60 | License API requests per IP per minute (5 to 6000). Also the npm endpoint’s limit. |
package_url_ttl_minutes | 15 | Lifetime of signed package links (1 to 1440). |
staging_sites_free | on | Staging and local sites don’t use a seat. |
The key policy isn’t a settings field; it’s saved from the Licenses admin page.
Events
The license server writes audit events you can see in the admin: license_activated, license_deactivated, license_expired, release_published, release_deleted and package_downloaded.