PyQt Licensing
Add license key activation and validation to your PyQt desktop application using the Keymint Python SDK.
Problem
You distribute a PyQt desktop app and need to prevent piracy. You need license keys that bind to the user's machine, a clean activation dialog, and a backend to manage keys. Building a licensing server, activation counting, and a management UI from scratch delays your launch by weeks.
Keymint provides the licensing API, a Python SDK with type hints, and a dashboard. You write the PyQt activation dialog and validation logic — everything else is handled.
Architecture Diagram
Create Product
From the Keymint Dashboard:
- Navigate to Products
- Enter your app name (e.g. "PhotoEditor Pro")
- Copy the product ID — you'll use it in every API call
Create License Policy
When generating a license key, save your settings as a Template (in More options, open Save as a template). This lets you re-use the same configuration.
| Setting | Value |
|---|---|
licenseType | node-locked |
maxActivations | 1 |
format | XXXX-XXXX-XXXX-XXXX |
expiry | None (perpetual) |
Issue License
Install the Python SDK in your backend or admin scripts:
pip install keymintGenerate keys from a Python script:
from keymint import KeyMint
admin = KeyMint(api_key="YOUR_ADMIN_API_KEY")
license = admin.create_key({
"productId": "YOUR_PRODUCT_ID",
"maxActivations": "1",
"licenseType": "node-locked",
"newCustomer": {
"name": "Jane Doe",
"email": "jane@example.com"
}
})
print(license["key"]) # "A8E2K-9F1BC-3D4GH-7J2KM"Save the raw key when it's returned — it's encrypted at rest, and the API can't look a key up without already knowing it. You can still view or export keys in the dashboard.
Validate License
Install the SDK in your PyQt project:
pip install keymintCreate a license manager module. The SDK raises KeyMintApiError (with status, code and message) for every non-2xx response; treat 401, 402, 403 and 404 as a rejected license and anything else as a temporary failure. The Python SDK doesn't expose errorCode yet, so branch on status:
import os
from PyQt6.QtCore import QSettings
from keymint import KeyMint, KeyMintApiError
from keymint.identity import get_or_create_installation_id
PRODUCT_ID = os.environ.get("KEYMINT_PRODUCT_ID", "YOUR_PRODUCT_ID")
CLIENT_API_KEY = os.environ.get("KEYMINT_CLIENT_API_KEY", "")
client = KeyMint(api_key=CLIENT_API_KEY)
settings = QSettings("YourCompany", "YourApp")
def get_host_id() -> str:
"""Stable installation ID, persisted to ~/.keymint/installation-id."""
return get_or_create_installation_id()
def is_rejected(error: KeyMintApiError) -> bool:
"""401 = invalid, expired, blocked or wrong device; 402 = vendor
subscription lapsed; 403 = activation limit reached; 404 = product
deactivated or removed. Anything else (no network, 429, 5xx) is
transient."""
return error.status in (401, 402, 403, 404)
def get_stored_key() -> str | None:
return settings.value("license/key") or None
def is_activated() -> bool:
return settings.value("license/activated", False, type=bool)
def activate_license(license_key: str) -> dict:
"""Returns {'success': bool, 'rejected': bool, 'message': str}.
The SDK raises KeyMintApiError for every non-2xx response, so a returned
result always means success.
"""
try:
result = client.activate_key({
"productId": PRODUCT_ID,
"licenseKey": license_key,
"hostId": get_host_id(),
})
except KeyMintApiError as e:
if is_rejected(e):
return {"success": False, "rejected": True, "message": e.message}
return {"success": False, "rejected": False,
"message": "Could not reach the licensing server. Try again later."}
settings.setValue("license/key", license_key)
settings.setValue("license/activated", True)
return {"success": True, "rejected": False, "message": result.get("message", "License valid")}
def deactivate_license() -> bool:
"""Deactivate the current license, freeing a seat."""
license_key = get_stored_key()
if not license_key:
return False
try:
client.deactivate_key({
"productId": PRODUCT_ID,
"licenseKey": license_key,
"hostId": get_host_id(),
})
except KeyMintApiError:
return False
settings.remove("license/key")
settings.remove("license/activated")
return TrueNow gate your PyQt application entry point:
import sys
from PyQt6.QtWidgets import QApplication
from license_manager import get_stored_key, is_activated, activate_license, settings
from activation_dialog import ActivationDialog
from main_window import MainWindow
def main():
app = QApplication(sys.argv)
stored_key = get_stored_key()
if stored_key and is_activated():
# Re-check on launch: catches revoked or expired licenses.
# Re-activating on the same device doesn't use another seat.
result = activate_license(stored_key)
if result["success"] or not result["rejected"]:
# Valid, or the server couldn't be reached: keep working offline.
window = MainWindow()
window.show()
sys.exit(app.exec())
settings.setValue("license/activated", False)
# Show activation dialog
dialog = ActivationDialog()
if dialog.exec() == ActivationDialog.DialogCode.Accepted:
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()Handle Activations
Build a PyQt activation dialog:
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QLabel, QLineEdit,
QPushButton, QMessageBox
)
from PyQt6.QtCore import Qt
from license_manager import activate_license, get_stored_key
class ActivationDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Activate License")
self.setFixedSize(420, 220)
self.setWindowFlags(
self.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint
)
layout = QVBoxLayout()
layout.setSpacing(12)
title = QLabel("Enter your license key to activate")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title)
self.key_input = QLineEdit()
self.key_input.setPlaceholderText("A8E2K-9F1BC-3D4GH-7J2KM-XXXX")
self.key_input.setMaxLength(64)
layout.addWidget(self.key_input)
# Pre-fill if a key was stored but deactivated
stored = get_stored_key()
if stored:
self.key_input.setText(stored)
self.activate_btn = QPushButton("Activate")
self.activate_btn.clicked.connect(self._on_activate)
layout.addWidget(self.activate_btn)
self.status_label = QLabel("")
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.status_label)
self.setLayout(layout)
def _on_activate(self):
key = self.key_input.text().strip()
if not key:
self.status_label.setText("Please enter a license key")
return
self.activate_btn.setEnabled(False)
self.status_label.setText("Activating...")
self.activate_btn.repaint()
result = activate_license(key)
if result["success"]:
QMessageBox.information(self, "Success", "License activated successfully!")
self.accept()
else:
self.status_label.setText(f"Failed: {result['message']}")
self.activate_btn.setEnabled(True)Handle Revocations
Block a license from the Dashboard or programmatically:
from keymint import KeyMint
admin = KeyMint(api_key="YOUR_ADMIN_API_KEY")
admin.block_key({
"productId": "YOUR_PRODUCT_ID",
"licenseKey": "A8E2K-9F1BC-3D4GH-7J2KM",
})When a license is blocked, the next activate_key() call raises KeyMintApiError with status 401 and code 1 (expired and unknown keys return the same response; the error body's errorCode is license_invalid_or_forbidden). Branch on status, not on the message text, which may be reworded. Re-check periodically, and only revoke local access when the server rejects the license:
def check_license_health() -> bool:
"""Periodically verify the license is still valid."""
license_key = get_stored_key()
if not license_key:
return False
try:
client.activate_key({
"productId": PRODUCT_ID,
"licenseKey": license_key,
"hostId": get_host_id(),
})
return True
except KeyMintApiError as e:
if is_rejected(e):
settings.setValue("license/activated", False)
return False
# Network error, 429 or 5xx: keep the cached state
return is_activated()Add a deactivation option in your app's preferences:
from PyQt6.QtWidgets import QPushButton, QMessageBox
from license_manager import deactivate_license
# Inside your preferences dialog:
deactivate_btn = QPushButton("Deactivate License")
deactivate_btn.clicked.connect(self._on_deactivate)
def _on_deactivate(self):
reply = QMessageBox.question(
self,
"Deactivate License",
"This will free your license seat for use on another machine. Continue?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
success = deactivate_license()
if success:
QMessageBox.information(self, "Success", "License deactivated.")
self.close()
else:
QMessageBox.warning(self, "Error", "Failed to deactivate license.")Production Considerations
PyInstaller / Nuitka Bundling
When bundling with PyInstaller:
pyinstaller --hidden-import=keymint main.pyThe keymint package must be discoverable. If you use --onefile, the SDK works out of the box.
Host ID Strategy
Use the SDK's get_or_create_installation_id() (shown above). On first run it combines a random ID with a hardware anchor (the BIOS/hardware UUID, then the OS machine ID, then the primary MAC address, skipping placeholder values from cloned VMs), persists it to ~/.keymint/installation-id, and returns its SHA-256 hash. It stays stable across restarts and updates; an OS reinstall or a wiped home directory produces a new ID, which uses a new seat.
from keymint.identity import get_or_create_installation_id
host_id = get_or_create_installation_id() # 64-character hex stringIf you roll your own, avoid wmic — it isn't installed by default on current Windows 11 builds. Read HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid with winreg instead.
API Key Security
- Store the
clientAPI key as an environment variable or bundled constant - Never bundle
adminAPI keys in your PyQt app - Use
clientscope — it can only activate, deactivate, and manage floating sessions
Offline Verification
For air-gapped deployments, use Keymint's offline signing:
# Run from your backend/server (NOT bundled in the app). Signing also
# activates hostId on the license, so it uses a seat.
admin = KeyMint(api_key="YOUR_ADMIN_API_KEY")
offline_license = admin.sign_key({
"productId": "YOUR_PRODUCT_ID",
"licenseKey": "A8E2K-9F1BC-3D4GH-7J2KM",
"hostId": "HOST_ID_FROM_THE_MACHINE", # get_or_create_installation_id() on that machine
"ttl": 30 * 24 * 60 * 60, # 30 days
})
# offline_license["file"] is the .lic file contents (JSON with the signed JWT)Then verify it in the app with the SDK's verify_offline_license() (from keymint), or with an EdDSA-capable JWT library such as pip install "pyjwt[crypto]". See the Offline Verification guide for the file format and checks.
Offline licensing is available on all plans.
Complete Source Code
Full license_manager.py
"""
license_manager.py — Keymint license management for PyQt applications.
Drop this file into your PyQt project and call activate_license()
from your activation dialog.
"""
import os
from PyQt6.QtCore import QSettings
from keymint import KeyMint, KeyMintApiError
from keymint.identity import get_or_create_installation_id
PRODUCT_ID = os.environ.get("KEYMINT_PRODUCT_ID", "")
CLIENT_API_KEY = os.environ.get("KEYMINT_CLIENT_API_KEY", "")
if not PRODUCT_ID or not CLIENT_API_KEY:
raise RuntimeError(
"KEYMINT_PRODUCT_ID and KEYMINT_CLIENT_API_KEY must be set"
)
client = KeyMint(api_key=CLIENT_API_KEY)
settings = QSettings("YourCompany", "YourApp")
def get_host_id() -> str:
return get_or_create_installation_id()
def is_rejected(error: KeyMintApiError) -> bool:
# 401 invalid/expired/blocked/wrong device, 402 vendor subscription
# lapsed, 403 activation limit reached, 404 product deactivated or
# removed. Anything else is transient.
return error.status in (401, 402, 403, 404)
def get_stored_key() -> str | None:
return settings.value("license/key") or None
def is_activated() -> bool:
return settings.value("license/activated", False, type=bool)
def activate_license(license_key: str) -> dict:
try:
result = client.activate_key({
"productId": PRODUCT_ID,
"licenseKey": license_key,
"hostId": get_host_id(),
})
except KeyMintApiError as e:
if is_rejected(e):
return {"success": False, "rejected": True, "message": e.message}
return {"success": False, "rejected": False,
"message": "Could not reach the licensing server. Try again later."}
settings.setValue("license/key", license_key)
settings.setValue("license/activated", True)
return {"success": True, "rejected": False, "message": result.get("message", "")}
def check_license_health() -> bool:
license_key = get_stored_key()
if not license_key:
return False
result = activate_license(license_key)
if result["rejected"]:
settings.setValue("license/activated", False)
return False
return result["success"] or is_activated()
def deactivate_license() -> bool:
license_key = get_stored_key()
if not license_key:
return False
try:
client.deactivate_key({
"productId": PRODUCT_ID,
"licenseKey": license_key,
"hostId": get_host_id(),
})
except KeyMintApiError:
return False
settings.remove("license/key")
settings.remove("license/activated")
return True