Skip to content

Use cases

Activate, deactivate & move seats

Bind a key to a machine, free that seat so the customer can move to a new one, and do it for trial, annual and perpetual licenses — online, offline/air-gapped, or vendor-side when a machine is gone — with exactly what your app sees in each case.

Goal

Turn a license key into a running, licensed install on a specific machine — and let the customer (or you) free that seat later so they can move to a different machine without buying another seat. This is the everyday activation lifecycle: activate binds a key to a machine and consumes a seat; deactivate frees it; the same key works across trial, annual (term) and perpetual licenses.

The code below is C# (Keyright.NET), but the SDK shape is identical in the Node, Python and Java packages — see the runnable samples repo.

When to use it

  • Use this for the normal flow: a customer enters their key and your app activates; later they retire a laptop and want that seat back.
  • Use the vendor-side path when a machine is dead, re-imaged, or unreachable and the customer can’t free the seat themselves.
  • Use the offline path for a machine that can’t reach the service at all — you mint a signed lease bound to that one machine.
  • Don’t reach for this to change what a license grants — expiry, seats and renewals are covered under renewal/expiry, and which product versions it covers lives in Sell upgrades & control versions.

The three license shapes

A license carries a tier, a seat count, an optional expiry, and an optional trial flag. Those combine into the three shapes your customers see:

ShapeExpiryUtcIsTrialWhat your app shows
Perpetualnullfalse“Licensed — perpetual”
Annual / terma datefalse“Licensed — expires 31 Dec 2027”
Trial / evaluationa datetrue“Evaluation — expires 31 Dec 2027 (N days left)”

In your code

Your app reads all of this off the LicenseInfo returned by Activate/Validate — you never parse a key string yourself:

var info = await client.ActivateAsync(licenseKey);
if (info.Status != LicenseStatus.Valid)      // fail closed
    return ShowUnlicensed(info.Message);

string banner = info.IsTrial
    ? $"Evaluation — expires {info.ExpiryUtc:d} ({info.DaysRemaining} days left)"
    : info.ExpiryUtc is null
        ? "Licensed — perpetual"
        : $"Licensed — expires {info.ExpiryUtc:d}";

Activate (online)

ActivateAsync(key) sends the machine’s fingerprint and the key to the issuing service. The service checks the seat count, binds the license to this machine, and returns a signed lease that the SDK caches locally. From then on Validate() works offline against that cached lease (see Security & the lease model).

Re-activating the same machine is idempotent — it refreshes the lease and never consumes a second seat. Calling ActivateAsync at every startup (when online) is the recommended pattern: it silently keeps the lease fresh.

The call is the same shape in every SDK, and over raw HTTP it’s a single POST to /v1/activate that returns the signed lease to cache:

.NET

LicenseInfo info = await License.ActivateAsync(customerEnteredKey, ct);
if (info.IsValid && info.IsPaid) ShowLicensedUi(info.StatusBadge);   // lease cached; runs offline until it expires
else ShowActivationError(info.Message);                              // stays Free, e.g. "All seats … are in use."

Node.js

const info = await license.activate(customerEnteredKey);
if (info.isValid && info.isPaid) showLicensedUi(info.statusBadge);
else showActivationError(info.message);

Python

info = license.activate(customer_entered_key)
if info.is_valid and info.is_paid:
    show_licensed_ui(info.status_badge)
else:
    show_activation_error(info.message)

Java

LicenseInfo info = client.activate(customerEnteredKey);
if (info.isValid() && info.isPaid()) showLicensedUi(info.statusBadge());
else showActivationError(info.message());

Any language (raw HTTP)

curl -s -X POST "$BASE/v1/activate" -H "content-type: application/json" \
  -d '{"key":"LIC-…","machineId":"<stable-machine-id>","product":"acme-app"}'
# status:"ok" returns a signed "lease" string — store it verbatim and verify it offline yourself.
# Fail closed on any other status, a non-2xx code, or a lease that doesn't verify.

Trial activation. A trial key activates exactly like a paid key — same call, same seat model — it just comes back with IsTrial == true and an expiry:

var info = await client.ActivateAsync(trialKey);
// info.IsTrial == true, info.ExpiryUtc == the trial end, info.DaysRemaining counts down

Show the evaluation banner and gate whatever you want behind info.IsTrial. When the trial date passes, activation/validation returns LicenseStatus.Expired and you fall back to your unlicensed state. Trials are duration-from-first-activation aware and clock-tamper aware — a customer can’t win back time by turning the clock back (see the security page). Trials are usually self-served from your own site; see Offer a free trial.

Perpetual activation. A perpetual license has no expiry. Activate once; ExpiryUtc is null, so your “perpetual” branch runs. The lease still has a short TTL and is refreshed on the next online activation — the license never expires, but the offline lease is still a short-lived grace token (that’s what keeps a perpetual license from being copied to unlimited machines offline).

Annual / term activation. Identical call; ExpiryUtc is the paid-through date. Use info.IsExpiringSoon() / info.DaysRemaining to nudge customers before renewal. After the date, activation returns Expired. Renewing is a vendor-side change to the license (POST /admin/licenses/{id}/renew) — the customer keeps the same key and simply re-activates to pick up the new expiry.

When there are no seats left. If a new machine tries to activate a license whose seats are all used, the service returns LicenseStatus.SeatLimit — surface a clear “all seats in use — free one first” message. (Again, the same machine re-activating never hits this.)

The Keyright Licenses list for Acme App, each row showing the licensee, edition, a SEATS column with used-of-total counts (Stark Industries 2/3, Wayne Enterprises 0/10, Globex Corporation 1/25, Acme Inc. 1/3), an expiry, and a status pill — active, revoked, expired, and an 'in 10 days' expiring-soon warning on Initech LLC.
The Licenses list shows seats used / total per license, so you can see at a glance which keys have a free seat before a new machine activates.

