License keys and device limits
Pick a provider under License Protection in the app’s settings: Built-in license keys, Polar or LemonSqueezy. Your app talks to the same three endpoints whichever provider is selected:
POST https://releaseflow.net/api/{username}/{slug}/license/activatePOST https://releaseflow.net/api/{username}/{slug}/license/validatePOST https://releaseflow.net/api/{username}/{slug}/license/deactivate
All take and return JSON. {username} and {slug} are the same as in the app’s download URLs. No API key is involved: these endpoints are called from shipped apps.
Device limits
A built-in key holds a fixed number of seats, one per device. Devices per key in the app settings is the default for new keys (3 unless changed); each key can override it when you create it on the app’s Licenses page or through the API. Activating the same device_id again reuses its seat. With Polar and LemonSqueezy the provider’s own activation limit applies.
When every seat is taken, activation fails with ACTIVATION_LIMIT_REACHED and a link to the customer portal at https://releaseflow.net/{username}/{slug}/portal, where the customer signs in with their email and frees a device. Your app can also free its own seat with deactivate, unless the key has deactivation turned off.
Activate
Send the key and a stable identifier for the device. Built-in keys require device_id; Polar uses label and LemonSqueezy instance_name as the activation name (device_name works for built-in keys).
curl -X POST https://releaseflow.net/api/{username}/{slug}/license/activate \
-H "Content-Type: application/json" \
-d '{"license_key":"XXXX-XXXX-XXXX-XXXX","device_id":"<stable machine id>","device_name":"Work MacBook"}'{
"valid": true,
"activation_id": "cm...",
"activations_used": 1,
"max_activations": 3,
"message": "License activated successfully"
}HTTP/1.1 422
{
"valid": false,
"error_code": "ACTIVATION_LIMIT_REACHED",
"activations_used": 3,
"max_activations": 3,
"portal_url": "https://releaseflow.net/{username}/{slug}/portal",
"message": "This license is already active on 3 Macs. Deactivate one at ..."
}Store the returned activation_id (LemonSqueezy returns instance_id); validate and deactivate need it.
Validate
Call on launch and periodically. A built-in key is valid only for an active seat on this device: send activation_id, and device_id so a copied activation cannot be reused on another machine. LemonSqueezy expects instance_id.
curl -X POST https://releaseflow.net/api/{username}/{slug}/license/validate \
-H "Content-Type: application/json" \
-d '{"license_key":"XXXX-XXXX-XXXX-XXXX","activation_id":"cm...","device_id":"<stable machine id>"}'The response is {"valid": boolean, "message": string}, plus error_code, seat counts and portal_url for built-in keys. Treat 4xx answers as definitive and 5xx, 429 and network errors as “unknown”: when the license service or the provider is unreachable the endpoints return 503 with retry_after, and the app should keep its last known state.
Deactivate
curl -X POST https://releaseflow.net/api/{username}/{slug}/license/deactivate \
-H "Content-Type: application/json" \
-d '{"license_key":"XXXX-XXXX-XXXX-XXXX","activation_id":"cm..."}'Returns {"success": boolean, "message": string}. activation_id is required for built-in and Polar keys, instance_id for LemonSqueezy.
Error codes (built-in keys)
| error_code | Status | Meaning |
|---|---|---|
ACTIVATION_LIMIT_REACHED | 422 | Every seat is taken. The response includes portal_url. |
DEVICE_ID_REQUIRED | 400 | Activation without device_id (built-in keys). |
LICENSE_NOT_FOUND | 404 | No such key for this app. |
NOT_ACTIVATED | 403 / 404 | Validate: this device has no active seat. Deactivate: no such activation. |
DEVICE_MISMATCH | 403 | activation_id belongs to another device_id. |
LICENSE_REVOKED | 403 | Revoked from the dashboard or after a refund. |
LICENSE_SUSPENDED | 403 | Suspended from the dashboard. |
LICENSE_EXPIRED | 403 | Past the key's expiry date. |
SELF_DEACTIVATION_DISABLED | 403 | The key does not allow deactivating from the app. |
A device blocked from the app’s Devices page gets 403 with "blocked": true on activate and validate.
Swift: ReleaseFlowLicensing
The ReleaseFlowLicensing Swift package (macOS 14+) wraps these endpoints for Mac apps: it stores the key in the Keychain, derives device_id from a per-app hash of the hardware UUID, validates on sync(), handles offline grace, and supports per-machine trials.
import ReleaseFlowLicensing
@MainActor
let licensing = LicenseManager(
configuration: .releaseFlow(
username: "{username}",
appSlug: "{slug}",
storagePrefix: "com.example.myapp",
deviceTracking: true, // must match "Track devices" in the app settings
preActivationDays: 0
)
)
// On launch and on wake:
await licensing.sync()
switch licensing.status {
case .licensed, .licensedOffline, .trial, .preActivation:
break // usable: licensing.status.isUsable == true
case .trialAvailable:
try await licensing.startTrial()
case .blocked(let reason):
showBlocked(reason)
default:
showActivationWindow() // then: try await licensing.activate(key: key)
}
// Free this Mac's seat:
try await licensing.deactivate()With Track devices enabled, the package also calls POST https://releaseflow.net/api/{username}/{slug}/license/machine/register on sync and POST https://releaseflow.net/api/{username}/{slug}/license/machine/trial for startTrial(). Trial length, offline grace and re-validation interval come from the app settings.
TypeScript / Electron
From Electron or any other runtime, call the endpoints with fetch:
const LICENSE_API = "https://releaseflow.net/api/{username}/{slug}/license";
type LicenseResult = {
valid: boolean;
message: string;
activation_id?: string;
error_code?: string;
activations_used?: number;
max_activations?: number;
portal_url?: string;
};
async function license(
action: "activate" | "validate",
body: Record<string, string>,
): Promise<LicenseResult> {
const res = await fetch(`${LICENSE_API}/${action}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
// 5xx/429 = service unavailable: keep the last known state, retry later.
if (res.status >= 500 || res.status === 429) throw new Error("retry later");
return (await res.json()) as LicenseResult;
}
const deviceId = getStableMachineId(); // e.g. a hash of the hardware UUID
const activation = await license("activate", {
license_key: key,
device_id: deviceId,
device_name: os.hostname(),
});
// Store activation.activation_id, then on launch:
await license("validate", {
license_key: key,
activation_id: storedActivationId,
device_id: deviceId,
});Issuing keys
Polar and LemonSqueezy issue keys at checkout. Built-in keys are created by the hosted checkout at https://releaseflow.net/{username}/{slug}/checkout, from the dashboard, or through the REST API with an API key. With the @releaseflow/sdk TypeScript client:
import { configure, client } from "@releaseflow/sdk";
configure({ apiKey: process.env.RELEASEFLOW_API_KEY! });
// Built-in license keys: create one for a customer (returns the full key).
const { data: key } = await client.post({
url: "/apps/{appId}/license-keys",
path: { appId: "cm..." },
body: { customerEmail: "customer@example.com", maxActivations: 3 },
});That is POST https://releaseflow.net/api/rest/apps/{appId}/license-keys with an x-api-key header; all REST endpoints are listed in the API reference.