# **Licence** checks in Qvix software

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

1. A buyer pays for the product on https://qvixgroup.com. A key like `QVX-7K2M-9PQR-4XTB-H8WN` is made at once and reaches the buyer by email, by SMS and in their account.
2. The buyer types the key into the software. The software calls `activate` with the key and where it runs (a website domain, or an id for the computer).
3. Each key works in a set number of places. When they are all used, `activate` answers `limit_reached`. The buyer frees an old place in their account (https://qvixgroup.com/en/account), or the software calls `deactivate` when it is removed.
4. 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.
5. 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 removes `www.`, so `https://www.Shop.com/admin` is stored as `shop.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

```json
{
  "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:

1. Verify `signature` over the `signed` text with the public key. If it does not match, ignore the answer.
2. Decode `signed` from base64 and read the JSON from it. Use only these fields, not the unsigned copy next to them.
3. Check that `key` is this key, `domain` is this place (made the same way the server makes it), `nonce` is the one just sent, and `product` is your slug.
4. Save `signed`, `signature` and the nonce. On start-up, check the saved answer the same way and accept it while `checked_at` is less than 7 days old.

## Rules for the software

- Call `activate` when the buyer saves a key, `verify` once a day (and when the settings page opens), and `deactivate` when 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 `status` in plain words (see the table above) and how many places are used, from `activations`.
- 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
<?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:

```php
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: `MachineGuid` from `HKLM\SOFTWARE\Microsoft\Cryptography`.
- macOS: `IOPlatformUUID` from `ioreg -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

```js
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

```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.
- `localhost` and 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 edited `signed` text (it must be refused).

```bash
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
