Licence integration guide
How to add LeTissier licence checking to an app. Written to be followed directly — by a person or by an agent working in one of the app repositories.
Service base URL: https://letissier.ie
Signing public key (embed this): 1fca6c21f2eb7963fd646272a731a41a191d3a4cda839e295c5cda67978fcc85
The public key is safe to ship inside a binary. It only verifies; it cannot mint a licence.
Which apps use this
| App | Repository | Language | SDK to copy |
|---|---|---|---|
| Vizz | legofsalmon/vizz | Rust | https://letissier.ie/integrate/sdk/rust/licence.rs |
| Light | legofsalmon/light | Rust | https://letissier.ie/integrate/sdk/rust/licence.rs |
| Datamosh | legofsalmon/ffgl-datamosh | C++17 | https://letissier.ie/integrate/sdk/cpp/licence.hpp + licence.cpp |
| Yewee | legofsalmon/facetrack | Python | https://letissier.ie/integrate/sdk/python/letissier_licence.py |
| Crewbox | legofsalmon/crewbox | TypeScript | https://letissier.ie/integrate/sdk/node/licence.ts |
Product ids used by the API: vizz, light, datamosh, yewee, crewbox.
A machine-readable index of everything on this page is at
https://letissier.ie/integrate/manifest.json, and this guide in plain text is at
https://letissier.ie/integrate/llms.txt.
How the model works
A licence is an Ed25519-signed claim blob: base64url(payload).base64url(signature).
The app embeds the public key above and verifies it offline. There is no
network call in the hot path, and no secret in the binary.
Two dates in the claims do different jobs, and confusing them is the main way an integration goes wrong.
maintUntil — what the customer is entitled to. Compare it against the
build's own release date, compiled in. A build released at or before
maintUntil runs forever. A build released after it is outside the customer's
update window. This is what makes "one year of updates, yours to keep" work
with no server: nothing expires, so a machine that never reconnects keeps
running the version it is entitled to.
exp — the check-in deadline for the current lease. Passing it does not
end the licence. The app checks in and receives a fresh token with a new exp.
Default is 30 days; it is set per licence, so a tour licence may be 90 or 180
days without any code change.
Claims
{
"v": 1,
"key": "LT-V1ZZ-K7M2-9PQR-4XTC",
"product": "vizz",
"edition": "standard",
"customer": "uuid",
"name": "Buyer Name",
"seats": 2,
"maintUntil": 1791536000,
"exp": 1762505600,
"machine": "8b9dd6da2bcf47bdfe7ceb27c2a58680",
"mode": "online",
"iat": 1760000000,
"jti": "uuid"
}
Reject any token whose v is not 1.
Statuses
check() returns exactly one of these. Handle all six.
| Status | Meaning | What the app should do |
|---|---|---|
active | Everything is in order | Run normally |
update_required | Licence valid, but this build is newer than maintUntil | Run an entitled build, or prompt to renew. Do not treat as piracy |
check_in_required | The lease lapsed | Try a heartbeat. Apply your own grace period before restricting |
expired | A trial that ran out | Prompt to buy |
wrong_machine | Token was issued for another machine | Re-activate |
invalid | Bad signature, malformed, or unknown version | Treat as unlicensed |
Fail toward the customer, not against them. A rig mid-show should warn loudly and keep running rather than stop dead.
Machine fingerprint
Produce a stable per-machine string.
| Platform | Source |
|---|---|
| macOS | ioreg -rd1 -c IOPlatformExpertDevice | awk -F'"' '/IOPlatformUUID/{print $4}' |
| Linux | /etc/machine-id |
| Windows | HKLM\SOFTWARE\Microsoft\Cryptography → MachineGuid |
Do not use a MAC address: they change with docks, VPNs and USB adapters, which would burn a seat every time a customer plugs in a dongle.
Hash it exactly once, and not in your app
Send the raw fingerprint as machine. The service hashes it on receipt —
sha256(trimmed_fingerprint) hex, first 32 characters — and stores only
that hash. It never keeps the fingerprint itself.
That same hash is what lands in the token's machine claim, and it is what
check() recomputes locally from the raw fingerprint you hand it. So the one
rule is: the app holds the raw fingerprint and hashes it nowhere.
your app the service
-------- -----------
fingerprint ──POST──▶ machineHash(fingerprint) ──▶ stored, and signed
into the token as
the `machine` claim
check(token, fingerprint)
└─ machineHash(fingerprint) == claims.machine ✓
Hashing before you POST breaks this permanently and silently. Activation
succeeds, the token comes back, and every check from then on returns
wrong_machine — because the token is bound to sha256(sha256(fingerprint))
while the app compares sha256(fingerprint). Releasing the seat and
re-entering the key does not help; it just re-creates the same mismatch.
machineHash is exported by every SDK for one purpose only: displaying a short
machine id in a UI, and comparing against the claim. Not for the wire.
Activate, heartbeat and trial responses all echo the hash the service recorded, so you can assert it in one line and catch this at integration time:
assert response.machine == machineHash(fingerprint)
The bundled LicenceClient in the TypeScript and Python SDKs does this for you
and throws if it disagrees. The Rust and C++ SDKs are verification-only — if
you are writing the HTTP calls yourself, make that assertion yourself.
API
All endpoints are POST, take and return JSON, and allow cross-origin use.
Errors return { "ok": false, "reason": "...", "message": "..." }.
Activate a machine
POST https://letissier.ie/api/licence/activate
{ "key": "LT-V1ZZ-...", "machine": "<fingerprint>", "label": "FOH laptop", "product": "vizz" }
{
"ok": true,
"token": "eyJ2Ijox....abc",
"machine": "8b9dd6da2bcf47bdfe7ceb27c2a58680",
"product": "vizz",
"edition": "standard",
"seats": 2,
"seatsUsed": 1,
"checkInBy": "2026-09-18T00:00:00.000Z",
"maintenanceUntil": "2027-08-21T00:00:00.000Z"
}
machine is the hash the service recorded. Assert it equals your own
machineHash(fingerprint) before storing the token — see
Hash it exactly once. Heartbeat
and trial responses echo it too.
Activating the same machine twice renews its lease rather than consuming a second seat.
Send your own product id as product. A key sold for a different product is
then refused with wrong_product before it takes a seat, and the message
names the product the key is really for. Without product the service
activates any valid key and your app is left to reject the token's product
claim afterwards, with a seat already gone. Check the claim anyway.
Every key goes through this same call: a shop sale, a key issued by hand from
the Studio, and a Studio-issued trial. A trial comes back with
edition: "trial", one seat, and an exp that is the trial's end, so treat
it exactly like the in-app trial below.
Check in
POST https://letissier.ie/api/licence/heartbeat
{ "key": "LT-V1ZZ-...", "machine": "<fingerprint>", "product": "vizz" }
Returns a fresh token and checkInBy. Call on launch and roughly daily.
Persist the returned token — that is what resets the offline window.
Release a seat
POST https://letissier.ie/api/licence/deactivate
{ "key": "LT-V1ZZ-...", "machine": "<fingerprint>" }
Start a trial
POST https://letissier.ie/api/licence/trial
{ "product": "vizz", "email": "vj@example.com", "machine": "<fingerprint>", "name": "VJ" }
Issues and activates a trial in one call — the product's own length, published
as trialDays in the manifest — returning key, token and
expiresAt. One trial per machine per product.
Error reasons
| Reason | HTTP | Meaning |
|---|---|---|
bad_request | 400 | Required field missing |
malformed_key | 400 | Key failed its checksum — likely a typo |
bad_email | 400 | Email did not parse |
unknown_key | 404 | No such licence |
not_activated | 404 | Heartbeat from a machine that never activated |
revoked | 403 | Licence revoked, usually a refund |
expired | 403 | Trial ended |
no_seats | 409 | All seats in use; release one first |
wrong_product | 409 | The key is for another product (only when product is sent) |
trial_already_used | 409 | This machine already had a trial |
server_error | 500 | Retry with backoff |
Treat a network failure as not a licensing failure: fall back to the cached
token and its exp. Never block launch because the service was unreachable.
Integration steps
The same five steps in every language.
- Copy the SDK file(s) into the app.
- Replace
REPLACE_WITH_YOUR_PUBLIC_KEY_HEXwith the key at the top of this page. - Compile in the build's release date as a Unix timestamp. Bake it in — if it is read from the filesystem, anyone can edit it.
- On launch: load the cached token, call
check, act on the status. - In the background: heartbeat when online and persist the returned token.
Store the token wherever the app already keeps user state. It is not a secret — it is signed, and useless on another machine.
Rust — Vizz, Light
Add to Cargo.toml:
[dependencies]
ed25519-dalek = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
base64 = "0.22"
hex = "0.4"
use letissier_licence::{check, Status};
const BUILD_DATE: i64 = 1_761_000_000; // set from CI at build time
const PUBLIC_KEY: &str = "1fca6c21f2eb7963fd646272a731a41a191d3a4cda839e295c5cda67978fcc85";
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs() as i64;
match check(&token, &fingerprint, BUILD_DATE, now, PUBLIC_KEY).status {
Status::Active => start(),
Status::UpdateRequired => warn_update_window_ended(),
Status::CheckInRequired => try_heartbeat_then_grace(),
Status::Expired => prompt_purchase(),
Status::WrongMachine => prompt_reactivate(),
Status::Invalid => prompt_licence_entry(),
}
C++ — Datamosh
Pick a crypto backend at compile time:
c++ -std=c++17 -DLICENCE_BACKEND_SODIUM licence.cpp -lsodium # for shipped plugins
c++ -std=c++17 licence.cpp -lcrypto # OpenSSL
Use libsodium for the FFGL plugin: it links statically, so the plugin has no runtime dependency for a customer to install.
#include "licence.hpp"
constexpr std::int64_t kBuildDate = 1761000000;
const auto verdict = letissier::check(token, fingerprint, kBuildDate, now);
if (verdict.status == letissier::Status::Active) {
enable_plugin();
} else {
show_status(letissier::to_string(verdict.status));
}
A plugin cannot show a dialog mid-render. Check once at load, cache the verdict, and surface the state as a parameter or a watermark rather than blocking the render thread.
Python — Yewee
Requires cryptography (add to requirements.txt).
from letissier_licence import check, ACTIVE, CHECK_IN_REQUIRED
BUILD_DATE = 1761000000
verdict = check(token, fingerprint, BUILD_DATE)
if verdict.status == ACTIVE:
start_tracking()
elif verdict.status == CHECK_IN_REQUIRED:
try_heartbeat()
else:
show_licence_panel(verdict.status)
TypeScript — Crewbox
No dependencies; Node's crypto has Ed25519 built in.
import { check, LicenceClient } from "./licence";
const BUILD_DATE = 1761000000;
const verdict = check({ token, fingerprint, buildDate: BUILD_DATE });
if (verdict.status !== "active") {
const client = new LicenceClient({ fingerprint });
const fresh = await client.heartbeat(key).catch(() => null);
if (fresh) persist(fresh.token);
}
Crewbox runs offline by design, so its check must never gate startup on the network. Verify the cached token, and treat an unreachable service as normal.
Verifying an integration
The SDKs are proven to agree with each other and with the server signer. Test
vectors are published at https://letissier.ie/integrate/vectors.json — signed with a
test key included in that file, not the production key above.
A correct integration reproduces these, using the vectors' own publicKeyHex:
| Input | Expected |
|---|---|
tokens.valid | verifies |
tokens.tampered | fails — payload edited after signing |
tokens.wrongKey | fails — signed by an untrusted key |
tokens.malformed | fails |
fingerprint hashed | equals machineHash |
machine from a live activation | equals machineHash(fingerprint) |
build date after maintUntil | update_required |
now after exp, standard licence | check_in_required |
now after exp, trial | expired |
If your implementation disagrees on any row, it is wrong — the four shipped SDKs all agree on all of them.
Troubleshooting
Every launch says wrong_machine, and re-activating does not fix it.
wrong_machine is decided entirely inside your app — the service has no such
error and cannot cause it. It means the token's machine claim is not what
machineHash(fingerprint) produces locally. Three causes, in order of
likelihood:
- The app pre-hashed the fingerprint before POSTing it, so the service hashed it a second time. Send the raw fingerprint. This one is permanent and survives releasing seats — see Hash it exactly once.
- The token from the activation response was never written over the cached one, so the app keeps checking a token bound to an older machine. Persist the new token before checking it.
- The fingerprint is not stable between activation and check — a different
source, or different case. Hashing trims whitespace but does not
normalise case, and the macOS
IOPlatformUUIDand WindowsMachineGuidare upper case at source. Pick one form and use it everywhere.
To tell them apart, print all three of claims.machine, your
machineHash(fingerprint), and the machine field from the last activation
response. The two that disagree name the cause.
Recovering a licence that has already been activated wrongly. The bad hash
holds a real seat. Release it from the customer portal at
https://letissier.ie/account, then activate again from a build that sends the raw
fingerprint.
Rules
- Send the raw fingerprint to this service and nowhere else, never pre-hashed and never logged.
- Never ship the private signing key. Only the public key belongs in an app.
- Do not invent extra claim fields; the signature covers the exact payload.
- Do not fail closed on a network error.
- Do not use
maintUntilas an expiry date. It is an entitlement boundary — treating it as an expiry would switch off software people have paid to keep.