← Back to Guides
🖥

C# / .NET Desktop

Add license validation to your WinForms, WPF, MAUI, or console app. One file, a few lines of code, and encrypted offline support out of the box.

.NET 8.0 .NET Framework 4.8 .NET Standard 2.0 SDK v4.0

1 Quick Start

In a hurry? Here is the whole thing. You need two values from your dashboard: a vendor API key and a product key.

Copy the SDK file. Drop UnifiedLicensing.DotNet.v4.0-Full.cs into your project. Visual Studio and dotnet build pick it up automatically.

Create a config and a manager. Two objects, done.

Validate the key. One awaited call tells you if the user is legit.

using UnifiedLicensing.Full;

var config = new UnifiedLicenseConfig
{
    VendorApiKey = "ul_your_api_key",
    ProductKey   = "your-product-key",
    BaseUrl      = "https://api.unifiedlicensing.com/api/v1"
};

var manager = new UnifiedLicenseManager(config);

var result = await manager.ValidateLicenseAsync("XXXX-XXXX-XXXX-XXXX");

if (result.Valid)
{
    Console.WriteLine("Welcome aboard! Tier: " + result.LicenseInfo?.Tier);
}
Heads up ValidateLicenseAsync is async, so call it from an async method. Every desktop framework supports this — see the WinForms, WPF, and console examples below.

2 Setup

Five minutes of prep, then you never think about it again.

Grab your keys

Add the SDK file

Copy UnifiedLicensing.DotNet.v4.0-Full.cs anywhere into your project folder. That's it. There is no NuGet package to install for the SDK itself — it's a single self-contained file.

Why one file? A single .cs file means no dependency hell, easy diffing in source control, and you can read every line that touches the network. Obfuscators love it too.

Dependencies

The SDK targets .NET 8.0, .NET Framework 4.8, and .NET Standard 2.0. What you need depends on your target:

Component.NET 8.0.NET Framework 4.8.NET Standard 2.0
HTTP clientBuilt in (System.Net.Http)NuGet: System.Net.HttpNuGet: System.Net.Http
JSONBuilt in (System.Text.Json)NuGet: Newtonsoft.JsonNuGet: Newtonsoft.Json
AES-256 cryptoBuilt in (AesGcm)Falls back to AES-CBC automaticallyFalls back to AES-CBC automatically

Only on .NET Framework 4.8 or .NET Standard 2.0? Run these once:

dotnet add package System.Net.Http
dotnet add package Newtonsoft.Json

API endpoints

The SDK calls these for you. Handy if you ever want to talk to the API directly (see section 9):

EndpointPurpose
POST/validate-license Check a license key. Body: api_key, product_key, license_key, machine_id, platform
POST/heartbeat Keep an active session alive. Same body as above.
POST/start-trial Begin a trial. Body: api_key, product_key, machine_id, platform
POST/check-trial Check trial status. Adds trial_token to the body.

3 Initialize the SDK

Create the config, create the manager, keep both alive for the lifetime of your app.

AppSetup.cs
using UnifiedLicensing.Full;

public static class AppSetup
{
    // One manager for the whole app. Reuse it.
    public static readonly UnifiedLicenseManager Manager = new(
        new UnifiedLicenseConfig
        {
            // Required
            VendorApiKey = "ul_your_api_key",
            ProductKey   = "your-product-key",

            // Always set this explicitly
            BaseUrl = "https://api.unifiedlicensing.com/api/v1",

            // Behaviour (all optional)
            OfflineMode          = true,
            BackgroundValidation = true,
            Encryption           = true,
            HardwareBinding      = HardwareBindingMode.Loose
        });
}

Every option, what it does, and its default:

PropertyDefaultDescription
VendorApiKeyYour ul_... key. Required.
ProductKeyWhich product this app checks against. Required.
BaseUrl(legacy)Point it at https://api.unifiedlicensing.com/api/v1. Set it explicitly.
OfflineModetrueServe validations from the encrypted local cache when offline.
BackgroundValidationtrueRe-check online quietly in the background when the cache gets stale.
EncryptiontrueAES-256 encrypt the cached license on disk.
SignatureVerificationtrueRSA-verify cached licenses. Needs VendorPublicKey to do anything.
VendorPublicKeynullYour RSA public key (PEM) for offline signature checks. Optional.
HardwareBindingNoneTie licenses to one machine. See section 10.
ClockTamperDetectiontrueCatch users rolling their system clock backward to dodge expiry.
Best practice Build the manager once at startup and inject it where needed. It holds an HttpClient internally — creating one per validation wastes sockets.

