SDK integration
Python SDK integration
Add Keyright licensing to a Python app: install the keyright package, verify licenses offline, activate online, gate features on entitlements, and handle trials and air-gapped machines — failing closed by design.
This is the Python client walkthrough — from pip install to a shipping app that unlocks paid features against a real license key. The keyright package verifies the exact same license and lease formats as the .NET SDK, so a mixed-language product line can share one Keyright tenant and one public key. This page spends its depth on the Python client code; the code blocks use the real SDK surface, so copy them as-is.
Before you start: the vendor setup
The one-time vendor steps are language-agnostic and are covered in full elsewhere — do them once, then come back here for the client:
- Create your product and note its slug (the identifier your app sends as
product). - Copy your signing public key — a base64
SubjectPublicKeyInfostring. It is not a secret; it ships inside your app and can only verify signatures, never mint them. All security comes from the private key staying encrypted on the server. - Define tiers and entitlements — each tier carries a seat count and a template of named flags and numeric limits that get baked into every license.
Step-by-step, with the dashboard screens and the equivalent API calls, is in Getting started and the .NET walkthrough. Everything below assumes you have your product slug and your base64 public key in hand.
How it works
You issue license keys server-side in Keyright; each is signed with your tenant’s RSA private key, which never leaves the server. Your Python app embeds only the matching public key and verifies licenses offline against it, so a routine license check needs no network. For seat enforcement and revocation you also activate online: the app exchanges a license key for a short-lived signed lease bound to the machine, then keeps working offline until that lease expires. Anything that doesn’t check out — bad signature, wrong product, expiry, revocation, a rolled-back clock — resolves to the Free edition instead of raising. It fails closed.
Install the SDK
The package is on PyPI. It needs Python 3.8+ and pulls in cryptography for RSA verification:
pip install "keyright>=1.1.4"
Everything you need is exported from the top-level package:
from keyright import (
KeyrightClient,
KeyrightOptions,
LicenseInfo,
LicenseStatus,
Edition,
MachineFingerprint,
)
Initialize the client
Construct one KeyrightClient at startup with the static initialize factory. It validates the options and loads the public key up front (raising ValueError if the key is missing), so build it once and reuse it:
from keyright import KeyrightClient, KeyrightOptions
license = KeyrightClient.initialize(KeyrightOptions(
product="acme-app", # must match the product slug you issue keys for
public_key_base64="MIIBIjANBgkq...", # the base64 public key from your dashboard
service_url="https://keyright.delta1labs.com", # omit if you ship offline license files only
))
Only product and public_key_base64 are meaningful to configure; service_url is required only for online activation. KeyrightOptions accepts several optional keyword arguments that control where a license is read from and how it is validated:
KeyrightOptions(
product="acme-app",
public_key_base64="MIIBIjANBgkq...",
service_url="https://keyright.delta1labs.com",
# Explicit license sources (highest precedence first)
license_string=None, # a license JSON string passed directly
license_file_path=None, # a path to a license file
env_var_name="KEYRIGHT_LICENSE", # env var naming a license file path (this is the default)
# Keep an old key valid during a rotation window
additional_public_keys_base64=["<previous public key>"],
# Offline revocation — ship a signed revocation list with your build
revocation_list_json=None,
revocation_list_path=None,
# Node-lock tolerance: how many soft-component changes to allow (default 1)
node_lock_tolerance=1,
))
The SDK resolves a license from several sources, in precedence order (highest first): an explicit license_string, then an explicit license_file_path, then the file named by the KEYRIGHT_LICENSE environment variable, then config_license_path, then the OS app-data path ({LocalAppData}/Keyright/{product}/license.json), and finally the cached activation lease. You usually set none of these — online activation writes the lease cache for you.
Gate features offline
Call validate(). It resolves the best license from the sources above, verifies the RSA signature, product match, node-lock, expiry, any shipped revocation list, and trial/clock-tamper state — all offline — and it never raises. On any failure it resolves to a LicenseInfo in the Free edition carrying the reason.
validate() returns a tuple (LicenseInfo, source) — always unpack it. source is a short diagnostic string ("lease", "environment", "default", "none", …) telling you which source won:
from keyright import Edition
info, source = license.validate() # -> (LicenseInfo, str) — unpack the tuple
if info.is_paid: # True for any edition above Free
enable_paid_features()
if info.edition == Edition.ENTERPRISE:
enable_enterprise_features()
If you only want the LicenseInfo and don’t care which source it came from, validate_info() returns just the first element:
info = license.validate_info() # equivalent to license.validate()[0]
Gate on entitlements
Prefer gating individual features on entitlements rather than on the edition, so changing a tier’s template doesn’t mean shipping new code. LicenseInfo.entitlements is an EntitlementSet with two typed, fail-closed accessors:
info = license.validate_info()
# Boolean flag — a missing or non-truthy flag reads as False
if info.entitlements.is_enabled("export"):
show_export_command()
# Numeric limit — pass the fail-closed fallback yourself; a missing or
# unparseable value returns your fallback, and "unlimited" returns a very large int
max_projects = info.entitlements.get_limit("max-projects", fallback=1)
if current_project_count >= max_projects:
prompt_to_upgrade()
For a single quick check the client also exposes convenience shortcuts that validate on the spot — license.is_enabled("export"), license.get_limit("max-projects", 1), and license.edition(). Each one re-runs validate() internally, so when you check several entitlements at once, validate once and reuse the EntitlementSet:
info = license.validate_info()
ents = info.entitlements
can_export = ents.is_enabled("export")
max_seats = ents.get_limit("max-seats", 1)
Both is_enabled and get_limit fail closed: a missing flag is disabled, a missing or unparseable limit returns your fallback. Combine an edition check with an entitlement check when a feature belongs to a tier and a template flag, and both must clear.
Read the status for diagnostics
To find out why a check resolved the way it did — for a “Register” dialog or logging — read the snake_case fields on LicenseInfo:
info, source = license.validate()
print(info.status) # LicenseStatus enum: VALID, NO_LICENSE, SIGNATURE_INVALID,
# EXPIRED, MACHINE_MISMATCH, REVOKED, CLOCK_TAMPERED, MALFORMED
print(info.message) # human-facing explanation string
print(info.licensee) # who the license was issued to
print(info.edition) # Edition.FREE | LICENSED | ENTERPRISE
print(info.is_valid) # True only when status == LicenseStatus.VALID
print(info.is_paid) # True for any edition above Free
print(info.status_badge) # e.g. "Enterprise", "Licensed Trial", "Free"
print(info.kind) # LicenseKind.PERPETUAL | SUBSCRIPTION | TRIAL
print(info.is_trial) # True for a trial license/lease
print(info.expiry_utc) # datetime | None (None = perpetual)
print(info.days_remaining) # int | None — e.g. 27 for a "27 days left" badge
print(info.trial_days) # int | None — the trial's configured length
print(source) # which source resolved it: "lease", "environment", ...
is_valid is the strict check (status is VALID); is_paid is the “unlock paid features” check (edition above Free). A valid trial is both.
Activate online with a license key
When a customer enters a key, call activate(key). It is synchronous. It posts the key plus a stable machine id to your service, which consumes a seat and returns a short-lived signed lease bound to that machine. The SDK verifies the lease against your embedded public key and caches it locally, so every later validate() succeeds with no network until the lease’s grace window elapses.
activate does not raise for the ordinary failure paths (bad key, seat limit, offline, revoked) — it returns a fail-closed LicenseInfo, exactly like validate(). It returns the LicenseInfo directly (not a tuple). Inspect the result:
info = license.activate(customer_entered_key)
if info.is_valid and info.is_paid:
# Activated. The lease is cached; the app now works offline until it expires.
show_licensed_ui(info.status_badge) # e.g. "Enterprise" or "Enterprise Trial"
else:
# Surface info.message; the app stays in Free mode.
show_activation_error(info.message) # "All seats for this license are in use.", etc.
- Seats are enforced server-side. Activating more machines than the license allows returns a seat-limit result (
info.status == LicenseStatus.NO_LICENSE, message “All seats for this license are in use.”) and no lease. Re-activating a machine that’s already bound is idempotent — no extra seat is consumed. - Offline grace. If the service is unreachable,
activatefalls back to any still-valid cached lease, so a brief outage doesn’t lock the user out. When the lease nears expiry the app must reach the server again to refresh it. - Revocation takes effect on the next refresh — see below.
activate raises ValueError only for programmer errors: a missing service_url or an empty key.
Deactivate to free a seat
To release this machine’s seat so it can be re-activated elsewhere, call deactivate(key). It binds the same machine id activate uses and posts to the service. Unlike activate, it has no offline fallback — if the service can’t be reached the error propagates, so wrap it:
try:
status = license.deactivate(license_key) # -> "ok" | "not_found"
if status == "ok":
# The seat is freed and the locally cached lease has been deleted.
show_free_ui()
except (RuntimeError, OSError) as ex:
show_error(f"Could not reach the licensing service: {ex}")
On "ok" the SDK deletes the locally cached lease for this product, so a following validate() drops back to Free.
Air-gapped machines: import an offline lease
For a machine that can never reach the service, an operator signs an offline lease for its machine id (dashboard Licenses → offline lease, or POST /admin/licenses/{id}/offline-lease) and delivers the JSON by file. To get that machine’s id to the operator, read it locally:
from keyright import MachineFingerprint
machine_id = MachineFingerprint.current().to_bound_string() # "KRM1:..." — hand this to the operator
print(machine_id)
Then import the delivered lease. import_offline_lease verifies signature, product, machine, and not-expired via the same path a cached lease uses, caches it locally on success, and raises ValueError if the lease is invalid — so handle it:
try:
with open("acme.lease.json", encoding="utf-8") as f:
info = license.import_offline_lease(f.read())
# Cached. Subsequent validate() calls now succeed offline.
show_licensed_ui(info.status_badge)
except ValueError as ex:
# wrong signature / product / machine, or expired
show_error(str(ex))
Node-locking
The SDK derives a stable machine fingerprint from an anchor component (the machine GUID) plus soft signals (host name, OS). node_lock_tolerance (default 1) lets a small hardware or OS change slide without locking the user out, while the anchor must always match. The activation request sends this machine id so seats are counted per device. MachineFingerprint.current().to_bound_string() gives the exact string the server binds a lease to; MachineFingerprint.current().display_id gives a short, human-friendly KR-XXXX-XXXX form for support conversations.
Trials
A trial key activates through the exact same activate path as a paid one. Show the state straight off LicenseInfo:
info = license.validate_info()
if info.is_trial:
badge = info.status_badge # e.g. "Enterprise Trial"
left = info.days_remaining # e.g. 27 -> "27 days left"
show_trial_banner(badge, left)
A trial’s duration is counted from first activation on this machine (the SDK records this locally). When it lapses, validate() returns Free with status == LicenseStatus.EXPIRED and a message naming the trial length. A trial supersedes seamlessly when the customer later activates a paid key. Full flow: Self-service free trials.
Troubleshooting
- Activation says the key wasn’t recognized. The most common cause is a product mismatch: the key was issued for a different product (or tenant) than the
productslug your client is configured with. The server scopes activation by product, so a valid key foracme-appwon’t activate a client initialized withproduct="other-app". Confirm the slug matches the product you issued the key under. - Everything reads as Free. That’s the design — the SDK fails closed on any verification problem instead of raising. Read
info.status(NO_LICENSE,SIGNATURE_INVALID,EXPIRED,MACHINE_MISMATCH,REVOKED,CLOCK_TAMPERED,MALFORMED) andinfo.messageto see which one. ASIGNATURE_INVALIDalmost always means the embeddedpublic_key_base64doesn’t match the tenant that signed the key. validate()“returns two things”. It returns a tuple(LicenseInfo, source)— unpack it (info, source = license.validate()), or callvalidate_info()for just theLicenseInfo.- A time-limited license suddenly won’t validate. If the system clock is moved backward beyond the tolerance (default 24h) on a trial or subscription, the SDK treats it as clock tampering and returns status
CLOCK_TAMPERED. It does not stick — set the correct time and validation recovers on the next call. Perpetual licenses are never subject to this check. ValueErrorfromactivateordeactivate. These raise only for programmer errors — a missingservice_urlor an empty key. Ordinary licensing failures come back as aLicenseInfo, not an exception.
See also
- Getting started — the condensed vendor path and go-live checklist.
- Activation lifecycle — how keys, seats, and leases move through their states.
- Security & leases — the signing model, lease format, and fail-closed guarantees.
- HTTP API reference — the full endpoint map.
- Samples repo — runnable client examples across languages.