Skip to content
← All posts
· Delta1 Labs LicensingKeyright

Floating Seats with Keyright: Licensing by Simultaneous Use, Not by Machine

Node-locked activation ties a license to a machine. Floating licensing ties it to simultaneous use: you buy 25 seats, and any 25 of your 400 installs can run at once. The mechanism is a lease — a client checks out a seat, heartbeats to keep it, and checks it back in when it exits. A crashed client never checks in, so its lease simply expires and the seat returns to the pool. Here is how to build concurrent-seat licensing on a self-hosted Keyright instance with lease checkout, server-side counting, TTL-based reclaim and graceful all-seats-in-use handling.

Most licensing models answer the question “is this copy allowed to run on this machine?” Floating licensing answers a different one: “are too many copies running right now?” That shift — from counting installs to counting simultaneous use — is what lets a studio with 400 workstations buy 25 seats, because at no moment are more than 25 artists actually in the app. The seats float: whoever launches next takes a free one, and whoever quits hands one back.

The mechanism underneath is a lease. A client checks out a seat before it enables licensed features, keeps the lease alive with a heartbeat while it runs, and checks the seat back in when it exits. The issuing service counts active leases and refuses to hand out more than you bought. The elegant part is what happens when a client doesn’t check in — a crash, a kill -9, a yanked network cable. It simply stops heartbeating, its lease expires, and the seat returns to the pool with no intervention. This article shows how to build that on a self-hosted Keyright instance.

Node-locked versus floating

It is worth being precise about what changes. Keyright’s default activation is node-locked: ActivateAsync binds a license key to a machine fingerprint and consumes a seat for the life of that activation. Install on a second machine and you spend a second seat, regardless of whether the first machine is even powered on. That is exactly right for dedicated, always-the-same-person workstations.

Floating inverts the binding. The license is not tied to a machine at all; it is tied to a count of concurrent leases. You can deploy the app to every machine in the building. What you cannot do is have more of them running the licensed functionality at the same time than you have seats.

Node-locked activationFloating seat
Bound toA machine fingerprintA live lease, for the duration of use
Consumed whenYou activateYou acquire a seat (app launch / feature entry)
Released whenYou deactivateYou release on exit, or the lease TTL expires
Over-installBlocked — each install is a seatAllowed — install widely, run up to N at once
Needs the serviceAt activation / revalidationContinuously, to keep and count leases
Best forDedicated workstations, offline-tolerantShared labs, roaming users, CI pools

The lease lifecycle

A floating seat moves through a small state machine: acquired → renewed (repeatedly) → released, with an expiry path that rejoins “released” whenever a renewal fails to arrive in time.

Acquire seat checked out Active + Heartbeat renew before TTL Released (clean) seat returned Expired (reclaimed) TTL elapsed, no renew every ~TTL/3 exit crash / network loss Clean exit and silent death both end with the seat back in the pool.

The two terminal states are the point: a clean exit and a silent death both return the seat. One is prompt, the other waits out the TTL, but neither leaks.

Acquiring a seat

Initialize the client exactly as for node-locked licensing — the same verify-only public key and the URL of your self-hosted instance — then acquire a seat instead of (or in addition to) activating. AcquireSeatAsync returns a SeatLease: a handle carrying the lease id, its expiry, and the recommended renew interval the server computed from the TTL.

var client = KeyrightClient.Initialize(new KeyrightOptions
{
    Product         = "acme-studio",
    PublicKeyBase64 = config["Keyright:PublicKey"],   // verify-only, not a secret
    ServiceUrl      = config["Keyright:ServiceUrl"],   // your self-hosted instance
});

SeatLease lease;
try
{
    lease = await client.AcquireSeatAsync(userKey);
}
catch (SeatsExhaustedException ex)
{
    // Not an error condition in the app's sense — the pool is simply full.
    return Gate.Deny($"All {ex.MaxSeats} seats are in use. Try again shortly.");
}

