Skip to content
Gaurav TiwariDocs

Licenses, Releases and the License API

Version 0.2.0 Updated November 15, 2026

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:

Product types

TypeLicensedRelease artifactHow updates reach the buyer
pluginYesWordPress plugin ZIPSigned package URL from get_version
themeYesWordPress theme ZIPSigned package URL from get_version
emdashYesnpm tarball (.tgz or .tar.gz)The npm registry endpoint, plus get_version
digitalYesOne file of any typeDownload links in the portal and receipt
serviceNoNoneNone
eventNoNoneNone
bundle (0.2.0)No, but each licensed product in it gets its own licenseNone (each item’s own)As for each item’s own type
physical (0.2.0)NoNoneShipped; 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, base32 and base62.
  • 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:// and https:// are different sites, and so is a URL with no scheme. The tests check that a site activated as https://client.example reads as inactive when checked as http://client.example or client.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, uat or development (so staging.example.com, shop.dev.example.com and example.com/staging/ match, but developer.example.com and example.com/stage-door don’t);
  • a host containing localhost, .test or .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.link or .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:

  1. Upload. The admin page posts the file to POST /_vendwick/admin/package-upload?productId=&filename=&version=&channel= as the raw request body with a Content-Length. Only signed-in administrators can upload, and the request needs EmDash’s X-EmDash-Request: 1 header. 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.
  2. 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."
  3. Publish. commerce/releases/publish with the stageId turns 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 a Theme Name: header in its style.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 replaceExisting set. The release keeps its id and original date, and the old file is deleted.

EmDash tarballs

  • The file name must end in .tgz or .tar.gz. Make it with npm pack or pnpm pack.
  • Vendwick reads package/package.json from the tarball. The name must match the product’s package name, and the version must be valid semver. If you entered a version, it must match too.
  • It keeps the packument fields from package.json: dependencies, peerDependencies, optionalDependencies, bin and engines.
  • 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 beta channel 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=1 get 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):

RouteAction
/_emdash/api/plugins/vendwick/license/checkcheck_license
/_emdash/api/plugins/vendwick/license/activateactivate_license
/_emdash/api/plugins/vendwick/license/deactivatedeactivate_license
/_emdash/api/plugins/vendwick/license/versionget_version

The same API answers on a store path, /_vendwick/license, in both shapes (GET query or POST body):

PathAction
/_vendwick/license?edd_action=<action> (or edd_action in the body)any of the four
/_vendwick/license/checkcheck_license
/_vendwick/license/activateactivate_license
/_vendwick/license/deactivatedeactivate_license
/_vendwick/license/versionget_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-urlencoded or multipart/form-data.
  • Aliases: license or license_key; item_id or product_id; item_name or slug; url or site_url.
  • item_id takes priority over item_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): an item_id or item_name from the FluentCart or EDD store finds the imported product when it owns the license, and the answer’s item_id is 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_name is matched the way WordPress sanitize_title would turn it into a slug, so My Plugin finds my-plugin.
  • beta (any non-empty value except 0) asks get_version for pre-releases.
  • PHP quirks are kept for old clients: duplicate keys keep the last value, . and spaces in a key become _, and name[]= 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.description is the product description turned into paragraphs, and only appears when the product has one. sections.changelog is the release notes.
  • requires, tested, requires_php, homepage, banners and icons come 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_version is the highest stable release. With beta=1, new_version can be a pre-release while stable_version stays 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-SHA256 header. Each download is recorded with a hashed IP.
  • Single byte ranges work, so interrupted downloads can resume. Range: bytes=a-b, bytes=a- and bytes=-n answer 206 Partial Content with Content-Range and the part’s Content-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 answers 416 with Content-Range: bytes */<size>. Several ranges in one header, or a header that doesn’t parse, get the whole file with a 200. Every answer carries Accept-Ranges: bytes and an ETag (the file’s SHA-256), and If-Range with that ETag keeps the range; any other If-Range gets 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_license or get_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, clientAddress is 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 so clientAddress is 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%2Fname or @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-version dependencies, peerDependencies, optionalDependencies, bin, engines and dist (tarball, shasum as SHA-1, integrity as SHA-512). dist-tags.latest follows the same rule as update checks; dist-tags.beta appears when the highest release is a newer pre-release.
  • The license key is the token: Authorization: Bearer <key>, or npm’s legacy Basic form with the key as the password.
  • Responses carry Cache-Control: private, no-cache and Vary: 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

KeyDefaultWhat it does
license_server_enabledonOff answers every license request with license_server_disabled.
license_rate_limit60License API requests per IP per minute (5 to 6000). Also the npm endpoint’s limit.
package_url_ttl_minutes15Lifetime of signed package links (1 to 1440).
staging_sites_freeonStaging 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.

Share

Was this helpful?

Still stuck? Open a support ticket