4 Validate a License

This is the method you'll call most. Here's the full pattern with error handling and the useful bits of the result.

Licensing.cs
using System;
using System.Threading.Tasks;
using UnifiedLicensing.Full;

public class LicensingService
{
    public async Task<bool> UnlockAppAsync(string licenseKey)
    {
        try
        {
            var result = await AppSetup.Manager.ValidateLicenseAsync(licenseKey);

            if (result.Valid)
            {
                // Where did the answer come from?
                if (result.Cached)
                    Console.WriteLine("Validated from local cache.");

                if (result.InGracePeriod)
                {
                    var daysLeft = TimeSpan.FromMilliseconds(result.GraceRemaining).Days;
                    Console.WriteLine($"Working offline. Grace period: {daysLeft} day(s) left.");
                }

                if (result.Stale)
                    Console.WriteLine("Warning: " + result.Warning);

                // All the license details live here
                var info = result.LicenseInfo;
                Console.WriteLine($"Tier:       {info?.Tier}");
                Console.WriteLine($"Expires:    {info?.ExpiryDate}");
                Console.WriteLine($"Seats used: {info?.CurrentActivations}/{info?.MaxActivations}");

                EnableProFeatures(info);
                return true;
            }

            // Not valid. Tell the user why, plainly.
            Console.WriteLine("Invalid license: " + result.Error);
            Console.WriteLine("Error code:      " + result.ErrorCode);

            if (result.RequiresOnline)
                Console.WriteLine("Connect to the internet and try again.");

            return false;
        }
        catch (Exception ex)
        {
            // Network blew up, storage failed, whatever.
            Console.WriteLine("Validation error: " + ex.Message);
            return false;
        }
    }

    private void EnableProFeatures(LicenseData info)
    {
        // Unlock your features here, optionally gated by tier:
        // if (info?.Tier == "Professional") { ... }
    }
}

The result object

ValidationResult carries everything you need:

PropertyMeaning
ValidThe big one. Can the app run?
ErrorHuman-readable reason when invalid.
ErrorCodeStable machine code: HARDWARE_MISMATCH, QUOTA_EXCEEDED, CLOCK_TAMPERED, etc.
Cachedtrue when answered from the local cache instead of the API.
InGracePeriod / GraceRemainingOffline grace window and milliseconds left in it.
Stale / WarningCache is past its grace window but still usable — show the warning.
RequiresOnlineThis decision can't be made offline. Ask the user to connect.
QuotaExceeded / UpgradeUrlYour own API quota ran out. Surface the upgrade link.
LicenseInfoFull LicenseData: tier, expiry, seats, features, platforms.

React to lifecycle events

The manager raises events mid-session, e.g. when a background re-check finds a revoked license:

manager.OnLicenseRevoked   += (s, r) => LockApp("Your license was revoked.");
manager.OnLicenseExpired   += (s, r) => LockApp("Your license expired. Please renew.");
manager.OnQuotaExceeded    += (s, r) => ShowUpgradeBanner(r.UpgradeUrl);
manager.OnValidationFailed += (s, ex) => Logger.Warn(ex);
manager.OnCacheUsed        += (s, r) => Logger.Info("Offline validation used.");
Don't block the UI thread Never call .Result or .Wait() on ValidateLicenseAsync from a UI thread. That's a classic deadlock. Always await it.

5 WinForms Example

A complete, runnable activation dialog. Paste it into a new WinForms project as Program.cs and hit F5.

Program.cs — complete file
using System;
using System.Drawing;
using System.Windows.Forms;
using UnifiedLicensing.Full;

