Add license validation, device binding, trials, and offline support to your Flutter app in under 30 minutes.
The fastest way to validate a license is a single http.post() call. Add the http package, then:
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<void> main() async {
final response = await http.post(
Uri.parse('https://api.unifiedlicensing.com/api/v1/validate-license'),
headers: {
'Content-Type': 'application/json',
'x-vendor-api-key': 'ul_YOUR_KEY',
},
body: jsonEncode({
'license_key': 'XXXX-XXXX-XXXX-XXXX',
'product_key': 'your-product-key',
'platform': 'mobile',
}),
);
final data = jsonDecode(response.body);
print(data['success']); // true when the license is valid
print(data['quota_remaining']); // how many validations you have left
}
Content-Type: application/json and your vendor API key in the x-vendor-api-key header. Requests without the header are rejected immediately.| Endpoint | Purpose | Returns |
|---|---|---|
POST /validate-license | Validate a license key | success, license, quota_remaining |
POST /check-trial | Check trial status | valid, expires_at |
POST /activate-device | Bind a device to a license | success |
POST /deactivate-license | Unbind a device from a license | success |
Before writing any code, gather two credentials:
ul_.Treat the vendor API key as a secret. Anyone who has it can consume your validation quota, so never ship it in plain text inside your binary — see Security below.
Add these packages to your pubspec.yaml:
dependencies:
flutter:
sdk: flutter
http: ^1.2.0 # REST calls to the licensing API
shared_preferences: ^2.2.0 # persist license key + validation cache
device_info_plus: ^10.0.0 # unique device ID for binding
flutter pub get
Create one reusable class that wraps every endpoint. Everything is async and typed, so the rest of your app stays clean:
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
class ApiException implements Exception {
final int statusCode;
final String message;
ApiException(this.statusCode, this.message);
@override
String toString() => 'ApiException($statusCode): $message';
}
class LicenseManager {
LicenseManager({
this.baseUrl = 'https://api.unifiedlicensing.com/api/v1',
required this.apiKey,
required this.productKey,
});
final String baseUrl;
final String apiKey;
final String productKey;
Map<String, String> get _headers => {
'Content-Type': 'application/json',
'x-vendor-api-key': apiKey,
};
Future<Map<String, dynamic>> _post(
String path,
Map<String, dynamic> body,
) async {
final response = await http
.post(
Uri.parse('$baseUrl/$path'),
headers: _headers,
body: jsonEncode(body),
)
.timeout(const Duration(seconds: 15));
if (response.body.isEmpty) {
throw ApiException(response.statusCode, 'Empty response from server');
}
return jsonDecode(response.body) as Map<String, dynamic>;
}
/// Validates a license key. Throws [ApiException] on failure.
Future<Map<String, dynamic>> validateLicense(String licenseKey) async {
final data = await _post('validate-license', {
'license_key': licenseKey,
'product_key': productKey,
'platform': 'mobile', // mobile | desktop | web
});
if (data['success'] == true) return data;
throw ApiException(_statusFrom(data), 'License rejected');
}
/// Binds [deviceId] to [licenseKey].
Future<void> activateDevice(String licenseKey, String deviceId) async {
final data = await _post('activate-device', {
'license_key': licenseKey,
'product_key': productKey,
'device_id': deviceId,
});
if (data['success'] != true) {
throw ApiException(_statusFrom(data), 'Device activation failed');
}
}
/// Releases a device seat so it can be reused elsewhere.
Future<void> deactivateLicense(String licenseKey, String deviceId) async {
final data = await _post('deactivate-license', {
'license_key': licenseKey,
'product_key': productKey,
'device_id': deviceId,
});
if (data['success'] != true) {
throw ApiException(_statusFrom(data), 'Deactivation failed');
}
}
/// Returns expiry for a valid trial, or null if the token is spent.
Future<DateTime?> checkTrial(String trialToken) async {
final data = await _post('check-trial', {
'trial_token': trialToken,
'product_key': productKey,
});
if (data['valid'] != true) return null;
return DateTime.parse(data['expires_at'] as String);
}
int _statusFrom(Map<String, dynamic> data) =>
(data['status'] as num?)?.toInt() ?? 403;
}
Once validated, save the license key with SharedPreferences so users only activate once:
import 'package:shared_preferences/shared_preferences.dart';
Future<void> saveLicenseKey(String key) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('ul_license_key', key);
}
Future<String?> loadLicenseKey() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString('ul_license_key');
}
Future<void> clearLicenseKey() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('ul_license_key');
}
runAppValidate synchronously during startup so the first frame already knows whether the app is licensed:
import 'package:flutter/material.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
bool activated = false;
final savedKey = await loadLicenseKey();
if (savedKey != null) {
final manager = LicenseManager(
apiKey: const String.fromEnvironment('UL_API_KEY'),
productKey: 'your-product-key',
);
try {
final result = await manager.validateLicense(savedKey);
activated = result['success'] == true;
} on ApiException {
activated = false;
} catch (_) {
// Network unreachable - fall back to cached state (Offline Support)
final prefs = await SharedPreferences.getInstance();
activated = prefs.getBool('ul_last_valid') ?? false;
}
}
runApp(MyApp(activated: activated));
}
main() finishes.To enforce per-seat licenses, bind each installation to a stable device ID using device_info_plus:
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
Future<String> getDeviceId() async {
final plugin = DeviceInfoPlugin();
if (Platform.isAndroid) {
final info = await plugin.androidInfo;
return info.id; // ANDROID_ID - stable per app signing key
}
if (Platform.isIOS) {
final info = await plugin.iosInfo;
return info.identifierForVendor!; // IDFV
}
throw UnsupportedError('Unsupported platform: ${Platform.operatingSystem}');
}
Future<void> activateOnFirstLaunch(LicenseManager manager) async {
final prefs = await SharedPreferences.getInstance();
final licenseKey = prefs.getString('ul_license_key');
if (licenseKey == null) return;
final deviceId = await getDeviceId();
await manager.activateDevice(licenseKey, deviceId);
await prefs.setString('ul_device_id', deviceId);
}
When the user signs out or moves to a new phone, free up the old seat:
Future<void> transferToNewDevice(LicenseManager manager) async {
final prefs = await SharedPreferences.getInstance();
final licenseKey = prefs.getString('ul_license_key');
final oldDeviceId = prefs.getString('ul_device_id');
if (licenseKey != null && oldDeviceId != null) {
await manager.deactivateLicense(licenseKey, oldDeviceId);
}
await clearLicenseKey();
await prefs.remove('ul_device_id');
}
Users will launch your app in airplane mode. Cache the last successful validation and honor a grace period instead of locking people out:
const gracePeriod = Duration(hours: 72);
Future<bool> validateWithOfflineFallback(
LicenseManager manager,
String licenseKey,
) async {
final prefs = await SharedPreferences.getInstance();
try {
final result = await manager.validateLicense(licenseKey);
await prefs.setBool('ul_last_valid', result['success'] == true);
await prefs.setString(
'ul_last_checked',
DateTime.now().toIso8601String(),
);
return result['success'] == true;
} on ApiException {
await prefs.setBool('ul_last_valid', false);
return false;
} catch (_) {
// SocketException / timeout - decide from the cache
final wasValid = prefs.getBool('ul_last_valid') ?? false;
if (!wasValid) return false;
final checked = prefs.getString('ul_last_checked');
if (checked == null) return false;
final elapsed = DateTime.now().difference(DateTime.parse(checked));
return elapsed < gracePeriod;
}
}
Trials are verified with the /check-trial endpoint. Store the expiry locally and render a live countdown:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
Future<DateTime?> refreshTrial(LicenseManager manager) async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString('ul_trial_token');
if (token == null) return null;
final expiresAt = await manager.checkTrial(token);
if (expiresAt == null) return null; // trial consumed or expired
await prefs.setString('ul_trial_expires', expiresAt.toIso8601String());
return expiresAt;
}
class TrialCountdown extends StatefulWidget {
const TrialCountdown({super.key, required this.expiresAt});
final DateTime expiresAt;
@override
State<TrialCountdown> createState() => _TrialCountdownState();
}
class _TrialCountdownState extends State<TrialCountdown> {
late final Timer _timer;
late Duration _remaining;
@override
void initState() {
super.initState();
_remaining = widget.expiresAt.difference(DateTime.now());
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
setState(() {
_remaining = widget.expiresAt.difference(DateTime.now());
if (_remaining.isNegative) _remaining = Duration.zero;
});
});
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
String get _label {
final d = _remaining.inDays;
final h = _remaining.inHours.remainder(24);
final m = _remaining.inMinutes.remainder(60);
final s = _remaining.inSeconds.remainder(60);
return '${d}d ${h}h ${m}m ${s}s';
}
@override
Widget build(BuildContext context) {
if (_remaining == Duration.zero) {
return const Card(
color: Color(0x33C0392B),
child: Padding(
padding: EdgeInsets.all(16),
child: Text('Your trial has ended. Please enter a license key.'),
),
);
}
return Card(
margin: const EdgeInsets.all(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('Free trial remaining: $_label'),
),
);
}
}
Licenses can be revoked while the app is installed. Re-check periodically with a Timer.periodic and react to revocations:
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
class BackgroundValidator {
BackgroundValidator(this._manager, {required this.onRevoked});
final LicenseManager _manager;
final void Function() onRevoked;
Timer? _timer;
void start({Duration interval = const Duration(hours: 6)}) {
_timer?.cancel();
_timer = Timer.periodic(interval, (_) => _revalidate());
}
Future<void> _revalidate() async {
final prefs = await SharedPreferences.getInstance();
final key = prefs.getString('ul_license_key');
if (key == null) return;
try {
await _manager.validateLicense(key);
} on ApiException catch (e) {
if (e.message.contains('rejected')) {
await prefs.setBool('ul_last_valid', false);
onRevoked();
}
} catch (_) {
// Offline - the next cycle will retry.
}
}
void stop() {
_timer?.cancel();
_timer = null;
}
}
// Wire it into your home screen's lifecycle:
@override
void initState() {
super.initState();
validator.start();
}
@override
void dispose() {
validator.stop();
super.dispose();
}
Handle three classes of failure: network errors, rejected licenses, and exhausted quotas.
import 'dart:io';
import 'package:http/http.dart' as http;
Future<ValidationOutcome> robustValidate(
LicenseManager manager,
String key,
) async {
try {
final data = await manager.validateLicense(key);
return ValidationOutcome.valid(data['quota_remaining']);
} on ApiException catch (e) {
if (e.message.contains('Quota') || e.statusCode == 429) {
// Rate limited: back off exponentially, do not hammer the endpoint
return ValidationOutcome.rateLimited();
}
if (e.statusCode == 403) {
return ValidationOutcome.invalid();
}
return ValidationOutcome.serverError(e.statusCode);
} on SocketException {
return ValidationOutcome.offline();
} on http.ClientException {
return ValidationOutcome.offline();
} on TimeoutException {
return ValidationOutcome.offline();
}
}
enum ValidationKind { valid, invalid, rateLimited, offline, serverError }
class ValidationOutcome {
const ValidationOutcome._(this.kind, [this.quotaRemaining]);
final ValidationKind kind;
final int? quotaRemaining;
factory ValidationOutcome.valid(int? q) =>
ValidationOutcome._(ValidationKind.valid, q);
factory ValidationOutcome.invalid() =>
const ValidationOutcome._(ValidationKind.invalid);
factory ValidationOutcome.rateLimited() =>
const ValidationOutcome._(ValidationKind.rateLimited);
factory ValidationOutcome.offline() =>
const ValidationOutcome._(ValidationKind.offline);
factory ValidationOutcome.serverError(int code) =>
const ValidationOutcome._(ValidationKind.serverError);
}
| Situation | Detection | Recommended UX |
|---|---|---|
| No network | SocketException, timeout | Use offline cache / grace period |
| Invalid license | success: false, HTTP 403 | Show activation screen, clear stored key |
| Quota exceeded | HTTP 429 | Retry with backoff; surface a friendly message |
| Server error | HTTP 5xx | Fall back to cached state, retry later |
# Local development
flutter run --dart-define=UL_API_KEY=ul_YOUR_KEY
# Release builds
flutter build apk --release \
--dart-define=UL_API_KEY=$UL_API_KEY \
--obfuscate \
--split-debug-info=build/symbols
// Read it without exposing it in source control:
const apiKey = String.fromEnvironment('UL_API_KEY');
--obfuscate on release builds. It renames Dart symbols, making reverse engineering meaningfully harder. Pair it with --split-debug-info so stack traces remain de-symbolicatable privately.build/symbols lets attackers map your obfuscated traces - exclude it from version control and releases.flutter_secure_storage for high-value apps: it stores tokens in the Android Keystore / iOS Keychain rather than plain preferences.A production-ready MaterialApp with a LicenseGate: users see either an activation screen or your real content, decided by a single bootstrap flow.
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:device_info_plus/device_info_plus.dart';
Future<void> main() => runApp(const UnifiedLicensingApp());
class UnifiedLicensingApp extends StatelessWidget {
const UnifiedLicensingApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'My Licensed App',
theme: ThemeData.dark(useMaterial3: true),
home: const LicenseGate(child: HomeScreen()),
);
}
}
/// Shows [child] only when a valid license is present.
class LicenseGate extends StatefulWidget {
const LicenseGate({super.key, required this.child});
final Widget child;
@override
State<LicenseGate> createState() => _LicenseGateState();
}
enum GateState { loading, needsActivation, active }
class _LicenseGateState extends State<LicenseGate> {
GateState _state = GateState.loading;
final _keyController = TextEditingController();
String? _error;
late final LicenseManager _manager = LicenseManager(
apiKey: const String.fromEnvironment('UL_API_KEY'),
productKey: 'your-product-key',
);
@override
void initState() {
super.initState();
_bootstrap();
}
Future<void> _bootstrap() async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString('ul_license_key');
if (saved == null) {
setState(() => _state = GateState.needsActivation);
return;
}
final ok = await _verify(saved);
setState(() => _state = ok ? GateState.active : GateState.needsActivation);
}
Future<bool> _verify(String key) async {
final prefs = await SharedPreferences.getInstance();
try {
final result = await _manager.validateLicense(key);
if (result['success'] != true) return false;
final deviceId = await getDeviceId();
await _manager.activateDevice(key, deviceId);
await prefs.setString('ul_license_key', key);
await prefs.setBool('ul_last_valid', true);
await prefs.setString(
'ul_last_checked',
DateTime.now().toIso8601String(),
);
return true;
} on ApiException {
return false;
} catch (_) {
// Offline: trust the cached verdict inside the grace window
final wasValid = prefs.getBool('ul_last_valid') ?? false;
final checked = prefs.getString('ul_last_checked');
if (!wasValid || checked == null) return false;
return DateTime.now()
.difference(DateTime.parse(checked)) &
const Duration(hours: 72);
}
}
Future<void> _submit() async {
setState(() => _error = null);
final key = _keyController.text.trim();
if (key.isEmpty) {
setState(() => _error = 'Please enter your license key.');
return;
}
final ok = await _verify(key);
if (!mounted) return;
if (ok) {
setState(() => _state = GateState.active);
} else {
setState(() => _error =
'That license key is not valid. Please double-check and retry.');
}
}
@override
Widget build(BuildContext context) {
switch (_state) {
case GateState.loading:
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
case GateState.needsActivation:
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.key, size: 48),
const SizedBox(height: 16),
const Text('Activate Your Copy',
style: TextStyle(fontSize: 22)),
const SizedBox(height: 24),
TextField(
controller: _keyController,
decoration: const InputDecoration(
labelText: 'License key',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 12),
FilledButton(onPressed: _submit, child: const Text('Activate')),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(_error!,
style: const TextStyle(color: Colors.redAccent)),
),
],
),
),
),
);
case GateState.active:
return widget.child;
}
}
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) => const Scaffold(
body: Center(child: Text('Welcome! Your license is active.')),
);
}
home: LicenseGate(child: ...)) so the entire route stack is protected at once.Yes. The http package works identically on web - send 'platform': 'web' instead of 'mobile'. Note that device_info_plus cannot produce a durable hardware ID in a browser, so generate a UUID on first run, persist it with shared_preferences (or IndexedDB-backed storage), and use that as the device_id.
Yes. Use 'platform': 'desktop'. On Windows, macOS, and Linux, derive a machine fingerprint from platform APIs (e.g. machine GUID on Windows, IOPlatformUUID on macOS) and pass it as device_id - the same activate-device/deactivate-license flow applies.
No. Hot reload preserves state, and hot restart simply re-runs main(), which reloads the persisted key from SharedPreferences. Uninstalling the app clears local storage and requires reactivation - exactly what you want.
A good default is once per app launch plus the periodic timer from Background Validation. Each call consumes quota, so avoid validating more than a few times per session unless the user performs a licensing-sensitive action.