// Fail closed: licensed features stay off until we actually hold a seat.
LicenseFeatures.Enable(lease);

Under the hood that is a single POST. The client sends a stable client instance id (one per install, persisted locally) so the service can recognise a re-acquire from the same client and so you can show “who holds a seat” in an admin view. An Idempotency-Key makes the acquire safe to retry through a lost response — a timed-out acquire that actually succeeded replays the same lease rather than burning a second seat:

POST /v1/seats/acquire HTTP/1.1
Host: licensing.example.com
Idempotency-Key: 5f2c-acme-studio-inst-91a7
Content-Type: application/json

{
  "product": "acme-studio",
  "licenseKey": "ACME-STU-7F3K-...",
  "clientInstanceId": "inst-91a7",
  "metadata": { "user": "r.okafor", "host": "lab-ws-14" }
}
{
  "leaseId": "lease_3b9a0c",
  "expiresAt": "2026-11-13T14:32:05Z",
  "ttlSeconds": 120,
  "renewAfterSeconds": 40,
  "seatsInUse": 24,
  "maxSeats": 25
}

When the pool is full the service does not invent a seat; it answers 409 Conflict with a problem+json body (type: .../seats-exhausted, seatsInUse, maxSeats), which the SDK surfaces as SeatsExhaustedException. The count is authoritative on the server — a patched client cannot talk itself into a 26th seat.

Heartbeating to hold the seat

A lease is deliberately short-lived. Holding it means renewing it before expiresAt, which both proves the client is alive and pushes the expiry forward. Renew on a timer at roughly renewAfterSeconds — comfortably inside the TTL so a single missed beat does not drop the seat.

public sealed class SeatKeepAlive : IAsyncDisposable
{
    private readonly KeyrightClient _client;
    private readonly PeriodicTimer _timer;
    private readonly CancellationTokenSource _cts = new();
    private SeatLease _lease;

    public SeatKeepAlive(KeyrightClient client, SeatLease lease)
    {
        _client = client;
        _lease  = lease;
        _timer  = new PeriodicTimer(TimeSpan.FromSeconds(lease.RenewAfterSeconds));
        _ = RunAsync();
    }

    private async Task RunAsync()
    {
        while (await _timer.WaitForNextTickAsync(_cts.Token))
        {
            try
            {
                _lease = await _client.RenewSeatAsync(_lease);   // POST /v1/seats/{id}/renew
            }
            catch (LeaseExpiredException)
            {
                // The server already reclaimed our seat (we were out too long).
                // Stop pretending to be licensed and try to re-acquire from scratch.
                LicenseFeatures.Disable();
                await _client.RecoverSeatAsync(_lease.Product, _cts.Token);
                return;
            }
            catch (TransientException)
            {
                // Network blip: keep the existing lease, let the next tick retry.
                // We still hold the seat until expiresAt regardless.
            }
        }
    }

    public async ValueTask DisposeAsync()
    {
        _cts.Cancel();
        _timer.Dispose();
        await _client.ReleaseSeatAsync(_lease);   // clean check-in on shutdown
    }
}

Two design choices earn their keep here. First, a renew interval well below the TTL (a third is a sensible default) gives you free retries: one dropped renewal is harmless because the lease is still valid until the next tick. Second, a transient failure does not panic and disable features — the client already holds the seat until expiresAt, so it rides out a brief outage and only gives up if the lease genuinely expires.

When a client never checks in

This is the case node-locked licensing handles badly and floating handles for free. If the process crashes, the host is powered off, or the network vanishes, the keep-alive stops. No release is ever sent. On the server the lease simply reaches expiresAt with no renewal, and the next count treats it as gone — the seat is back in the pool. There is no stuck-seat support ticket and no admin cleanup step.

