LeTissierCreative Studios
Cue 08IntegrateFor app developers

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

AppRepositoryLanguageSDK to copy
Vizzlegofsalmon/vizzRusthttps://letissier.ie/integrate/sdk/rust/licence.rs
Lightlegofsalmon/lightRusthttps://letissier.ie/integrate/sdk/rust/licence.rs
Datamoshlegofsalmon/ffgl-datamoshC++17https://letissier.ie/integrate/sdk/cpp/licence.hpp + licence.cpp
Yeweelegofsalmon/facetrackPythonhttps://letissier.ie/integrate/sdk/python/letissier_licence.py
Crewboxlegofsalmon/crewboxTypeScripthttps://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.

StatusMeaningWhat the app should do
activeEverything is in orderRun normally
update_requiredLicence valid, but this build is newer than maintUntilRun an entitled build, or prompt to renew. Do not treat as piracy
check_in_requiredThe lease lapsedTry a heartbeat. Apply your own grace period before restricting
expiredA trial that ran outPrompt to buy
wrong_machineToken was issued for another machineRe-activate
invalidBad signature, malformed, or unknown versionTreat 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.

PlatformSource
macOSioreg -rd1 -c IOPlatformExpertDevice | awk -F'"' '/IOPlatformUUID/{print $4}'
Linux/etc/machine-id
WindowsHKLM\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

ReasonHTTPMeaning
bad_request400Required field missing
malformed_key400Key failed its checksum — likely a typo
bad_email400Email did not parse
unknown_key404No such licence
not_activated404Heartbeat from a machine that never activated
revoked403Licence revoked, usually a refund
expired403Trial ended
no_seats409All seats in use; release one first
wrong_product409The key is for another product (only when product is sent)
trial_already_used409This machine already had a trial
server_error500Retry 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.

  1. Copy the SDK file(s) into the app.
  2. Replace REPLACE_WITH_YOUR_PUBLIC_KEY_HEX with the key at the top of this page.
  3. 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.
  4. On launch: load the cached token, call check, act on the status.
  5. 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:

InputExpected
tokens.validverifies
tokens.tamperedfails — payload edited after signing
tokens.wrongKeyfails — signed by an untrusted key
tokens.malformedfails
fingerprint hashedequals machineHash
machine from a live activationequals machineHash(fingerprint)
build date after maintUntilupdate_required
now after exp, standard licencecheck_in_required
now after exp, trialexpired

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:

  1. 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.
  2. 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.
  3. 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 IOPlatformUUID and Windows MachineGuid are 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 maintUntil as an expiry date. It is an entitlement boundary — treating it as an expiry would switch off software people have paid to keep.