Developer guide
Licence checks in Qvix software
Everything needed to add the licence key check to a plugin, theme or desktop app: the rules, the API and code to copy. For developers and AI coding agents.
Give this link to an AI agent and it can read the whole guide at once.
- API
- POST https://qvixgroup.com/api/licence
- Sign-in
- The licence key only
- Signature
- Ed25519
- Offline
- Up to 7 days
Every Qvix plugin, theme, desktop app or other software that is sold with a licence key checks that key with the Qvix licence API. This guide is everything a developer or an AI coding agent needs to add that check. Follow it as written and the software works the same way as every other Qvix product.
How it works
- A buyer pays for the product on https://qvixgroup.com. A key like
QVX-7K2M-9PQR-4XTB-H8WNis made at once and reaches the buyer by email, by SMS and in their account. - The buyer types the key into the software. The software calls
activatewith the key and where it runs (a website domain, or an id for the computer). - Each key works in a set number of places. When they are all used,
activateanswerslimit_reached. The buyer frees an old place in their account (https://qvixgroup.com/en/account), or the software callsdeactivatewhen it is removed. - Once a day the software calls
verify. If there is no internet, it keeps working on the last good answer for up to 7 days. - Every answer is signed. The software checks the signature with the Qvix public key, so a fake server cannot unlock it.
Before you start: the product in the Qvix admin
In Admin > Products, open the product and set:
- Comes with a licence key: on.
- Sites or computers per key: how many places one key may run in (for example 1 or 3).
- Slug: note it down (for example
wp-sms-otp). The software checks that the key belongs to this slug, so a key for another product does not unlock it.
Monthly and yearly products: the key stops being valid when the subscription ends and starts again after renewal. Nothing extra is needed in the software.
What you need in the software
Put these three values in the code as constants. Do not download the public key from the server.
| Value | What to use |
|---|---|
| API base | https://qvixgroup.com/api/licence |
| Product slug | The slug from Admin > Products |
| Public key | PUBLIC_KEY_NOT_SET |
API
Every request is POST to the API base plus the endpoint, with a form or JSON body. There is no login, cookie or API token: the licence key is the credential. The limit is 60 requests a minute per IP address.
| Endpoint | Send | Does |
|---|---|---|
/verify |
key, domain (optional), nonce (optional) |
Is the key good? If this place is activated, its "last checked" date is updated. |
/activate |
key, domain, nonce (optional) |
Start using the key here, if there is a free place. Activating the same place again is fine and uses no extra place. |
/deactivate |
key, domain, nonce (optional) |
Free this place, so the key can be used somewhere else. |
key: the licence key. Upper or lower case both work.domain: where the software runs. For a website, its domain. The server lower-cases it, keeps only the host from a URL and removeswww., sohttps://www.Shop.com/adminis stored asshop.com. For a desktop app, a stable id for the computer (see "Desktop and other software"). Up to 190 characters.nonce: a new random text of up to 64 characters for each request. It comes back inside the signed answer, so an old answer cannot be replayed.
Answer
{
"valid": true,
"status": "valid",
"product": "wp-sms-otp",
"expires_at": "2027-10-18T10:00:00+06:00",
"activations": { "used": 1, "limit": 3 },
"activated": true,
"signed": "eyJ2YWxpZCI6dHJ1ZSwic3RhdHVzIjoidmFsaWQi...",
"signature": "q8cJ3kP0...=="
}
status |
HTTP | Meaning | What the software shows |
|---|---|---|---|
valid |
200 | The key is good | Unlock |
invalid |
404 | No such key | "This key is not right, please check it" |
unpaid |
403 on activate | The order is not paid yet | "Payment is not confirmed yet" |
refunded |
403 on activate | The order was refunded | "This key is no longer active" |
expired |
403 on activate | Monthly or yearly time is over | "Renew to keep using it" and a link to the shop |
revoked |
403 on activate | Switched off by the Qvix team | "Contact Qvix support" |
limit_reached |
409 | Every place for this key is in use | "Free an old site or computer in your Qvix account" with a link to https://qvixgroup.com/en/account |
expires_at is only set for monthly or yearly products. activated says whether this place is activated. A wrong form (no key, for example) answers 422 without a signature. Unlock only when valid is true inside the signed part.
Checking the signature
signed is the answer as base64 JSON. Inside are the fields above plus key (upper case), domain (as the server stores it), nonce (what you sent) and checked_at (server time). signature is the Ed25519 signature, in base64, of the exact signed text (not of the decoded JSON).
The software must:
- Verify
signatureover thesignedtext with the public key. If it does not match, ignore the answer. - Decode
signedfrom base64 and read the JSON from it. Use only these fields, not the unsigned copy next to them. - Check that
keyis this key,domainis this place (made the same way the server makes it),nonceis the one just sent, andproductis your slug. - Save
signed,signatureand the nonce. On start-up, check the saved answer the same way and accept it whilechecked_atis less than 7 days old.
Rules for the software
- Call
activatewhen the buyer saves a key,verifyonce a day (and when the settings page opens), anddeactivatewhen the software is deactivated or uninstalled. - Time out network calls after about 15 seconds and never block the website or the app while waiting.
- A network error is not a wrong key. Keep using the saved answer until it is 7 days old.
- Lock only the paid features when the key is not valid. Do not break the buyer's website, delete data or show alarming messages.
- Show the buyer the
statusin plain words (see the table above) and how many places are used, fromactivations. - Do not store the key anywhere public, and do not send it anywhere except the Qvix API.
- Keep all text the buyer sees in Bangla and English if the software has both languages.
WordPress plugin
Copy this class into the plugin, for example as includes/class-qvix-licence.php, and change the four constants at the top. It adds a licence page under Settings, a notice while there is no valid key, a daily check, and frees the place when the plugin is deactivated. sodium_crypto_sign_verify_detached is built into WordPress 5.2 and newer.
<?php
if (! defined('ABSPATH')) exit;
class My_Plugin_Licence
{
const PRODUCT = 'wp-sms-otp'; // product slug in the Qvix admin
const PUBLIC_KEY = 'PUBLIC_KEY_NOT_SET';
const API = 'https://qvixgroup.com/api/licence/';
const PREFIX = 'my_plugin'; // unique to this plugin: option, cron and page names
public static function boot($plugin_file)
{
add_action('admin_menu', [__CLASS__, 'menu']);
add_action('admin_notices', [__CLASS__, 'notice']);
add_action(self::PREFIX . '_licence_check', [__CLASS__, 'refresh']);
if (! wp_next_scheduled(self::PREFIX . '_licence_check')) {
wp_schedule_event(time() + HOUR_IN_SECONDS, 'daily', self::PREFIX . '_licence_check');
}
register_deactivation_hook($plugin_file, [__CLASS__, 'release']);
}
/** Call this before every paid feature */
public static function is_valid()
{
$proof = self::read_proof(get_option(self::PREFIX . '_licence', []));
return $proof && $proof['valid'] === true && strtotime($proof['checked_at']) > time() - 7 * DAY_IN_SECONDS;
}
/** The signed answer, or null when there is none yet or it cannot be trusted */
public static function proof()
{
return self::read_proof(get_option(self::PREFIX . '_licence', []));
}
private static function call($action, $key)
{
$nonce = wp_generate_password(20, false);
$response = wp_remote_post(self::API . $action, [
'timeout' => 15,
'body' => ['key' => $key, 'domain' => self::domain(), 'nonce' => $nonce],
]);
if (is_wp_error($response)) {
return null; // no internet: the saved answer keeps working
}
$body = json_decode(wp_remote_retrieve_body($response), true) ?: [];
$data = ['key' => $key, 'nonce' => $nonce, 'signed' => $body['signed'] ?? '', 'signature' => $body['signature'] ?? ''];
$proof = self::read_proof($data);
if ($proof && $action !== 'deactivate') {
update_option(self::PREFIX . '_licence', $data, false);
}
return $proof;
}
private static function read_proof($data)
{
if (empty($data['signed']) || empty($data['signature']) || empty($data['key'])) {
return null;
}
$signature = base64_decode($data['signature'], true);
$public = base64_decode(self::PUBLIC_KEY, true);
if (! $signature || ! $public || ! sodium_crypto_sign_verify_detached($signature, $data['signed'], $public)) {
return null;
}
$proof = json_decode(base64_decode($data['signed']), true);
$ours = is_array($proof)
&& $proof['key'] === strtoupper(trim($data['key']))
&& $proof['domain'] === self::domain()
&& $proof['nonce'] === $data['nonce']
&& (empty($proof['product']) || $proof['product'] === self::PRODUCT);
return $ours ? $proof : null;
}
public static function refresh()
{
$data = get_option(self::PREFIX . '_licence', []);
if (! empty($data['key'])) {
self::call('verify', $data['key']);
}
}
public static function release()
{
$data = get_option(self::PREFIX . '_licence', []);
if (! empty($data['key'])) {
self::call('deactivate', $data['key']);
}
delete_option(self::PREFIX . '_licence');
wp_clear_scheduled_hook(self::PREFIX . '_licence_check');
}
/** The site as the server stores it: lower case host, no "www." */
private static function domain()
{
$host = strtolower((string) wp_parse_url(home_url(), PHP_URL_HOST));
return preg_replace('/^www\./', '', $host);
}
public static function menu()
{
add_options_page('Licence', 'My Plugin licence', 'manage_options', self::PREFIX . '-licence', [__CLASS__, 'page']);
}
public static function page()
{
$message = '';
if (isset($_POST['licence_key']) && check_admin_referer(self::PREFIX . '_licence')) {
$proof = self::call('activate', sanitize_text_field(wp_unslash($_POST['licence_key'])));
$message = ! $proof ? 'Could not reach the licence server. Please try again.'
: ($proof['valid'] ? 'Licence activated.' : 'This key does not work here: ' . $proof['status']);
}
$data = get_option(self::PREFIX . '_licence', []);
$proof = self::proof();
?>
<div class="wrap">
<h1>Licence</h1>
<?php if ($message) : ?><div class="notice notice-info"><p><?php echo esc_html($message); ?></p></div><?php endif; ?>
<form method="post">
<?php wp_nonce_field(self::PREFIX . '_licence'); ?>
<p><label for="licence_key">Licence key</label><br>
<input id="licence_key" name="licence_key" class="regular-text" placeholder="QVX-XXXX-XXXX-XXXX-XXXX"
value="<?php echo esc_attr($data['key'] ?? ''); ?>"></p>
<?php submit_button('Activate licence'); ?>
</form>
<?php if ($proof) : ?>
<p>Status: <strong><?php echo esc_html($proof['status']); ?></strong>,
places used: <?php echo (int) $proof['activations']['used']; ?> of <?php echo (int) $proof['activations']['limit']; ?></p>
<?php endif; ?>
</div>
<?php
}
public static function notice()
{
if (current_user_can('manage_options') && ! self::is_valid()) {
$url = admin_url('options-general.php?page=' . self::PREFIX . '-licence');
echo '<div class="notice notice-warning"><p>Add your licence key to use every feature: <a href="' . esc_url($url) . '">Licence settings</a></p></div>';
}
}
}
In the main plugin file:
require_once __DIR__ . '/includes/class-qvix-licence.php';
My_Plugin_Licence::boot(__FILE__);
// Before a paid feature
if (My_Plugin_Licence::is_valid()) {
// paid feature
}
Rename the class and PREFIX for every plugin, so two Qvix plugins on one site do not clash.
Desktop and other software
Any language works: send a form POST, read the JSON, check the Ed25519 signature. For domain, send a stable id for the computer that does not change after a restart or an update:
- Windows:
MachineGuidfromHKLM\SOFTWARE\Microsoft\Cryptography. - macOS:
IOPlatformUUIDfromioreg -rd1 -c IOPlatformExpertDevice. - Linux:
/etc/machine-id.
Do not send the raw id. Send the first 32 characters of sha256(raw id + ":" + product slug) in lower-case hex. It stays private, is the same every time, and uses only letters and digits, so the server stores it unchanged. Show it to the buyer as "computer id", because the account page lists it.
Node.js or Electron
const crypto = require('crypto');
const API = 'https://qvixgroup.com/api/licence';
const PRODUCT = 'qvix-pos';
const PUBLIC_KEY = 'PUBLIC_KEY_NOT_SET';
// Ed25519 public key from its 32 raw bytes
const publicKey = crypto.createPublicKey({
key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), Buffer.from(PUBLIC_KEY, 'base64')]),
format: 'der',
type: 'spki',
});
async function licence(action, key, place) {
const nonce = crypto.randomBytes(16).toString('hex');
const res = await fetch(`${API}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ key, domain: place, nonce }),
signal: AbortSignal.timeout(15000),
});
const body = await res.json();
return readProof({ key, place, nonce, signed: body.signed, signature: body.signature });
}
// Save the object passed in here; call it again on start-up with the saved copy
function readProof({ key, place, nonce, signed, signature }) {
if (!signed || !signature) return null;
const ok = crypto.verify(null, Buffer.from(signed), publicKey, Buffer.from(signature, 'base64'));
if (!ok) return null;
const proof = JSON.parse(Buffer.from(signed, 'base64').toString('utf8'));
const ours = proof.key === key.trim().toUpperCase() && proof.domain === place && proof.nonce === nonce
&& (!proof.product || proof.product === PRODUCT);
return ours ? proof : null;
}
function isValid(proof) {
return !!proof && proof.valid === true && Date.now() - Date.parse(proof.checked_at) < 7 * 24 * 3600 * 1000;
}
Python
import base64, json, secrets, time
from datetime import datetime
import requests
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
API = "https://qvixgroup.com/api/licence"
PRODUCT = "qvix-pos"
PUBLIC_KEY = Ed25519PublicKey.from_public_bytes(base64.b64decode("PUBLIC_KEY_NOT_SET"))
def licence(action, key, place):
nonce = secrets.token_hex(16)
body = requests.post(f"{API}/{action}", data={"key": key, "domain": place, "nonce": nonce}, timeout=15).json()
return read_proof(key, place, nonce, body.get("signed"), body.get("signature"))
def read_proof(key, place, nonce, signed, signature):
if not signed or not signature:
return None
try:
PUBLIC_KEY.verify(base64.b64decode(signature), signed.encode())
except Exception:
return None
proof = json.loads(base64.b64decode(signed))
ours = (proof["key"] == key.strip().upper() and proof["domain"] == place and proof["nonce"] == nonce
and (not proof.get("product") or proof["product"] == PRODUCT))
return proof if ours else None
def is_valid(proof):
if not proof or proof["valid"] is not True:
return False
return time.time() - datetime.fromisoformat(proof["checked_at"]).timestamp() < 7 * 24 * 3600
Testing
- Buy the product once on the live site (or ask the Qvix team for a test order) to get a real key.
localhostand test sites count as places too. After testing, free them in your account (https://qvixgroup.com/en/account) so the buyer's places stay free.- Try each case: a wrong key, the same site twice (still one place used), one site more than the limit (
limit_reached), no internet (the saved answer keeps working), and an editedsignedtext (it must be refused).
curl -X POST https://qvixgroup.com/api/licence/verify -d key=QVX-XXXX-XXXX-XXXX-XXXX -d domain=example.com -d nonce=test123
This guide as plain Markdown, for AI agents and editors: https://qvixgroup.com/en/developers/licence.md