Foliox License Docs

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?

LibraryRuns whereSecret needed?Talks toLanguages
License clientInside the app your customers runNo — only your product ID and the public key/api/v1/verify, /api/v1/activatePython, Node.js, Go, Rust
Automation clientYour own server, bot or checkout webhookYes — 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.

LanguageLicense clientAutomation clientNeeds
Python 3.9+license_client.pyautomation_client.pypip install cryptography (license client only)
Node.js 18+licenseClient.jsautomationClient.jsNothing
Go 1.22+licenseclient/client.go—Standard library only
Rustsrc/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.

FieldMeaning
validTrue when the license may be used right now.
reasonWhy not, when valid is false (table below).
status, expires_atThe key’s state and end date, straight from the signed answer.
from_offline_cacheTrue when the server could not be reached and the stored answer was used.
key_id, checked_atThe key’s numeric ID and the server’s own timestamp for the answer.
messageA human-readable detail for your logs. Never contains the key.
reasonWhere it comes fromSuggested reaction
not_found, revoked, expired, suspended, blocked, malformed_keyThe server (signed)Definite: lock the feature and explain. Never retried, never softened by the cache.
hwid_mismatchThe 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_limitedThe clientCould 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_responseThe clientThe answer was not genuine or not for this product — something is between you and the server. Treat as not licensed.
clock_skew, clock_rollbackThe clientThe computer’s clock disagrees with the server (or was set back). Ask the user to correct the system time.
invalid_requestThe clientThe 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.

OptionDefaultWhat it does
cache path~/.<product>/license_cache.jsonWhere the signed offline answer is kept (created owner-only, written atomically).
timeout6 sPer attempt.
retries2 (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 deadline20 sHard limit for one check including every retry, so a dead network can’t freeze your app.
max response age15 minHow 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.
hwidderived from the machineOverride the device ID (64 lowercase hex characters) if you want your own fingerprint.
allow insecure loopbacktruePermits 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:

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.

MethodPermissionDoes
products()products:readLists your products (ID, name, default duration).
create_keys / createKeys(product, count, duration_days, expires_at, customer_email, notes, key_type)keys:createGenerates 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:readStatus, expiry, device binding.
revoke_key / revokeKey(key), restore_key / restoreKey(key)keys:revokeRevoke or restore. Safe to repeat.
reset_device / resetDevice(key)keys:resetUnbinds 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.

ErrorHTTPMeaning
AuthenticationError401Missing, wrong, expired or revoked token (or an address the token isn’t allowed from).
PermissionDeniedError403INSUFFICIENT_SCOPE (the token lacks the permission) or ACCOUNT_SUSPENDED.
PlanLimitError402Your plan’s limit was reached; details names it.
NotFoundError404No such product or key in your account.
ValidationFailedError400Invalid input; fields says which.
RateLimitedError429Too many requests; retry_after is the server’s hint.
ServerError, NetworkError5xx / —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

ThreatHow it is handled
A key or token sent in the clear, or to the wrong hostHTTPS 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 answerEvery 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 replayedThe signed product must match yours, and the signed timestamp must be recent (15 minutes by default).
A revoked or expired license surviving offlineDefinite 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 keyThe stored answer is bound to device, product and a key fingerprint; the raw key is never written to disk.
Setting the clock backA “last seen” marker refuses to go backwards (clock_rollback).
Hostile or broken servers and networksAnswers 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 errorsThe token lives only in the Authorization header and is masked in repr/inspect; errors never contain it.
Path or request tampering through argumentsKeys and product IDs are validated and URL-encoded before they go into a request.
Duplicate keys after a retryKey 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:

Integration checklist

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:

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.