← Back to Guides

Flutter Integration

Add license validation, device binding, trials, and offline support to your Flutter app in under 30 minutes.

1. Quick Start

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
}
Tip: Always send Content-Type: application/json and your vendor API key in the x-vendor-api-key header. Requests without the header are rejected immediately.

API Endpoints

EndpointPurposeReturns
POST /validate-licenseValidate a license keysuccess, license, quota_remaining
POST /check-trialCheck trial statusvalid, expires_at
POST /activate-deviceBind a device to a licensesuccess
POST /deactivate-licenseUnbind a device from a licensesuccess

2. Setup

Before writing any code, gather two credentials:

  1. Vendor API Key — log in to the vendor dashboard and copy your API key from the account page. It starts with ul_.
  2. Product Key — open the Products tab in the dashboard, select (or create) your app, and copy its product key.

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.

3. Dependencies

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

4. License Manager

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;
}

5. Store License Key

Persisting the key

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');
}

Auto-validating before runApp

Validate 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));
}
Tip: Keep startup validation fast by pairing it with a timeout. If your backend is slow, show a splash screen while main() finishes.

6. Device Binding

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}');
}

Activating on first launch

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);
}

Releasing a seat (device transfer)

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');
}

7. Offline Support

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;
  }
}
Tip: Tune the grace period to match your business model. 24 hours suits subscription apps; 7 days is friendlier for one-time purchases.

8. Trial Support

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'),
      ),
    );
  }
}

9. Background Validation

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();
}

10. Error Handling

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);
}
SituationDetectionRecommended UX
No networkSocketException, timeoutUse offline cache / grace period
Invalid licensesuccess: false, HTTP 403Show activation screen, clear stored key
Quota exceededHTTP 429Retry with backoff; surface a friendly message
Server errorHTTP 5xxFall back to cached state, retry later

11. Security

# 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');

12. Complete Example

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.')),
      );
}
Tip: Reuse this gate across screens by placing it above your navigator (home: LicenseGate(child: ...)) so the entire route stack is protected at once.

13. FAQ

Does this work on Flutter Web?

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.

Can I ship to desktop?

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.

Does hot reload or hot restart break licensing?

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.

How often should I re-validate?

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.