namespace MyLicensedApp
{
    static class Program
    {
        [STAThread]
        static void Main()
        {
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);
            Application.Run(new ActivationForm());
        }
    }

    public class ActivationForm : Form
    {
        private readonly UnifiedLicenseManager _manager;
        private TextBox _keyBox;
        private Button _goButton;
        private Label _status;

        public ActivationForm()
        {
            _manager = new UnifiedLicenseManager(new UnifiedLicenseConfig
            {
                VendorApiKey = "ul_your_api_key",
                ProductKey   = "your-product-key",
                BaseUrl      = "https://api.unifiedlicensing.com/api/v1"
            });

            Text = "My App - Activate";
            Size = new Size(540, 220);
            FormBorderStyle = FormBorderStyle.FixedDialog;
            MaximizeBox = false;
            StartPosition = FormStartPosition.CenterScreen;

            var prompt = new Label
            {
                Text = "Enter your license key:",
                Location = new Point(20, 18),
                AutoSize = true
            };

            _keyBox = new TextBox
            {
                Location = new Point(20, 44),
                Width = 360
            };

            _goButton = new Button
            {
                Text = "Activate",
                Location = new Point(392, 42),
                Width = 106
            };
            _goButton.Click += OnActivateClick;

            _status = new Label
            {
                Location = new Point(20, 92),
                AutoSize = true,
                ForeColor = Color.DimGray
            };

            Controls.Add(prompt);
            Controls.Add(_keyBox);
            Controls.Add(_goButton);
            Controls.Add(_status);

            AcceptButton = _goButton;
        }

        private async void OnActivateClick(object sender, EventArgs e)
        {
            var key = _keyBox.Text.Trim();
            if (key.Length == 0)
            {
                _status.ForeColor = Color.DimGray;
                _status.Text = "Please enter a license key.";
                return;
            }

            _goButton.Enabled = false;
            _status.ForeColor = Color.DimGray;
            _status.Text = "Validating...";

            try
            {
                var result = await _manager.ValidateLicenseAsync(key);

                if (result.Valid)
                {
                    _status.ForeColor = Color.ForestGreen;
                    _status.Text = result.Cached
                        ? "Activated (offline cache). Welcome back!"
                        : "Activated. Thanks for buying My App!";

                    // Launch your real main window here, then close this one:
                    // Hide();
                    // new MainForm().Show();
                    // Close();
                }
                else
                {
                    _status.ForeColor = Color.Firebrick;
                    _status.Text = "Activation failed: " + (result.Error ?? "Unknown error");

                    if (result.QuotaExceeded && !string.IsNullOrEmpty(result.UpgradeUrl))
                        MessageBox.Show("Validation quota reached. Upgrade: " + result.UpgradeUrl);
                }
            }
            catch (Exception ex)
            {
                _status.ForeColor = Color.Firebrick;
                _status.Text = "Something went wrong: " + ex.Message;
            }
            finally
            {
                _goButton.Enabled = true;
            }
        }
    }
}
MAUI? The same pattern works — swap Label/Button for MAUI controls and put the await inside an async click handler. The licensing code doesn't change at all.

6 WPF Example

Same idea, XAML flavor. Two files: the window markup and the code-behind.

MainWindow.xaml
<Window x:Class="MyLicensedApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="My App - Activate"
        Height="210" Width="500"
        WindowStartupLocation="CenterScreen"
        ResizeMode="NoResize">
    <StackPanel Margin="24">
        <TextBlock Text="Enter your license key:"
                   FontSize="14"
                   Foreground="#EFEAE0"/>

        <StackPanel Orientation="Horizontal" Margin="0,12,0,0">
            <TextBox x:Name="KeyBox"
                     Width="300"
                     Padding="6,5"/>
            <Button x:Name="GoButton"
                    Content="Activate"
                    Padding="16,5"
                    Margin="12,0,0,0"
                    Click="OnActivateClick"/>
        </StackPanel>

        <TextBlock x:Name="StatusText"
                   Margin="0,16,0,0"
                   Foreground="#A9B2C3"
                   TextWrapping="Wrap"/>
    </StackPanel>
</Window>
MainWindow.xaml.cs
using System;
using System.Windows;
using System.Windows.Media;
using UnifiedLicensing.Full;

namespace MyLicensedApp
{
    public partial class MainWindow : Window
    {
        private readonly UnifiedLicenseManager _manager = new(
            new UnifiedLicenseConfig
            {
                VendorApiKey = "ul_your_api_key",
                ProductKey   = "your-product-key",
                BaseUrl      = "https://api.unifiedlicensing.com/api/v1"
            });

