Opening soon — authors keep 100 % of their price Become an author Sign in Create account
Hello, sign in Account & Lists YourAuthor area

Licensing for developers

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.

1.What this does, and what it does not

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.

2.Three steps

  1. Open your product in your author area, go to Licensing, and download the library. It already carries our public key and your product identifier — nothing to configure.
  2. Ask the buyer for their key on first install and store it wherever your product keeps its settings. Never hard-code it.
  3. Call valide() where it matters. Once per request is enough — the library caches and will not hit the network on every page.

3.Integration, stack by stack

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.

PHP Plain PHP SDK

The shortest form. Put the check at the entry point of whatever you are protecting — a front controller, an admin page, a scheduled script.

monproduit.php
require_once __DIR__ . '/AddonsMarketLicence.php';

$licence = new AddonsMarketLicence(
    $config['licence'],
    __DIR__ . '/cache'
);

if (!$licence->valide()) {
    http_response_code(403);
    exit($licence->message());
}
PS PrestaShop 1.7 and 8 SDK

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.

monmodule.php
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();
    }
}
WP WordPress SDK

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.

monplug.php
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.

woocommerce.php
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>';
    }
});
M2 Magento 2 SDK

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.

Model/Licence.php
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.

composer.json
{
  "autoload": {
    "files": [
      "AddonsMarketLicence.php"
    ]
  }
}
LV Laravel SDK

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.

app/Licence/VerifieLicence.php
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);
    }
}
SF Symfony SDK

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.

src/Licence/LicenceSubscriber.php
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]];
    }
}
JS Node.js API

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.

licence.mjs
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.

serveur.mjs
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 });
}
UI React, Vue, Angular API

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.

serveur.mjs
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 });
});
Licence.jsx
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.

SH Shopify API

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.

PY Python API

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.

licence.py
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'}
{ } Raw HTTP API

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
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"}'

4.The library

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.

5.The rule that matters most

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.

6.Domains

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.

7.The API, if you would rather call it yourself

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.

Endpoint

POST https://addonsmarket.com/api/licence

Request

HTTP
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"
}

What you send

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.

Response

JSON
{
  "donnees": {
    "ok": true,
    "motif": "",
    "domaine": "exemple.fr",
    "essai": false,
    "quand": "",
    "emis": 1790687453,
    "revalider": 1790773853,
    "sursis": 2592000,
    "hote": "boutique.exemple.fr"
  },
  "signature": "4p9JXJRfcB5R/LNEQwr/cwnVv1M5R1OdCNcU…"
}

Response fields

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.

HTTP statuses

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.

8.Why the response is signed

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.

9.Verifying the signature yourself

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.

Our public key

Ed25519, raw 32 bytes in base64. It is the same for every author and every product.

/PTkKQpweo9XCLOS0+b0vgrecx7Ly/SsAuwTiA737Tw=

The canonical form

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.

  1. Keys sorted in ascending order.
  2. No whitespace: no space after the colons or the commas.
  3. Forward slashes are not escaped.
  4. Non-ASCII characters stay as UTF-8, with no backslash-u escaping.

For the response above, the exact bytes that were signed:

utf-8
{"domaine":"exemple.fr","emis":1790687453,"essai":false,"hote":"boutique.exemple.fr","motif":"","ok":true,"quand":"","revalider":1790773853,"sursis":2592000}
PHP PHP
sodium
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)
    );
}
JS Node.js
node:crypto
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'));
PY Python
cryptography
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'))

10.Refusal reasons

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.

11.Testing before you sell

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
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":"…"}

12.What you must tell your buyer

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.

Something unclear?

Write to us from your author area. A question that needed asking usually means this page is missing a paragraph.

Author area