Move a machine (customer-initiated deactivate)

Deactivate frees this machine’s seat and clears the local lease, so the seat is immediately available elsewhere. This is how a customer moves machines: deactivate on the old machine, then activate on the new one, which takes the freed seat. Every SDK exposes the same call, and over raw HTTP it’s POST /v1/free-seat:

.NET

await License.DeactivateAsync(key);   // frees this machine's seat and clears the cached lease

Node.js

const status = await license.deactivate(key);   // 'ok' | 'not_found'; throws if the service is unreachable

Python

status = license.deactivate(key)   # "ok" | "not_found"; raises if the service is unreachable

Java

String status = client.deactivate(key);   // "ok" | "not_found"; throws IllegalStateException if unreachable

Any language (raw HTTP)

curl -s -X POST "$BASE/v1/free-seat" -H "content-type: application/json" \
  -d '{"key":"LIC-…","machineId":"<stable-machine-id>","product":"acme-app"}'
# -> {"status":"ok"} — delete your cached lease at the same time

Deactivation has no offline fallback in any SDK — releasing a seat is a server-side change, so the call fails (throws/raises) if the service can’t be reached. This works the same for perpetual, annual and trial licenses. After deactivation, Validate() returns NoLicense on that machine.

What you do (vendor side)

Free a seat for the customer (force-deactivate)

When a machine dies, is re-imaged, or a customer can’t get to it, you free the seat from your backend — no client needed.

Dashboard: open the license in the Licenses view, see its active machines, and free the one that’s gone.

The Stark Industries license page in Keyright: a Devices panel showing 2 of 3 seats in use with machine IDs WORKSTATION-STAR and DESKTOP-STARK-01, each marked online with a 'lease renews by' date and a Free seat button; a Details card listing the pro edition, 3 seats (2 in use), perpetual term and entitlements; and Renew, Add seats, License file and Send portal link actions above the tabs.
A license's Devices panel: each bound machine shows a Free seat button, so you can hand a seat back when a machine is dead or re-imaged — and License file mints an offline lease for an air-gapped seat.

API:

curl -X POST "https://keyright.delta1labs.com/admin/licenses/<KEY>/free-seat" \
     -H "X-Admin-Token: $KEYRIGHT_ADMIN_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"machineId":"KRM1:..."}'

POST /admin/licenses/{id}/free-seat is Support+; POST /admin/licenses/{id}/deactivate (Admin+) does the same. The machine’s next online Validate() sees the seat is gone. You can list a license’s machines with GET /admin/licenses/{id}/activations.

Offline / air-gapped activation

For a machine that can’t reach the service at all, the vendor issues a long-lived signed lease bound to that one machine. It works identically for trial and paid licenses.

  1. On the offline machine, read its ID:

    var machineId = MachineFingerprint.Current().ToBoundString();  // KRM1:...

    (or run keyright machine-id). Send that string to the vendor.

  2. The vendor (with connectivity + an admin token) mints a lease for that machine — default TTL 365 days:

    curl -X POST "https://keyright.delta1labs.com/admin/licenses/<KEY>/offline-lease" \
         -H "X-Admin-Token: $KEYRIGHT_ADMIN_TOKEN" \
         -H "Content-Type: application/json" \
         -d '{"machineId":"KRM1:...","days":365}'   > offline-lease.json

    This confirms a seat for that machine (so offline activations still count against the seat limit — you can’t mint unlimited offline leases past the seat count).

  3. Back on the offline machine, import it — no network involved:

    client.ImportOfflineLease(File.ReadAllText("offline-lease.json"));
    var info = client.Validate();   // Valid, fully offline

Because the lease is bound to that machine’s fingerprint, the file is useless on any other machine (it returns MachineMismatch). See the security page for why this holds even offline, and Run offline / air-gapped for the full offline story.

Fully offline, no service at all

If you never want the machine (or the vendor) to touch a network, sign a standalone offline license file with the CLI and ship it with the product:

keyright license --sign --licensee "Acme Corp" --product yourapp --tier pro \
  --private-key yourapp.private.json --expiry 2027-12-31 --out license.json

The client validates it purely offline via Validate() (it’s discovered by path, the KEYRIGHT_LICENSE env var, or the default app-data location). There’s no seat tracking in this mode — it’s the right fit for genuinely air-gapped or embedded deployments.

A note on offline deactivation

There is no client-generated “proof of deactivation” today, so an air-gapped machine can’t hand back its seat by itself. Free it vendor-side (force-deactivate, above) once your backend has connectivity, or issue offline leases with a TTL short enough that a decommissioned machine’s seat returns on its own when the lease lapses. See the samples repo.

Cloud vs Self-hosted

The activation flow is identical in both modes. Only the endpoints change:

  • Runtime calls (/v1/activate, /v1/validate, /v1/free-seat) point at your ServiceUrl — https://keyright.delta1labs.com on Keyright Cloud, your own issuing-service URL when self-hosted.
  • Vendor-side calls (/admin/licenses/...) use the same $BASE and an admin token. On Keyright Cloud the token comes from your dashboard login; self-hosted it’s your tenant/env admin token.
  • Seat binding, leases and the offline paths behave the same either way.

Verify

  1. Activate a key on machine A and confirm LicenseInfo.Status == Valid and the right banner (trial / term / perpetual). The dashboard Licenses → activations list shows machine A.
  2. Activate the same key again on machine A — confirm no second seat is consumed (seat count unchanged).
  3. From a new machine B with the seat full, confirm you get LicenseStatus.SeatLimit.
  4. DeactivateAsync on A (or free-seat vendor-side), then activate on B — B now takes the freed seat and the dashboard reflects the move.

See also