        public MainWindow()
        {
            InitializeComponent();
            KeyBox.Focus();
        }

        private async void OnActivateClick(object sender, RoutedEventArgs e)
        {
            var key = KeyBox.Text.Trim();
            if (key.Length == 0)
            {
                StatusText.Text = "Please enter a license key.";
                return;
            }

            GoButton.IsEnabled = false;
            StatusText.Text = "Validating...";

            try
            {
                var result = await _manager.ValidateLicenseAsync(key);

                if (result.Valid)
                {
                    StatusText.Foreground = Brushes.LightGreen;
                    StatusText.Text = "Activated. Tier: " + (result.LicenseInfo?.Tier ?? "Standard");

                    // Open your main window, close this one:
                    // new ShellWindow().Show();
                    // Close();
                }
                else
                {
                    StatusText.Foreground = Brushes.IndianRed;
                    StatusText.Text = "Activation failed: "
                        + (result.Error ?? "Unknown error")
                        + (result.RequiresOnline ? " (connect to the internet)" : "");
                }
            }
            catch (Exception ex)
            {
                StatusText.Foreground = Brushes.IndianRed;
                StatusText.Text = "Something went wrong: " + ex.Message;
            }
            finally
            {
                GoButton.IsEnabled = true;
            }
        }
    }
}

Because the handler is async void on the UI thread, the await resumes on the UI thread automatically. No dispatcher juggling required.

7 Console App

The smallest possible integration. Create it with dotnet new console and replace Program.cs:

dotnet new console -n MyLicensedCli
cd MyLicensedCli
# drop UnifiedLicensing.DotNet.v4.0-Full.cs into the folder
Program.cs — complete file
using System;
using System.Threading.Tasks;
using UnifiedLicensing.Full;

var config = new UnifiedLicenseConfig
{
    VendorApiKey = "ul_your_api_key",
    ProductKey   = "your-product-key",
    BaseUrl      = "https://api.unifiedlicensing.com/api/v1"
};

var manager = new UnifiedLicenseManager(config);

Console.Write("Enter your license key: ");
var key = (Console.ReadLine() ?? "").Trim();

Console.WriteLine("Validating...");
var result = await manager.ValidateLicenseAsync(key);

if (!result.Valid)
{
    Console.ForegroundColor = ConsoleColor.Red;
    Console.WriteLine("Invalid license: " + result.Error);
    if (result.RequiresOnline)
        Console.WriteLine("Connect to the internet and try again.");
    Console.ResetColor();
    return 1;
}

if (result.Cached)
    Console.WriteLine("(validated from offline cache)");

if (result.InGracePeriod)
{
    var days = TimeSpan.FromMilliseconds(result.GraceRemaining).Days;
    Console.WriteLine($"(grace period active, {days} day(s) remaining)");
}

Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("License OK. Tier: " + (result.LicenseInfo?.Tier ?? "Standard"));
Console.ResetColor();

// TODO: your actual app starts here
return 0;

Top-level statements, one await, done. Returning 1 gives CI scripts and launchers a non-zero exit code to react to.

8 Offline Mode

Desktop apps must survive airplanes, hotels, and flaky Wi-Fi. The SDK handles that with an encrypted local cache.

How it works

First successful validation goes online. The response is stored locally, encrypted with AES-256-GCM at %APPDATA%\.ul_cache\license.enc. The encryption key is derived from the machine fingerprint, so the cache is useless if copied to another PC.

Later launches go offline-first. The SDK decrypts the cache, verifies the RSA signature (if you supplied a public key), checks expiry and hardware binding, and answers instantly. No network needed.

Stale caches get refreshed. If the cache is older than 3 days and BackgroundValidation is on, a fresh online check happens quietly in the background while the app keeps running.

Expired licenses enter a grace period. The user isn't locked out the second their subscription lapses — see the windows below.

Grace periods

License typeGrace windowAfter the window
Paid license7 daysThe app keeps working off the stale cache, but result.Stale and result.GracePeriodExpired turn true and result.Warning explains why. Revocations and renewals won't be picked up until a connection returns.
Trial6 hours
// Decide your own policy for stale results:
var result = await manager.ValidateLicenseAsync(key);

