Activating licenses in your app
Activate a license key from your app, verify it on startup, keep working offline with a signed Ed25519 token and free a device when the user moves.
Your app needs three calls to the license API: activate once when the user enters a key, verify when the app starts, and deactivate when the user moves to another computer. None of them needs an API key. The license key is the credential, so there is no secret to hide inside your app.
All three take JSON and are served under /api/v1/license on your Keygate address. For complete code in a specific framework, see the guides for Electron, Tauri, .NET, Python and Swift.
How it works
For why Keygate uses random keys plus signed tokens instead of keys your app checks by itself, see How license keys work.
- The user types their license key. Your app calls
activatewith the key and an identifier for the device. Keygate records the activation and returns a signed token. - Your app saves the key and the token. On every start it checks the token locally. If the token is still within its check in interval, the app runs without touching the network.
- When the token is due, the app calls
verifyand gets a fresh token, along with the current plan and features. If the license was canceled or revoked in the meantime, the answer says so. - When the user switches computers, the app calls
deactivateand the slot is free for the new machine.
Choosing an identifier
The identifier tells Keygate which device an activation belongs to. It must stay the same for the same machine, or every restart would look like a new device and use up a slot. Good sources are the operating system's machine ID: IOPlatformUUID on macOS, the MachineGuid registry value on Windows, /etc/machine-id on Linux. Hashing the value before you send it is a good habit, since you only need it to be stable, not readable.
If you license by person instead of by machine, send the user's email as the identifier. Activation works on desktop and hybrid products; a SaaS product counts people as seats instead.
Activating
curl -X POST https://licenses.example.com/api/v1/license/activate \
-H "Content-Type: application/json" \
-d '{
"license_key": "KG-ABCD2345-EFGH6789-JKLM2345-NPQR6789",
"identifier": "9f2c41e07b6a",
"label": "Office iMac"
}'| Field | Required | Notes |
|---|---|---|
license_key | Yes | The key the customer entered. |
identifier | Yes | Stable ID of the device, or the user's email. |
identifier_type | No | device (the default) or user, stored with the activation as a label. |
label | No | A name the customer will recognize in the portal, such as the computer's name. |
A successful answer:
{
"success": true,
"data": {
"status": "activated",
"license_id": "4f0c6c1e-2a7b-4d4e-9a55-0d3b7e1f2c11",
"token": "eyJsaWQiOiI0ZjBj...In0.Xk3m9QJ..."
}
}Calling activate again from a device that is already active returns already_activated and does not use another slot, so it is safe to call whenever you are unsure.
Verifying on startup
curl -X POST https://licenses.example.com/api/v1/license/verify \
-H "Content-Type: application/json" \
-d '{"license_key":"KG-ABCD2345-EFGH6789-JKLM2345-NPQR6789","identifier":"9f2c41e07b6a"}'{
"success": true,
"data": {
"status": "active",
"plan_id": "c2b7...",
"plan_name": "Pro",
"valid_until": "2027-10-04T00:00:00Z",
"features": { "export_pdf": true, "projects": "50" },
"grace_days": 7,
"token": "eyJsaWQiOiI0ZjBj...In0.Q8vR2..."
}
}valid_until is missing for licenses that never expire. A perpetual license with an update period also returns updates_until, the last date whose releases it may install.
Working offline
Every activate and verify answer contains a token signed with Ed25519. Your app can check it without a network connection, which is what lets it start on a plane or behind a firewall.
Get the public key
Fetch the verification key once and ship it inside your app. It only changes if you change LICENSE_SIGNING_KEY on the server.
curl https://licenses.example.com/api/v1/license/pubkey
{"success":true,"data":{"algorithm":"ed25519","format":"hex","public_key":"3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29"}}Check the token
A token is two base64url strings joined by a dot: the payload and its signature. The signature covers the payload exactly as it appears in the token, before decoding. In Node.js:
import { createPublicKey, verify } from "node:crypto";
const PUBLIC_KEY_HEX = "3b6a27bc..."; // from /api/v1/license/pubkey
// Wrap the raw 32 byte key in the DER header Node expects for Ed25519.
const publicKey = createPublicKey({
key: Buffer.concat([
Buffer.from("302a300506032b6570032100", "hex"),
Buffer.from(PUBLIC_KEY_HEX, "hex"),
]),
format: "der",
type: "spki",
});
export function readToken(token, identifier) {
const [payload, signature] = token.split(".");
const valid = verify(null, Buffer.from(payload), publicKey, Buffer.from(signature, "base64url"));
if (!valid) return null;
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
if (claims.did !== identifier) return null; // issued to another device
return claims;
}The same in Go:
func readToken(token, identifier string, publicKey ed25519.PublicKey) (map[string]any, bool) {
payload, signature, ok := strings.Cut(token, ".")
if !ok {
return nil, false
}
sig, err := base64.RawURLEncoding.DecodeString(signature)
if err != nil || !ed25519.Verify(publicKey, []byte(payload), sig) {
return nil, false
}
raw, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
return nil, false
}
var claims map[string]any
if json.Unmarshal(raw, &claims) != nil || claims["did"] != identifier {
return nil, false
}
return claims, true
}What the token contains
All times are Unix seconds.
| Field | Meaning |
|---|---|
lid | License ID. pid and pln are the product and plan IDs. |
sts | License status when the token was issued. |
did | The identifier the token was issued to. |
ftr | The plan's features, the same as in the verify answer. |
iat | When the token was issued. exp is when the app should check in again: the check in interval from now, or the end of the license plus its grace days if that comes sooner. |
vun | When the license itself ends. Missing means it never does. |
grc | Grace days after vun. |
upd | End of the update period, for perpetual licenses that have one. Install only releases published before it. |
Putting it together
- If the token is valid and
expis in the future, run normally. - If
exphas passed, call verify. On success, save the new token and run. - If verify cannot reach the server, decide how strict to be. Most apps give a few days of warning before locking features, because flaky networks are far more common than pirates.
- If verify answers 404, the license no longer works on this device.
How long the token lasts is the check in interval on the plan. A week is a reasonable default; shorter intervals make cancellations take effect sooner, longer ones suit apps that are often offline.
Deactivating
curl -X POST https://licenses.example.com/api/v1/license/deactivate \
-H "Content-Type: application/json" \
-d '{"license_key":"KG-ABCD2345-EFGH6789-JKLM2345-NPQR6789","identifier":"9f2c41e07b6a"}'Offer this in your app's settings, for example as a Deactivate this computer button, and delete the stored key and token afterwards. Customers who no longer have the old machine can free the slot themselves in the customer portal, so a lost laptop does not turn into a support ticket.
Handling errors
Errors come back in the same envelope with a code you can branch on. These are the ones an app usually needs to handle:
| Status | Code | What to tell the user |
|---|---|---|
| 404 | LICENSE_NOT_FOUND | The key is wrong, or the license no longer works here. Ask for a key again. |
| 409 | ACTIVATION_LIMIT | All devices are in use. The answer includes details.current and details.max. Point the user to the customer portal to free one. |
| 403 | LICENSE_EXPIRED, LICENSE_SUSPENDED, LICENSE_REVOKED, LICENSE_CANCELED | Returned by activate when the license exists but cannot be used. Show the reason. |
| 404 | FEATURE_NOT_AVAILABLE | The product is a SaaS product, which has no device activations. Use a desktop or hybrid product. |
| 429 | LOCKED_OUT | Too many failed attempts from this network. Wait a little and try again. |
The API reference has the full rules for errors and retries.
Retries
Activation is safe to repeat, but if your app retries after a timeout, send an Idempotency-Key header. Generate one random value when the user starts the activation and send the same value with every retry of it. A retry with the same key gets the first answer back instead of being processed twice. Keys are remembered for 24 hours.
curl -X POST https://licenses.example.com/api/v1/license/activate \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1d8a52-3c0e-4b7a-9f2e-1a4c7d9e0b35" \
-d '{"license_key":"KG-ABCD2345-EFGH6789-JKLM2345-NPQR6789","identifier":"9f2c41e07b6a"}'Last updated October 4, 2026