The only tunable is the reclaim delay: the gap between a client dying and its seat becoming available is bounded by the TTL. A short TTL (say 120 seconds) reclaims fast but costs more heartbeat traffic; a longer TTL is quieter but leaves a dead client’s seat parked longer. Pick the TTL from how quickly you need seats recycled, not from how chatty you can tolerate — heartbeats are tiny. For most desktop apps a one-to-three-minute TTL is the sweet spot.

Note the asymmetry with a clean release: ReleaseSeatAsync returns the seat immediately, which is why you should always wire it into shutdown even though expiry would eventually do the same job. Prompt release keeps the pool tight for everyone else; expiry is the safety net, not the primary path.

Reading the pool server-side

Because the service owns the count, you can expose it. The SDK’s GetSeatPoolAsync (or a plain GET) reports live usage, which is useful both for an in-app “23 of 25 seats in use” hint and for an admin dashboard listing current holders.

SeatPool pool = await client.GetSeatPoolAsync("acme-studio");
logger.LogInformation("Floating seats: {InUse}/{Max}", pool.InUse, pool.Max);

foreach (SeatHolder h in pool.Holders)
    logger.LogInformation("  {User}@{Host} since {Since:t}, expires {Exp:t}",
        h.User, h.Host, h.AcquiredAt, h.ExpiresAt);
GET /v1/seats/pool?product=acme-studio HTTP/1.1
Host: licensing.example.com

Surfacing the live count inside the app is the single biggest improvement to the user experience, because it turns “the app won’t start and I don’t know why” into “all seats are busy right now, and I can see when one frees up.”

Graceful “all seats in use”

Fail closed, but fail kindly. A refused acquire is a normal, expected state, not an error — so do not throw a stack trace at the user. Catch SeatsExhaustedException, tell them plainly, and offer to keep trying. Because seats free up the moment someone quits (or their lease expires), a short, bounded retry loop very often lands a seat within seconds.

async Task<SeatLease?> AcquireWithWaitAsync(KeyrightClient client, string key,
                                            TimeSpan budget, CancellationToken ct)
{
    var deadline = DateTimeOffset.UtcNow + budget;
    while (true)
    {
        try
        {
            return await client.AcquireSeatAsync(key, ct);
        }
        catch (SeatsExhaustedException) when (DateTimeOffset.UtcNow < deadline)
        {
            Ui.Status("All seats are in use — waiting for one to free up…");
            await Task.Delay(TimeSpan.FromSeconds(5), ct);   // bounded, with jitter in real code
        }
        catch (SeatsExhaustedException)
        {
            return null;   // gave up within budget; caller shows the final message
        }
    }
}

The product difference between a floating license that users tolerate and one they resent is almost entirely in this handler. A blunt “license error” makes concurrency feel like a defect; a clear “all 25 seats are busy, retrying…” makes it feel like exactly what the customer bought.

Bringing it together

Floating licensing is counting, made durable against clients that disappear without warning. The lease is the unit of counting; the heartbeat keeps it honest; the TTL is what turns a crash into a self-healing event instead of a lost seat.

  • Initialize the Keyright client as usual, then AcquireSeatAsync instead of binding to a machine.
  • Treat a refused acquire (SeatsExhaustedException / 409) as a normal full-pool state and handle it kindly, with a bounded retry.
  • Keep the lease alive with RenewSeatAsync on a timer set to about a third of the TTL; tolerate a single missed beat.
  • Always ReleaseSeatAsync on shutdown for a prompt return; rely on TTL expiry as the safety net for crashes.
  • Let the server own the count — enforcement, maximum and expiry are all authoritative on the issuing service.
  • Choose the TTL from how fast you need dead seats reclaimed; shorter means faster recycling at the cost of a little more heartbeat traffic.
  • Use node-locked activation for dedicated or offline-tolerant machines; use floating for shared, roaming and intermittent use.

Buy 25 seats, deploy to 400 machines, and let the pool sort out who is actually working right now. That is the whole promise of floating licensing — and with lease-based checkout, server-side counting and TTL reclaim, it holds even when a client walks away mid-session and never says goodbye.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.