if (result.Valid && result.GracePeriodExpired)
{
    // Still let them work, but nag politely:
    banner.Show("Couldn't reach the license server for a while. " +
                "Reconnect soon to keep verifying your license.");
}

Other offline safeguards

9 Trials & Heartbeats

Two related jobs: let people try before they buy, and keep tabs on active sessions.

Good news first

If BackgroundValidation is true (the default), the SDK already pings home periodically and on stale caches. Most apps never need manual heartbeats. The raw calls below are for when you want full control.

Trial flow

  1. Call /start-trial on first launch. You get back a trial_token and an expiry.
  2. Store the token locally (encrypted settings, registry, wherever suits).
  3. On later launches, call /check-trial with the saved token to get remaining days.
  4. When the trial expires, show your purchase screen.
LicenseApi.cs — thin REST helper
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

public class LicenseApi
{
    private static readonly HttpClient Http = new HttpClient();
    private const string Base = "https://api.unifiedlicensing.com/api/v1";

    private readonly string _apiKey;
    private readonly string _productKey;

    public LicenseApi(string apiKey, string productKey)
    {
        _apiKey = apiKey;
        _productKey = productKey;
    }

    private async Task<JsonDocument> PostAsync(string path, object payload)
    {
        var json = JsonSerializer.Serialize(payload);
        using var content = new StringContent(json, Encoding.UTF8, "application/json");

        using var response = await Http.PostAsync(Base + path, content);
        response.EnsureSuccessStatusCode();

        var stream = await response.Content.ReadAsStreamAsync();
        return await JsonDocument.ParseAsync(stream);
    }

    // POST /start-trial
    public Task<JsonDocument> StartTrialAsync(string machineId) =>
        PostAsync("/start-trial", new
        {
            api_key     = _apiKey,
            product_key = _productKey,
            machine_id  = machineId,
            platform    = "desktop"
        });

    // POST /check-trial
    public Task<JsonDocument> CheckTrialAsync(string trialToken, string machineId) =>
        PostAsync("/check-trial", new
        {
            api_key     = _apiKey,
            product_key = _productKey,
            trial_token = trialToken,
            machine_id  = machineId,
            platform    = "desktop"
        });

    // POST /heartbeat
    public Task<JsonDocument> HeartbeatAsync(string licenseKey, string machineId) =>
        PostAsync("/heartbeat", new
        {
            api_key      = _apiKey,
            product_key  = _productKey,
            license_key  = licenseKey,
            machine_id   = machineId,
            platform     = "desktop"
        });
}
Usage — first run vs. later runs
var api = new LicenseApi("ul_your_api_key", "your-product-key");
var machineId = UnifiedLicensing.Full.HardwareFingerprint.Generate();

// ---- First launch: begin the trial -------------------
var started = await api.StartTrialAsync(machineId);
string trialToken = started.RootElement.GetProperty("trial_token").GetString();
// Save trialToken somewhere durable (encrypted settings, etc.)

// ---- Every launch after that: check it ---------------
var status = await api.CheckTrialAsync(trialToken, machineId);
int daysLeft = status.RootElement.GetProperty("days_remaining").GetInt32();

if (daysLeft <= 0)
{
    Console.WriteLine("Trial finished. Time to buy!");
    // show purchase screen...
}

Manual heartbeat

Want your own session tracking? A WinForms timer keeps it simple:

Inside your main form
private System.Windows.Forms.Timer _heartbeat;
private LicenseApi _api;
private string _licenseKey;
private string _machineId;

private void StartHeartbeat()
{
    _heartbeat = new System.Windows.Forms.Timer { Interval = 15 * 60 * 1000 }; // 15 min
    _heartbeat.Tick += async (s, e) =>
    {
        try
        {
            var reply = await _api.HeartbeatAsync(_licenseKey, _machineId);
            // Inspect the reply; lock the app if the license went invalid.
        }
        catch (HttpRequestException)
        {
            // Offline. No drama - retry on the next tick.
        }
    };
    _heartbeat.Start();
}
Don't over-poll Heartbeat every 10–15 minutes is plenty. Hammering the endpoint burns your plan's validation quota and gains nothing.

