<developers/>

Licensing Manager: check address and API

The Licensing Manager sells software by subscription. A product that uses it gives every service a licence key instead of an account on a server. The licensed program sends its key to a public check address on your site, and the answer tells it whether it may run. Every answer i…

From the CoreInto manual, updated by itself · Last changed 12 Nov 2026

The signing key pair#

Your installation has one key pair (ECDSA P-256) for signing licence answers. It is made the first time it is needed: the first time a program checks a key, or the first time somebody opens Add-ons > Licensing Settings, whichever comes first. The pair is kept in two settings that have no form of their own:

SettingWhat it holds
LicensingPrivateKeyThe private key, sealed with the encryption key from configuration.php. It never leaves the server; it is not shown on any page.
LicensingPublicKeyThe public key (PEM). It is shown on Add-ons > Licensing Settings so you can put it into the licensed program.

Add-ons > Licensing Settings shows:

  • Check address: the public address programs send their key to (see "The check address" below).
  • Sent by the software and Answers: a short reminder of the request fields and the possible results.
  • Products that sell licences: every product with the licensing module, with its key prefix, IP limit, whether the domain and folder are locked, whether clients may reissue, and the days between checks. The product name links to the product's Setup tab.
  • Public key: the PEM text to paste into the licensed program. "It can only check answers, not make them."
  • Check code for the software (PHP): a ready-made PHP function (license_check() and license_read()) with your check address and public key already filled in. Copy it into the program as a starting point.
  • Make a new key pair: a folded section with a warning, a confirmation box ("I understand; make a new key pair") and the button. Use it only if the private key may have leaked. Every program that holds the old public key stops trusting your answers until it is given the new one. The action is written to the activity log as "Made a new licence signing key pair". Without the tick the page refuses with a message.

Keep a copy of configuration.php with your database backups: the private key in the database can only be opened with the encryption key in that file (see Backups and updates).

Binding#

