How to check a licence key inside your own product: the library, the API, ready-made integrations for eleven stacks, and the rules that keep your buyers’ sites running.
A licence key binds your product to one domain, or to as many as the buyer paid for. It lets an honest customer stay within what they bought, it gives you the count of your installations, and it makes a leaked key killable — the owner regenerates it and the leaked one dies. It is not copy protection. Your buyer has your source code and can delete the check in five minutes. Anyone who tells you otherwise is selling something.
Open the one that matches your product. Each block is complete and ready to paste; rename monmodule and adapt the storage of the key to your own settings. PHP stacks use the downloadable library; the others show the full client, cache and signature check included.
The shortest form. Put the check at the entry point of whatever you are protecting — a front controller, an admin page, a scheduled script.
require_once __DIR__ . '/AddonsMarketLicence.php';
$licence = new AddonsMarketLicence(
$config['licence'],
__DIR__ . '/cache'
);
if (!$licence->valide()) {
http_response_code(403);
exit($licence->message());
}
Keep one instance for the whole module. Check in getContent() so the merchant sees why the configuration screen refuses, and again in your display hooks so a module left half-configured does not render on the storefront.
require_once dirname(__FILE__) . '/AddonsMarketLicence.php';
class MonModule extends Module
{
private $licence = null;
private function licence()
{
if ($this->licence === null) {
$this->licence = new AddonsMarketLicence(
Configuration::get('MONMODULE_LICENCE'),
_PS_CACHE_DIR_ . 'monmodule'
);
}
return $this->licence;
}
public function getContent()
{
if (Tools::isSubmit('submitLicence')) {
Configuration::updateValue('MONMODULE_LICENCE', Tools::getValue('licence'));
}
$l = $this->licence();
if (!$l->valide()) {
return $this->displayError($l->message()) . $this->formulaireLicence();
}
if ($l->essai()) {
return $this->displayWarning($this->l('Development licence')) . $this->contenu();
}
return $this->contenu();
}
public function hookDisplayHeader()
{
if (!$this->licence()->valide()) {
return '';
}
return $this->contenuFront();
}
}
A static instance in one function, an admin notice, and the plugin body behind the check. Do not run the check on the front end of every page — the cache makes it cheap, but a notice belongs in the admin.
require_once __DIR__ . '/AddonsMarketLicence.php';
function monplug_licence() {
static $l = null;
if ($l === null) {
$l = new AddonsMarketLicence(
get_option('monplug_licence'),
WP_CONTENT_DIR . '/cache/monplug'
);
}
return $l;
}
add_action('admin_notices', function () {
$l = monplug_licence();
if (!$l->valide()) {
printf('<div class="notice notice-error"><p>%s</p></div>', esc_html($l->message()));
} elseif ($l->essai()) {
echo '<div class="notice notice-warning"><p>'
. esc_html__('Development licence', 'monplug') . '</p></div>';
}
});
add_action('init', function () {
if (monplug_licence()->valide()) {
monplug_demarrer();
}
});
For WooCommerce, gate what you add to the shop rather than the plugin as a whole: a payment gateway that disappears is clearer to the merchant than a plugin that dies silently.
add_filter('woocommerce_payment_gateways', function ($passerelles) {
if (monplug_licence()->valide()) {
$passerelles[] = 'WC_Gateway_MonPlug';
}
return $passerelles;
});
add_action('woocommerce_admin_field_monplug_licence', function () {
$l = monplug_licence();
if (!$l->valide()) {
echo '<div class="error inline"><p>' . esc_html($l->message()) . '</p></div>';
}
});
Wrap the library in a model and inject it where you need it. The cache directory must be the one Magento owns, or a deployment will wipe your verdict on every release.
namespace Editeur\MonModule\Model;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\App\Filesystem\DirectoryList;
class Licence
{
private $licence;
public function __construct(ScopeConfigInterface $config, DirectoryList $dossiers)
{
$this->licence = new \AddonsMarketLicence(
(string) $config->getValue('monmodule/general/licence'),
$dossiers->getPath(DirectoryList::CACHE) . '/monmodule'
);
}
public function valide(): bool
{
return $this->licence->valide();
}
public function message(): string
{
return $this->licence->message();
}
}
The library has no namespace, so declare it in your module’s composer.json rather than calling require_once by hand.
{
"autoload": {
"files": [
"AddonsMarketLicence.php"
]
}
}
A middleware is the right place: register it on the route group your product owns, never globally, so a licence problem never takes down the rest of the application.
namespace App\Licence;
use Closure;
use Illuminate\Http\Request;
class VerifieLicence
{
private $licence;
public function __construct()
{
$this->licence = new \AddonsMarketLicence(
config('monmodule.licence'),
storage_path('app/monmodule')
);
}
public function handle(Request $requete, Closure $suivant)
{
if (!$this->licence->valide()) {
abort(403, $this->licence->message());
}
return $suivant($requete);
}
}
An event subscriber on kernel.request, skipping sub-requests. Pass the key and the cache directory as arguments in your service definition rather than reading configuration inside the class.
namespace App\Licence;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\KernelEvents;
class LicenceSubscriber implements EventSubscriberInterface
{
private $licence;
public function __construct(string $cle, string $dossierCache)
{
$this->licence = new \AddonsMarketLicence($cle, $dossierCache);
}
public function onKernelRequest(RequestEvent $evenement): void
{
if (!$evenement->isMainRequest()) {
return;
}
if (!$this->licence->valide()) {
throw new AccessDeniedHttpException($this->licence->message());
}
}
public static function getSubscribedEvents(): array
{
return [KernelEvents::REQUEST => ['onKernelRequest', 16]];
}
}
No dependency beyond the standard library. This is the complete client: signed response, cache bound to its host, 24-hour reuse and 30-day grace period — the same rules as the PHP library.
import { createPublicKey, verify } from 'node:crypto';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
const API = 'https://addonsmarket.com/api/licence';
const PUBLIQUE = '/PTkKQpweo9XCLOS0+b0vgrecx7Ly/SsAuwTiA737Tw=';
const SURSIS = 2592000;
const publique = createPublicKey({
key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'),
Buffer.from(PUBLIQUE, 'base64')]),
format: 'der',
type: 'spki',
});
const canonique = (d) =>
JSON.stringify(Object.fromEntries(Object.keys(d).sort().map((k) => [k, d[k]])));
const signee = (rep, hote) =>
rep && rep.donnees && rep.signature
&& rep.donnees.hote === hote
&& verify(null, Buffer.from(canonique(rep.donnees), 'utf8'), publique,
Buffer.from(rep.signature, 'base64'));
export async function etat(cle, hote, fichierCache) {
const maintenant = Math.floor(Date.now() / 1000);
let cache = null;
try {
const lu = JSON.parse(await readFile(fichierCache, 'utf8'));
if (signee(lu, hote)) cache = lu;
} catch {}
if (cache && cache.donnees.revalider > maintenant) return cache.donnees;
try {
const r = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'User-Agent': 'MonProduit/1.0' },
body: JSON.stringify({ cle, url: hote }),
signal: AbortSignal.timeout(6000),
});
if (!r.ok) throw new Error(String(r.status));
const rep = await r.json();
if (!signee(rep, hote)) throw new Error('signature');
await mkdir(dirname(fichierCache), { recursive: true });
await writeFile(fichierCache, JSON.stringify(rep));
return rep.donnees;
} catch {
if (cache && cache.donnees.emis + SURSIS > maintenant) return cache.donnees;
return { ok: false, motif: 'reseau' };
}
}
Then call it once per request, and never block on a network failure.
import { etat } from './licence.mjs';
const d = await etat(process.env.LICENCE, req.hostname, './var/licence.json');
if (!d.ok && d.motif !== 'reseau') {
return res.status(403).json({ erreur: d.motif });
}
There is no honest way to check a licence in a browser. Whatever you write runs on the buyer’s machine, in code they can read, and removing it takes one line in the devtools. We do not ship a front-end library because we would be selling you a placebo.
If your product has a backend, check there and expose the verdict as data. The interface can then say something useful without the check itself being in the browser.
app.get('/api/etat', async (req, res) => {
const d = await etat(process.env.LICENCE, req.hostname, './var/licence.json');
res.json({ actif: d.ok, essai: d.essai });
});
const [actif, setActif] = useState(null);
useEffect(() => {
fetch('/api/etat')
.then((r) => r.json())
.then((d) => setActif(d.actif));
}, []);
if (actif === false) {
return <Bandeau>Licence inactive</Bandeau>;
}
If your product is a pure front-end template with no backend at all, there is nothing to check at runtime and you should not pretend otherwise. The licence is enforced where it can be: at download, and by the terms your buyer accepted.
A Shopify app has a backend of its own: use the Node or PHP client there, and send the shop domain — the myshopify.com address, or the custom domain if that is what you key on. A Shopify theme has no backend, so the front-end section above applies instead.
Standard library plus cryptography for the signature. Same rules as everywhere else: reuse for 24 hours, keep the last verdict for 30 days if we go quiet, block only on a signed refusal.
import base64, json, time, urllib.request
from pathlib import Path
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
API = 'https://addonsmarket.com/api/licence'
PUBLIQUE = '/PTkKQpweo9XCLOS0+b0vgrecx7Ly/SsAuwTiA737Tw='
SURSIS = 2592000
_publique = Ed25519PublicKey.from_public_bytes(base64.b64decode(PUBLIQUE))
def _canonique(d):
return json.dumps(d, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
def _signee(rep, hote):
try:
if rep['donnees']['hote'] != hote:
return False
_publique.verify(base64.b64decode(rep['signature']),
_canonique(rep['donnees']).encode('utf-8'))
return True
except (KeyError, TypeError, ValueError, InvalidSignature):
return False
def etat(cle, hote, fichier_cache):
maintenant = int(time.time())
chemin = Path(fichier_cache)
cache = None
try:
lu = json.loads(chemin.read_text(encoding='utf-8'))
if _signee(lu, hote):
cache = lu
except (OSError, ValueError):
pass
if cache and cache['donnees']['revalider'] > maintenant:
return cache['donnees']
try:
corps = json.dumps({'cle': cle, 'url': hote}).encode('utf-8')
requete = urllib.request.Request(API, data=corps, headers={
'Content-Type': 'application/json',
'User-Agent': 'MonProduit/1.0',
})
with urllib.request.urlopen(requete, timeout=6) as r:
rep = json.loads(r.read().decode('utf-8'))
if not _signee(rep, hote):
raise ValueError('signature')
chemin.parent.mkdir(parents=True, exist_ok=True)
chemin.write_text(json.dumps(rep), encoding='utf-8')
return rep['donnees']
except Exception:
if cache and cache['donnees']['emis'] + SURSIS > maintenant:
return cache['donnees']
return {'ok': False, 'motif': 'reseau'}
For anything else — Go, Ruby, .NET, Java, a shell script. One POST, one JSON body. Read the API section below for the fields, and the signature section for what you must verify before trusting the answer.
curl -X POST https://addonsmarket.com/api/licence \
-H 'Content-Type: application/json' \
-H 'User-Agent: MonProduit/1.0' \
-d '{"cle":"VOTRE_CLE","url":"boutique.exemple.fr"}'
One PHP file, no dependency, compatible back to PHP 5.6 so it runs on the old installations your buyers still have. Four methods are all you need.
| new AddonsMarketLicence($cle, $dossierCache) | Build it with the buyer’s key and a writable directory for the cache. |
| $licence->valide() | True when the product may run on this domain. |
| $licence->essai() | True on a development key or a staging domain. Show a discreet notice — never ship a build that hides it. |
| $licence->message() | A sentence to show the buyer, already translated, naming the domains and dating the event. |
Never let a network problem take down your buyer’s shop. The library already handles this and you should not work around it: a fresh verdict is reused for 24 hours; when our server does not answer, the last known verdict is kept for 30 days and the product keeps working; only a signed refusal stops it, immediately. If our server had a bad night, nobody’s shop goes dark. If a key is transferred, the old site stops within 24 hours.
A licence binds to the registrable domain. One key on example.com covers www.example.com, shop.example.com and any other subdomain — your buyer will not write to you because of a missing redirect. Test and staging addresses never use up a licence: localhost, .local, .test, and the usual dev., staging., preprod. prefixes all work without consuming anything.
The library is a thin wrapper over one endpoint. Call it directly if you work outside PHP — but then you must implement the caching rules above yourself, and verify the signature.
POST https://addonsmarket.com/api/licence
POST /api/licence HTTP/1.1
Host: addonsmarket.com
Content-Type: application/json
User-Agent: MonProduit/1.0
{
"cle": "VOTRE_CLE",
"url": "boutique.exemple.fr"
}
| cle | string | The buyer’s key. Letters and digits only; we strip anything else, so pasted spaces and dashes are harmless. |
| url | string | The host your product runs on. A full URL works too — we keep only the host. Strip the port and the leading www. yourself if you want the response to match what you sent. |
{
"donnees": {
"ok": true,
"motif": "",
"domaine": "exemple.fr",
"essai": false,
"quand": "",
"emis": 1790687453,
"revalider": 1790773853,
"sursis": 2592000,
"hote": "boutique.exemple.fr"
},
"signature": "4p9JXJRfcB5R/LNEQwr/cwnVv1M5R1OdCNcU…"
}
| ok | bool | Whether the product may run here. |
| motif | string | Why not, when ok is false. See the table below. |
| domaine | string | The domain the licence is bound to. |
| essai | bool | Development key or staging domain. |
| quand | string | When the key was replaced or revoked. |
| emis | int | When we answered. The start of the 30-day grace period. |
| revalider | int | Timestamp before which you must not ask again. |
| sursis | int | Seconds you may keep this verdict if we stop answering. |
| hote | string | The host we answered about. Reject the response if it is not yours. |
| 200 | A signed verdict. Note that a refusal is also a 200 — read ok, not the status code. |
| 400 | Missing key or missing host. |
| 405 | Anything other than POST or OPTIONS. |
| 429 | Rate limit reached. Keep your cached verdict and retry later — never treat this as a refusal. |
Thirty calls a minute per IP address. With the caching rules respected, a shop makes one call a day, so you will only meet this limit from a load test or a loop that forgot its cache.
Send a User-Agent that names your product. A few default agents of HTTP libraries — Python’s urllib among them — are filtered before they reach us and get a 403. Any custom string avoids it.
Without a signature, one line in a server’s hosts file is enough to answer “licence valid” in our place. Every response carries an Ed25519 signature over the canonical form of the data — keys sorted, compact JSON. Verify it before you trust anything. The library does it for you, including on the cached copy, which is also bound to its host so it cannot be copied from one site to another.
Only needed if you call the API directly. Rebuild the canonical form from the parsed object — never from the raw bytes you received, which may differ — then verify the detached Ed25519 signature against our public key.
Ed25519, raw 32 bytes in base64. It is the same for every author and every product.
/PTkKQpweo9XCLOS0+b0vgrecx7Ly/SsAuwTiA737Tw=
The signature covers the donnees object serialised by these four rules, and nothing else. Get one of them wrong and every signature will look invalid.
For the response above, the exact bytes that were signed:
{"domaine":"exemple.fr","emis":1790687453,"essai":false,"hote":"boutique.exemple.fr","motif":"","ok":true,"quand":"","revalider":1790773853,"sursis":2592000}
function signature_valide(array $donnees, $signature, $publique)
{
ksort($donnees);
$corps = json_encode($donnees, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return sodium_crypto_sign_verify_detached(
base64_decode($signature, true),
$corps,
base64_decode($publique, true)
);
}
import { createPublicKey, verify } from 'node:crypto';
const publique = createPublicKey({
key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'),
Buffer.from('/PTkKQpweo9XCLOS0+b0vgrecx7Ly/SsAuwTiA737Tw=', 'base64')]),
format: 'der',
type: 'spki',
});
const canonique = (d) =>
JSON.stringify(Object.fromEntries(Object.keys(d).sort().map((k) => [k, d[k]])));
const valide = verify(null, Buffer.from(canonique(rep.donnees), 'utf8'),
publique, Buffer.from(rep.signature, 'base64'));
import base64, json
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
publique = Ed25519PublicKey.from_public_bytes(base64.b64decode('/PTkKQpweo9XCLOS0+b0vgrecx7Ly/SsAuwTiA737Tw='))
canonique = json.dumps(rep['donnees'], sort_keys=True,
separators=(',', ':'), ensure_ascii=False)
publique.verify(base64.b64decode(rep['signature']), canonique.encode('utf-8'))
| autre_domaine | The key is valid but belongs to another domain. Tell the buyer to transfer it from their account. |
| transfert | The key was moved to another site and replaced. The buyer has a new one. |
| regeneration | The buyer asked for a new key. The old one is dead for good. |
| revocation | We revoked the key: abuse, fraud, or a settled dispute. |
| expiree | A time-limited test key that has run out. |
| inconnue | No such key. Usually a typo. |
| reseau | We could not be reached and no cached verdict remains. Do not block on this. |
Your author area gives you a development key for each of your products. It works on any domain, opens only your own products, and always reports essai — so an install running on it shows a development notice and can never be mistaken for a sold copy.
To see how your product behaves when it is refused, send a key that does not exist. You get a real signed refusal, which is exactly what a revoked key looks like — so you can check your error screen without revoking anything.
curl -X POST https://addonsmarket.com/api/licence \
-H 'Content-Type: application/json' \
-H 'User-Agent: MonProduit/1.0' \
-d '{"cle":"cettecleNexistePas000000","url":"boutique.exemple.fr"}'
{"donnees":{"ok":false,"motif":"inconnue", …},"signature":"…"}
Your product will send the domain it runs on to our server. That is personal data processing under European law, and your buyer must know about it. Say it in your product description and in your own documentation. We keep the domain, and the IP for twelve months to settle disputes; nothing else, and we sell none of it.
Write to us from your author area. A question that needed asking usually means this page is missing a paragraph.