SDKs & API wrapper
Two small libraries with no heavy dependencies: one lives inside your software and checks a license; the other lives on your server and issues keys. Both are built to be safe by default, and both only ever talk to the documented public APIs.
Which library do I need?
| Library | Runs where | Secret needed? | Talks to | Languages |
|---|---|---|---|---|
| License client | Inside the app your customers run | No — only your product ID and the public key | /api/v1/verify, /api/v1/activate | Python, Node.js, Go, Rust |
| Automation client | Your own server, bot or checkout webhook | Yes — an API token, kept server-side | /api/automation/v1/* | Python, Node.js (any language can call the REST API directly) |
Neither library can reach the dashboard APIs (/api/account, /api/admin). Those belong to browsers with a signed-in session and are deliberately not scriptable.
Get the files
They are single, readable source files — vendor them into your project rather than depending on a package registry, so you can review exactly what runs in your app.
| Language | License client | Automation client | Needs |
|---|---|---|---|
| Python 3.9+ | license_client.py | automation_client.py | pip install cryptography (license client only) |
| Node.js 18+ | licenseClient.js | automationClient.js | Nothing |
| Go 1.22+ | licenseclient/client.go | — | Standard library only |
| Rust | src/lib.rs + Cargo.toml | — | A few small crates (see Cargo.toml) |
Copy your product ID from the product’s page in the dashboard, and the service’s public key from the guide (or GET /api/v1/public-key). Compile the public key into your app rather than reading it from a file the user can edit.
License client
Python
from license_client import LicenseClient
client = LicenseClient(
base_url="https://license.foliox.sbs",
product="YOUR_PRODUCT_ID", # from the product page in your dashboard
public_key_b64=PUBLIC_KEY, # from the guide page - public, not a secret
)
result = client.check(license_key) # activates on first run, verifies afterwards
if not result.valid:
raise SystemExit(f"Not licensed: {result.reason}")
Node.js
const { LicenseClient } = require('./licenseClient');
const client = new LicenseClient({
baseUrl: 'https://license.foliox.sbs',
product: 'YOUR_PRODUCT_ID',
publicKeyB64: PUBLIC_KEY,
});
const result = await client.check(licenseKey);
if (!result.valid) throw new Error('Not licensed: ' + result.reason);
Go
client, err := licenseclient.New("https://license.foliox.sbs", "YOUR_PRODUCT_ID", publicKey)
if err != nil { log.Fatal(err) } // unsafe or malformed configuration is reported here
result := client.Check(licenseKey)
if !result.Valid { log.Fatalf("not licensed: %s", result.Reason) }
Rust
let client = LicenseClient::new("https://license.foliox.sbs", "YOUR_PRODUCT_ID", PUBLIC_KEY)?;
let result = client.check(license_key);
if !result.valid {
eprintln!("not licensed: {:?}", result.reason);
std::process::exit(1);
}
The first check on a computer activates the key (binding it to that device); every later check verifies it. You never call the two endpoints yourself.
What a check returns
check() never throws for runtime conditions — a wrong key, a dead network and a tampered answer are all just a result with valid false and a reason. Only bad configuration (an http:// address, a malformed public key) is reported as an error, immediately, when the client is created.
| Field | Meaning |
|---|---|
valid | True when the license may be used right now. |
reason | Why not, when valid is false (table below). |
status, expires_at | The key’s state and end date, straight from the signed answer. |
from_offline_cache | True when the server could not be reached and the stored answer was used. |
key_id, checked_at | The key’s numeric ID and the server’s own timestamp for the answer. |
message | A human-readable detail for your logs. Never contains the key. |
| reason | Where it comes from | Suggested reaction |
|---|---|---|
not_found, revoked, expired, suspended, blocked, malformed_key | The server (signed) | Definite: lock the feature and explain. Never retried, never softened by the cache. |
hwid_mismatch | The server (signed) | The key is bound to another computer: point the customer to the license check page to request a device reset. (Device-locked keys only — a public key never returns it.) |
network_error, rate_limited | The client | Could not get an answer (after retries) and there is no valid stored one. Ask the user to check the connection and try again. |
signature_invalid, product_mismatch, invalid_response | The client | The answer was not genuine or not for this product — something is between you and the server. Treat as not licensed. |
clock_skew, clock_rollback | The client | The computer’s clock disagrees with the server (or was set back). Ask the user to correct the system time. |
invalid_request | The client | The server rejected the request as malformed: a bug in your integration, not a licensing verdict. |
result = client.check(license_key)
if result.valid:
start_app() # result.from_offline_cache tells you it ran on the 72-hour grace
elif result.reason in ("network_error", "rate_limited"):
show("Could not reach the license server. Check your connection and try again.")
elif result.reason == "hwid_mismatch":
show("This key is bound to another computer. Request a device reset on the license check page.")
elif result.reason in ("expired", "revoked", "suspended", "not_found", "malformed_key"):
show("This license is not valid: " + result.reason)
else: # signature_invalid, clock_skew, invalid_response ...
show("The license answer could not be trusted (" + result.reason + "). Check the system clock.")
Options
The defaults suit most apps. In Python they are keyword arguments, in Node.js properties of the options object, in Go With… functions passed to New, in Rust fields of ClientOptions.
| Option | Default | What it does |
|---|---|---|
| cache path | ~/.<product>/license_cache.json | Where the signed offline answer is kept (created owner-only, written atomically). |
| timeout | 6 s | Per attempt. |
| retries | 2 (max 5) | Extra attempts on network errors, HTTP 429 and 5xx, with exponential backoff and full jitter (and Retry-After, capped at 5 s). |
| total deadline | 20 s | Hard limit for one check including every retry, so a dead network can’t freeze your app. |
| max response age | 15 min | How far the signed timestamp may be from the local clock. Set to 0 to switch off; then a captured “valid” answer can be replayed for longer. |
| hwid | derived from the machine | Override the device ID (64 lowercase hex characters) if you want your own fingerprint. |
| allow insecure loopback | true | Permits http://localhost for development only. Any other http:// address is always refused. |
Offline grace
If the server can’t be reached, the client uses the last valid signed answer for as long as the server allowed (72 hours from that answer) — and never past the key’s own expiry. The stored answer:
- is re-verified with the public key every time it is read, so editing the file only breaks it;
- is bound to this device, this product and this key (a fingerprint, never the key itself), so copying it to another computer or using it for another key does nothing;
- is deleted the moment the server gives a definite “no”, so a revoked license can’t come back to life offline;
- carries a “last seen” marker, so setting the system clock back to stretch the grace is detected (
clock_rollback).
This keeps honest customers working through an outage. It is a resilience feature, not a security boundary — see what no client can do.
Helpers
Each library also exposes normalize_key and is_valid_key_format (the key’s check character is validated locally, so a typo never costs a request), compute_hwid, and clear_cache for “sign out” flows. The device ID is identical in every language, so switching libraries never un-binds a customer.
Automation client
For your server: create keys after a purchase, look one up, revoke it, or reset its device. It authenticates with an API token and needs the matching permission for each call.
Python
import os
from automation_client import AutomationClient, PlanLimitError, RateLimitedError
api = AutomationClient("https://license.foliox.sbs", token=os.environ["LICENSE_API_TOKEN"])
keys = api.create_keys("YOUR_PRODUCT_ID", count=1, duration_days=365, customer_email="[email protected]")
print(keys[0]["key"], keys[0]["status"])
info = api.get_key(keys[0]["key"]) # status, expires_at, device_bound ...
api.revoke_key(keys[0]["key"]) # stops validating immediately
api.reset_device(keys[0]["key"]) # let the customer move to a new computer
Node.js
const { AutomationClient, PlanLimitError } = require('./automationClient');
const api = new AutomationClient({ baseUrl: 'https://license.foliox.sbs', token: process.env.LICENSE_API_TOKEN });
const [key] = await api.createKeys('YOUR_PRODUCT_ID', { count: 1, durationDays: 365, customerEmail: '[email protected]' });
console.log(key.key, key.status);
const info = await api.getKey(key.key);
await api.revokeKey(key.key);
await api.resetDevice(key.key);
Never ship the token
The token can mint keys. It belongs in an environment variable or secret manager on a server you control — never in an app, installer, browser page or repository. Give each integration its own token with the fewest permissions, an IP allow-list and an expiry.
| Method | Permission | Does |
|---|---|---|
products() | products:read | Lists your products (ID, name, default duration). |
create_keys / createKeys(product, count, duration_days, expires_at, customer_email, notes, key_type) | keys:create | Generates 1–100 keys. With a customer e-mail the buyer is e-mailed the key. key_type / keyType: "device" (default, one device each) or "public" (no device binding — any number of users). |
get_key / getKey(key) | keys:read | Status, expiry, device binding. |
revoke_key / revokeKey(key), restore_key / restoreKey(key) | keys:revoke | Revoke or restore. Safe to repeat. |
reset_device / resetDevice(key) | keys:reset | Unbinds the key from its device. Safe to repeat. A public key has no device: 409 PUBLIC_KEY. |
Errors
Every failure is a typed error carrying the HTTP status, the server’s code and a message — never the token.
| Error | HTTP | Meaning |
|---|---|---|
AuthenticationError | 401 | Missing, wrong, expired or revoked token (or an address the token isn’t allowed from). |
PermissionDeniedError | 403 | INSUFFICIENT_SCOPE (the token lacks the permission) or ACCOUNT_SUSPENDED. |
PlanLimitError | 402 | Your plan’s limit was reached; details names it. |
NotFoundError | 404 | No such product or key in your account. |
ValidationFailedError | 400 | Invalid input; fields says which. |
RateLimitedError | 429 | Too many requests; retry_after is the server’s hint. |
ServerError, NetworkError | 5xx / — | The service had a problem or could not be reached. |
AutomationConfigError | — | Unsafe or malformed configuration or argument, raised before anything is sent. |
Retries, and why creating keys is different
Reads and the repeatable actions (revoke, restore, reset) are retried on network errors, 429 and 5xx. Creating keys is not repeatable — if a request times out you cannot know whether the keys were made — so it is retried only when it provably never arrived (a refused connection, a failed DNS lookup, or a 429 that was refused before processing). Make your own side idempotent, as in this webhook:
# A checkout webhook. Your own database remembers which order already got a key, so a webhook that is
# delivered twice (they always are, sooner or later) can never mint two.
@app.post("/webhooks/order-paid")
def order_paid():
order = verify_and_parse(request) # check the shop's signature first
existing = db.key_for_order(order.id)
if existing:
return {"key": existing}
try:
[created] = api.create_keys("YOUR_PRODUCT_ID", customer_email=order.email, duration_days=order.days, notes=f"order {order.id}")
except PlanLimitError:
alert_me("License plan limit reached") # the buyer has paid: never lose the order
return {"queued": True}, 202
db.save_key_for_order(order.id, created["key"])
return {"key": created["key"]}
What the libraries protect against
| Threat | How it is handled |
|---|---|
| A key or token sent in the clear, or to the wrong host | HTTPS only (loopback excepted for development); certificates always verified, TLS 1.2 or newer; redirects are never followed, so nothing can bounce your key or token elsewhere. |
| A forged or altered answer | Every answer’s Ed25519 signature is verified over the exact payload before any field is believed. The signing key never leaves the server. |
| An answer for another product, or an old one replayed | The signed product must match yours, and the signed timestamp must be recent (15 minutes by default). |
| A revoked or expired license surviving offline | Definite answers delete the stored one; offline use ends at the grace time or the key’s expiry, whichever is first. |
| Copying the cache file to another computer or key | The stored answer is bound to device, product and a key fingerprint; the raw key is never written to disk. |
| Setting the clock back | A “last seen” marker refuses to go backwards (clock_rollback). |
| Hostile or broken servers and networks | Answers over 64 KB and non-JSON or wrongly shaped answers are refused; timeouts, a hard deadline and jittered backoff keep an outage from hanging your app or hammering the service. |
| Token leaks through logs and errors | The token lives only in the Authorization header and is masked in repr/inspect; errors never contain it. |
| Path or request tampering through arguments | Keys and product IDs are validated and URL-encoded before they go into a request. |
| Duplicate keys after a retry | Key creation is never repeated after an ambiguous failure. |
What no client library can do
Someone in full control of the computer running your software can patch it to skip the check, replace the library, emulate a device ID, or freeze the clock. No license check that runs on the user’s machine can prevent that — and we would rather say so than pretend. What the service does give you: a key that only works on one device at a time, instant revocation, answers that can’t be forged, and a full audit trail. To make bypassing worth less:
- Keep value on your server. The strongest protection is that something the user needs (a download, an API, a content pack) is only served to a valid license. Then a patched client gets nothing.
- Check on start-up and periodically (every few hours is plenty), from more than one place in your code, and don’t log or display the reason details to the user in a way that maps out your logic.
- Compile the public key into your binary; obfuscate or strip your build as suits your platform.
- Watch the key’s history in the dashboard: repeated
hwid_mismatchattempts usually mean a shared key — revoke it. - Inside the freshness window a captured answer can be replayed by someone who controls the machine’s network stack; that is inherent to a protocol without a per-request challenge, which is why the window is short.
Integration checklist
- The public key and product ID are in your code; no token is.
- You treat every
valid: false— includingnetwork_errorwith no cache — as not licensed. - Your UI explains
hwid_mismatch,expiredandclock_skewin plain words. - You re-check periodically, not only at launch, and never in a tight loop (the API is rate-limited).
- Your checkout webhook is idempotent and alerts you on
PlanLimitError. - You keep the libraries up to date and re-read this page when the version changes.
Compatibility and upgrading
The wire protocol and the signed format are unchanged, and the device ID is computed exactly as before, so upgrading the library never un-binds an existing customer. What is different in version 2:
- Plain
http://addresses (other than localhost) are refused, andproductmust be a valid product ID — both reported when the client is created. check()returns a result instead of raising, in every language (Python and Node.js used to raise on a bad signature; the result now sayssignature_invalid).- The stored offline answer has a new, device-bound format. The first run after upgrading needs one online check; older cache files are ignored.
- New fields on the result (
key_id,checked_at,message) and new options; existing arguments and field names are unchanged.
How the libraries are tested
Every library runs in our test suite against a real server and against deliberately misbehaving stand-ins: forged signatures, replayed and stale answers, another product’s answer, redirects, oversized and non-JSON responses, servers that hang or fail, a tampered or copied cache, a clock set back, and a certificate that can’t be verified. Each protection listed above is covered by a test.