A licence is tied to where the program runs. The lists are empty when the key is issued and are filled by the first checks:

  • IP addresses: the address the check came from (the caller's address as the web server sees it). Each new address is added until the list holds as many as the licence allows; a check from a further address then answers wrong_ip. The limit is the product's "Server addresses (IPs)" at the time of issue and can be changed per licence by staff.
  • Domains: used only when the product locks the domain. The domain sent with the first check is kept; later checks must send the same one or get wrong_domain. Domains are compared in lower case, without a scheme (https://), port, path or a leading www., so https://www.Example.com:8443/panel and example.com are the same domain.
  • Folders: used only when the product locks the folder. The dir sent with the first check is kept; later checks must send the same folder or get wrong_directory. Backslashes are turned into forward slashes and a trailing slash is dropped.

If the domain or folder lock is turned on after a licence already has empty lists, the next check fills them. If a check sends no domain or folder, nothing is bound for that list and the check passes.

Staff can edit all three lists on the licence page. Clients cannot edit them; they can only clear them by reissuing.

The check address#

The address is shown on Add-ons > Licensing Settings. With short addresses it is https://your-site.example.com/license/check; with the basic address style it is https://your-site.example.com/index.php?r=/license/check. It needs no sign-in.

What the program sends#

A POST with form fields, a POST with a JSON object as the body, or a GET with the fields in the query string. The fields:

FieldRequiredMeaning
keyyesThe licence key. Spaces around it are dropped and it is made upper case.
domainnoThe domain the program runs on (for example the web server's host name). Needed when the product locks the domain.
dirnoThe folder the program is installed in. Needed when the product locks the folder.
versionnoThe program's version, up to 32 characters. Shown on the licence page and in the check log.
noncenoAny text up to 64 characters. It is copied into the answer, so the program can be sure the answer is for this request and not an old one replayed.

The server address is not sent: it is the address the request comes from.

What the program receives#

The reply is JSON with two members:

{"data": "<the answer as JSON text>", "signature": "<base64>"}

data is the answer as a JSON string, and signature is an ECDSA P-256 signature with SHA-256 over the exact bytes of that string, encoded in base64 (DER format, as PHP's openssl_sign makes it). The program must check the signature over data with the public key from Licensing Settings before reading it (in PHP: openssl_verify($data, base64_decode($signature), $publicKey, OPENSSL_ALGO_SHA256) === 1). The sample code on the settings page does this.

The answer inside data always has:

MemberMeaning
statusOne of the results below.
messageA sentence for the program to show.
keyThe key as checked.
checked_atThe time of the check, UTC, for example 2026-10-09T14:03:21Z.
nonceThe nonce the program sent (empty if none).

When status is active the answer also has:

MemberMeaning
valid_untilThe time until which the program may trust this answer offline: now plus the product's "Days the software may run between checks".
productThe product name.
registered_toThe client's company, or the client's name when there is no company.
next_due_dateThe service's next due date (YYYY-MM-DD), empty when none.
ips, domains, directoriesThe bound lists after this check, each a JSON array of strings.

The results and the message that goes with each:

statusmessage
activeThe licence is valid.
invalidThere is no such licence key.
suspendedThe licence is suspended. Please contact the seller.
expiredThe licence has expired.
wrong_ipThe licence is in use on another server (IP address). Reissue it in the client area to move it.
wrong_domainThe licence is in use on another domain. Reissue it in the client area to move it.
wrong_directoryThe licence is in use in another folder. Reissue it in the client area to move it.

The checks are made in that order: an unknown key is invalid; then suspended; then expired; then the address; then the domain; then the folder. Only an active check updates the bound lists. Every check, whatever its result, updates the licence's "last check" time, address and version, and is written to the check log.

Two other replies are not signed:

  • HTTP 429 with {"error": "Too many checks with unknown keys from this address. Try again in 15 minutes."}: an address that sent 30 unknown keys within 15 minutes is refused for 15 minutes. Checks of known keys do not count.
  • HTTP 503 with {"error": "The licence server cannot sign answers right now."}: the key pair could not be used, usually because the encryption key is not loaded. The error is written to the error log.

What the program should do#

The sample code on Licensing Settings shows the intended use: send a fresh nonce with each check; accept the answer only if the signature is right and the nonce matches; save the whole signed reply; while your site cannot be reached, read the saved reply again (checking its signature) and keep running until its valid_until. With the default of 7 days, a program whose licence server is down keeps working for a week.

The API#

Three actions of the JSON API (/api/v1/ACTION, Bearer credentials; the reference is on Settings > API Credentials and in API):

ActionScopeParametersReturns
licenses.listservices:readclient_id, service_id, status (active, reissued, suspended, expired), limit, offsetlicenses: id, service_id, client_id, license_key, status, allowed_ips, allowed_hosts, allowed_dirs (as arrays), max_ips, reissues, last_check_at, last_ip, last_version, created_at; newest first
licenses.getservices:readid, key or service_idlicense: the licence with its service, product and client fields, plus checks: the last 20 checks (created_at, result, ip, host, dir, version)
licenses.reissueservices:writeid, key or service_id; count_for_client (true to count it towards the client's reissue limit)license: the licence after the reissue

licenses.reissue is written to the activity log as "Reissued licence KEY through the API". Without id, key or service_id the answer is 400 "Give id, key or service_id."; an unknown licence is 404.

Version 2: signed tokens, product codes and releases#

Version 2 (CoreInto 0.30) is what intoPanel, StackRegent and later CoreInto itself use. The full contract between this site and the programs is docs/LICENSING-SPEC.md in the repository; this section is what you see and set here. The first check address (/license/check) keeps working unchanged for software that already uses it.

The product's extra settings (Setup tab)#

FieldWhat it does
Product codecoreinto, intopanel or stackregent (any lower-case code works). The program sends it with every check; a key sold for another product answers wrong_product. Releases are matched to licences by it.
Lock the licence to the machine of the first checkThe program sends a fingerprint of its server (a SHA-256 of /etc/machine-id on Linux, of the MachineGuid on Windows; the raw value never leaves the server). Afterwards another machine answers wrong_machine. Turn it on for the panels.
EditionA name the program shows, such as "Unlimited".
Accounts allowed0 for no limit. Sent to the program, which refuses new accounts above it. The program reports how many it has; the licence page shows it, marked "over the limit" when it is.
FeaturesComma-separated names the program turns on.
Trial licence (one per server)Each address and each machine may use one trial of the product code, ever (remembered in license_trials, even after the trial ends or is reissued). A second one answers trial_used. Clients cannot reissue a trial. Sell a trial as a free product with a fixed term (for example 10 days).
Grace days after a missed checkHow long the program keeps working after valid_until when this site cannot be reached. Usually 7.
Install command shown to the clientShown on the client's service page with a copy button; {key} becomes the licence key.

The addresses#

AddressUsed for
POST /license/v2/checkActivation and the daily check. JSON in; {"token": "..."} out: a standard JWT signed with ES256 (the same key pair as version 1), whose header names the key by kid.
POST /license/v2/releasesThe program asks for releases newer than its version. Only a licence already bound to that server gets them (otherwise not_activated). Each release comes with a download link that works for 15 minutes, for that licence only.
GET /license/v2/download/...The link itself. Wrong, changed or old links answer 404 and count towards the address's limit of unknown tries.

Version 2 answers only for products with a product code: a licence of a product without one answers wrong_product. A check without a nonce of 16 to 64 characters gets HTTP 400 and no token. Check-ins of one licence are decided one at a time (the licence row is locked), and trials of one product code too, so two servers checking in at the same moment cannot both bind.

Both addresses keep working in maintenance mode, as payment notifications do. Their checks are in Licence Checks with the API shown (v2, v2-rel); downloads appear there with the result download.

Release keys and Add-ons > Licensing Releases#

Releases are signed by you, on your own computer, never on this site:

  1. Once per product: php tools/release.php keygen-product --private=intopanel-release.pem --public=intopanel-release.pub. Keep the private file offline. Paste the public one into Licensing Settings > Release keys with the product code, and build it into the program.
  2. For each release: php tools/release.php sign-product --product=intopanel --version=0.2.0 --file=intopanel-0.2.0.tar.gz --key=intopanel-release.pem prints the signature.
  3. Add-ons > Licensing Releases > Add Release: product code, version, channel (Stable or Beta), the file (upload; or, when it is larger than PHP's upload limit, put it in storage/releases/incoming over FTP and choose it), the signature and notes. The file is refused unless the signature matches it with the product's release key. It is moved into storage/releases/PRODUCT/ (never served directly).
  4. Publish it. Active licences of that product code are offered it; Beta also offers Stable releases. Withdraw takes it back; Delete removes the row and the file.

The client's service page shows "Download version X" for the newest published stable release while the service and licence are active, and the install command when the product has one.

Licensing Settings and the licence page#

Licensing Settings also shows the version 2 addresses, the current key id (kid) and the retired ones (making a new key pair keeps the old public key as retired), and the release keys by product code.

One licence shows the product code, edition and trial mark, the server name the program reported, accounts in use, the machines it is bound to (editable, 64 hexadecimal characters each), the machine of each check, and how many addresses and machines checked in successfully over 7 days. When that is more than the licence allows (plus its reissues), a warning says the key may be shared between servers.

The API's licence answers now also carry allowed_machines, usage_accounts and last_hostname, and the checks in licenses.get carry machine and api.

Public answers for your product website#

A product's own website (for example intopanel.com) can show the plans on sale, the release notes and a licence check by IP, read live from this installation. Each part is off until you switch it on under General Settings > Store: Publish licence plans and releases for your website and Public licence check by server IP. While off, the addresses answer 404 as if they did not exist.

AddressAnswers
GET /license/public/plans?product=CODEThe products with module Licensing and that product code which are not hidden or retired: name, tagline, summary, description, featured, edition, account limit, features, trial mark, prices in every currency (terms with a negative price are left out) and the store address to order it. Cached for 5 minutes.
GET /license/public/releases?product=CODEPublished releases only: version, channel, size, SHA-256, notes and date. No download links.
GET /license/public/verify?ip=ADDRESS[&product=CODE]Whether a public IPv4 or IPv6 address holds a licence (licensed), and for each licence bound to it (by the address it last checked from, or a line of its allowed addresses): state, product, plan, edition, trial mark, billing cycle, next due date, issue date, last check and the version it reported.

The answers are JSON and never hold a client's name, email, licence key, domain or amounts paid. Each visitor's address may make 300 calls in 15 minutes (60 for the licence check); after that it gets HTTP 429. Private and reserved addresses are refused with HTTP 400.

With Publish the knowledge base, announcements and downloads for your website switched on as well, the website can also show what any visitor of the client area may already read:

AddressAnswers
GET /license/public/kbThe visible knowledge base categories, and the articles that are not "signed-in clients only" and sit in at least one visible category: title, Markdown body, categories, tags, order, last change and the article's address in the client area.
GET /license/public/announcementsPublished announcements whose date has come: title, Markdown body, dates and address.
GET /license/public/downloadsDownloads that are not hidden and not for clients or product owners only, in visible categories: title, description, kind, file name, size, SHA-256 and the client area's own download address (which counts the download).

Website sign-in with client accounts#

Your own website (a forum or an ideas board, for example) can sign people in with their client area account, so they keep one password. Switch on Website sign-in with client accounts under General Settings > Store, set a shared secret of at least 32 characters (openssl rand -hex 32) and list the addresses that may receive website sign-ins, one per line (such as https://community.example.com/sso/).

The website sends the visitor to /client/website-sign-in?return=ADDRESS&state=RANDOM. They sign in to the client area if they have not yet, then return to ADDRESS with ?token= added. The token is base64url(JSON).base64url(HMAC-SHA256) signed with the shared secret and valid for two minutes; it holds sub (the user's id), name, email, email_verified, aud (the website's scheme and host), iat, exp and nonce (the website's state). The website must check the signature, the time, the audience and that the nonce is the one it sent in the same browser. Return addresses must use https (http only for localhost), may not contain a user name, password or #, and must start with one of the listed addresses; anything else is refused.