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.
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);
}
ValidateLicenseAsync is async, so call it from an async method. Every desktop framework supports this — see the WinForms, WPF, and console examples below.
Five minutes of prep, then you never think about it again.
ul_. Keep it private; it lives in your app, not on a server, so restrict its permissions if your plan allows.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.
.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.
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 client | Built in (System.Net.Http) | NuGet: System.Net.Http | NuGet: System.Net.Http |
| JSON | Built in (System.Text.Json) | NuGet: Newtonsoft.Json | NuGet: Newtonsoft.Json |
| AES-256 crypto | Built in (AesGcm) | Falls back to AES-CBC automatically | Falls 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
The SDK calls these for you. Handy if you ever want to talk to the API directly (see section 9):
| Endpoint | Purpose |
|---|---|
| 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. |
Create the config, create the manager, keep both alive for the lifetime of your app.
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:
| Property | Default | Description |
|---|---|---|
VendorApiKey | — | Your ul_... key. Required. |
ProductKey | — | Which product this app checks against. Required. |
BaseUrl | (legacy) | Point it at https://api.unifiedlicensing.com/api/v1. Set it explicitly. |
OfflineMode | true | Serve validations from the encrypted local cache when offline. |
BackgroundValidation | true | Re-check online quietly in the background when the cache gets stale. |
Encryption | true | AES-256 encrypt the cached license on disk. |
SignatureVerification | true | RSA-verify cached licenses. Needs VendorPublicKey to do anything. |
VendorPublicKey | null | Your RSA public key (PEM) for offline signature checks. Optional. |
HardwareBinding | None | Tie licenses to one machine. See section 10. |
ClockTamperDetection | true | Catch users rolling their system clock backward to dodge expiry. |
HttpClient internally — creating one per validation wastes sockets.
This is the method you'll call most. Here's the full pattern with error handling and the useful bits of the result.
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") { ... }
}
}
ValidationResult carries everything you need:
| Property | Meaning |
|---|---|
Valid | The big one. Can the app run? |
Error | Human-readable reason when invalid. |
ErrorCode | Stable machine code: HARDWARE_MISMATCH, QUOTA_EXCEEDED, CLOCK_TAMPERED, etc. |
Cached | true when answered from the local cache instead of the API. |
InGracePeriod / GraceRemaining | Offline grace window and milliseconds left in it. |
Stale / Warning | Cache is past its grace window but still usable — show the warning. |
RequiresOnline | This decision can't be made offline. Ask the user to connect. |
QuotaExceeded / UpgradeUrl | Your own API quota ran out. Surface the upgrade link. |
LicenseInfo | Full LicenseData: tier, expiry, seats, features, platforms. |
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.");
.Result or .Wait() on ValidateLicenseAsync from a UI thread. That's a classic deadlock. Always await it.
A complete, runnable activation dialog. Paste it into a new WinForms project as Program.cs and hit F5.
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;
}
}
}
}
Label/Button for MAUI controls and put the await inside an async click handler. The licensing code doesn't change at all.
Same idea, XAML flavor. Two files: the window markup and the code-behind.
<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>
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.
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
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.
Desktop apps must survive airplanes, hotels, and flaky Wi-Fi. The SDK handles that with an encrypted local cache.
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.
| License type | Grace window | After the window |
|---|---|---|
| Paid license | 7 days | The 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. |
| Trial | 6 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.");
}
await manager.ValidateLicenseAsync(key, force: true).CLOCK_TAMPERED and forces an online revalidation.%APPDATA%\.ul_cache) simply forces the next launch online. Nothing breaks.Two related jobs: let people try before they buy, and keep tabs on active sessions.
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.
/start-trial on first launch. You get back a trial_token and an expiry./check-trial with the saved token to get remaining days.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"
});
}
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...
}
Want your own session tracking? A WinForms timer keeps it simple:
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();
}
Hardware binding ties a license to one physical machine, so one key can't be shared across an office.
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.
| Mode | Behavior | Use it when |
|---|---|---|
None (default) | No machine checks. Seat limits still enforced by the server. | You count seats, not machines. Simplest for your users. |
Loose | Fingerprints must be ≈70% similar. Survives RAM upgrades, OS reinstalls, renamed PCs. | Recommended for desktop apps. Blocks casual sharing without punishing legit upgrades. |
Strict | Exact 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);
}
Loose mode, most upgrades won't even trigger that.
The errors you're most likely to meet, and what to do about each.
| Symptom / code | Cause | Fix |
|---|---|---|
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. |
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.