releaseflow
PricingDocsLog in

License keys and device limits

Docs

  • Overview
  • Quickstart
  • Sparkle (macOS)
  • Electron
  • License keys
  • Channels
  • API reference

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/activate
  • POST https://releaseflow.net/api/{username}/{slug}/license/validate
  • POST 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).

request
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"}'
200
{
  "valid": true,
  "activation_id": "cm...",
  "activations_used": 1,
  "max_activations": 3,
  "message": "License activated successfully"
}
seat limit reached
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.

request
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

request
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_codeStatusMeaning
ACTIVATION_LIMIT_REACHED422Every seat is taken. The response includes portal_url.
DEVICE_ID_REQUIRED400Activation without device_id (built-in keys).
LICENSE_NOT_FOUND404No such key for this app.
NOT_ACTIVATED403 / 404Validate: this device has no active seat. Deactivate: no such activation.
DEVICE_MISMATCH403activation_id belongs to another device_id.
LICENSE_REVOKED403Revoked from the dashboard or after a refund.
LICENSE_SUSPENDED403Suspended from the dashboard.
LICENSE_EXPIRED403Past the key's expiry date.
SELF_DEACTIVATION_DISABLED403The 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.

Swift
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:

TypeScript
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:

@releaseflow/sdk
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.

← ElectronChannels →
releaseflow
PrivacyTermsDocsChangelogSupport