10 Hardware Binding

Hardware binding ties a license to one physical machine, so one key can't be shared across an office.

What gets fingerprinted

The SDK builds a stable SHA-256 fingerprint from CPU core count, OS platform, machine name, username, and process architecture. It never reads serial numbers or anything invasive — and it's a one-way hash, so nothing identifiable is stored.

The three modes

ModeBehaviorUse it when
None (default)No machine checks. Seat limits still enforced by the server.You count seats, not machines. Simplest for your users.
LooseFingerprints must be ≈70% similar. Survives RAM upgrades, OS reinstalls, renamed PCs.Recommended for desktop apps. Blocks casual sharing without punishing legit upgrades.
StrictExact fingerprint match or rejection.High-value software where sharing must be hard-stopped, and you have support staff for edge cases.
var config = new UnifiedLicenseConfig
{
    VendorApiKey = "ul_your_api_key",
    ProductKey   = "your-product-key",
    BaseUrl      = "https://api.unifiedlicensing.com/api/v1",
    HardwareBinding = HardwareBindingMode.Loose   // recommended
};

When a mismatch happens, validation fails with ErrorCode = "HARDWARE_MISMATCH" and HardwareMatch = false:

var result = await manager.ValidateLicenseAsync(key);

if (!result.Valid && result.ErrorCode == "HARDWARE_MISMATCH")
{
    MessageBox.Show(
        "This license is already active on another computer.\n" +
        "Contact support to move your activation.",
        "License in use",
        MessageBoxButtons.OK,
        MessageBoxIcon.Warning);
}
Support tip Someone buys a new PC? Reset their activation from the dashboard and their next validation rebinds to the new machine. With Loose mode, most upgrades won't even trigger that.

11 Troubleshooting

The errors you're most likely to meet, and what to do about each.

Symptom / codeCauseFix
NO_CACHED_LICENSE, RequiresOnline = true First run happened offline — there's no cache yet. Nothing to salvage locally. Get online once; afterwards offline mode works forever.
QUOTA_EXCEEDED / HTTP 429 Your vendor plan's daily validation limit was hit. The SDK serves cached results meanwhile. Raise the limit on your plan; surface result.UpgradeUrl.
HARDWARE_MISMATCH License bound to a different machine (strict) or too much changed (loose). Reset the activation from the dashboard, or relax the binding mode.
CLOCK_TAMPERED System clock jumped backward versus the uptime timer. User fixes their clock (wrong timezone/BIOS battery often), then reconnects for a fresh check.
INVALID_SIGNATURE Cached license fails RSA verification — usually a mismatched or altered VendorPublicKey. Confirm the PEM matches the key pair your account signed with, or leave it unset while developing.
HTTP 404 on every call Wrong BaseUrl (or the SDK's legacy default is being used). Set BaseUrl = "https://api.unifiedlicensing.com/api/v1" explicitly.
TLS handshake failure on .NET Framework 4.8 OS defaults to older TLS in some configs. Add this once at startup:
ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12;
FileNotFoundException for Newtonsoft.Json Missing NuGet package on net48 / netstandard2.0 targets. dotnet add package Newtonsoft.Json
PlatformNotSupportedException from AesGcm Windows 8.1 or older lacks AES-GCM primitives. Supported targets fall back to AES-CBC automatically. Otherwise require Windows 10+.
UI freezes during validation Calling .Result or .Wait() on the UI thread — classic deadlock. Always await inside an async handler. Never block on tasks.
Silent crash after a click handler Exception thrown outside a try/catch in an async void method. Wrap the whole handler body in try/catch (as in the examples above).
UnauthorizedAccessException writing the cache %APPDATA% locked down (kiosk machines, some AV suites). Run with a normal user profile, or whitelist %APPDATA%\.ul_cache.
Debug logging The SDK writes diagnostics to Console.WriteLine. In a GUI app attach a trace listener, or capture console output during development to see exactly which validation path (online, cache, stale) fired.

Still stuck? Grab a fresh copy of the SDK file from your dashboard and compare configs — nine times out of ten it's a typo in the product key or base URL.