Integrate UnifiedLicensing into your iOS and Android apps with the UnifiedLicensing SDK. This guide covers license validation, device binding, offline caching, trial support, and production-ready patterns for React Native.
The simplest possible integration: validate a license key with a single fetch() call to the /validate-license endpoint.
async function validateLicense(licenseKey: string) {
const response = await fetch('https://api.unifiedlicensing.com/api/v1/validate-license', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-vendor-api-key': 'ul_your_vendor_api_key',
},
body: JSON.stringify({
license_key: licenseKey,
product_key: 'my-product-key',
platform: 'mobile',
}),
});
const data = await response.json();
if (data.success) {
console.log('License valid!', data.license);
console.log('Quota remaining:', data.quota_remaining);
} else {
console.log('Invalid license');
}
}
platform: "mobile" from React Native apps so quota tracking is attributed correctly per platform.Before writing any code, you need two credentials:
ul_.Get both from the vendor dashboard: Open Vendor Dashboard
// src/licensing/config.ts export const API_BASE_URL = 'https://api.unifiedlicensing.com/api/v1'; export const PRODUCT_KEY = 'my-product-key'; // In production, load this securely (see Security section) export const VENDOR_API_KEY = process.env.UNIFIEDLICENSING_API_KEY ?? '';
| Credential | Where to Find It | Format |
|---|---|---|
| Vendor API Key | Dashboard → Settings tab | ul_... |
| Product Key | Dashboard → Products tab | Product slug |
Install the two recommended libraries for device binding and offline persistence:
npm install react-native-device-info @react-native-async-storage/async-storage cd ios && pod install # for iOS (bare workflow only)
react-native-device-info — Provides a stable unique device ID (DeviceInfo.getUniqueId()) used as device_id for activation/deactivation.@react-native-async-storage/async-storage — Persists the license key and cached validation results across app launches.getUniqueId() returns the ANDROID_ID, which persists per app installs on that device. On iOS it returns the identifierForVendor, which changes if all of your apps are uninstalled. Plan your reactivation UX accordingly.Create a reusable custom hook that manages validation state, loading, errors, and offline fallback:
// src/licensing/useLicense.ts
import { useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { API_BASE_URL, PRODUCT_KEY, VENDOR_API_KEY } from './config';
export type CachedLicense = {
license_key: string;
validated_at: number;
plan_features?: Record<string, unknown>;
};
type LicenseState = {
loading: boolean;
isValid: boolean;
error: string | null;
licenseKey: string | null;
planFeatures: Record<string, unknown> | null;
};
const CACHE_KEY = '@unifiedlicensing/cached_license';
const KEY_STORAGE = '@unifiedlicensing/license_key';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // re-validate after 24 hours
export function useLicense() {
const [state, setState] = useState<LicenseState>({
loading: false,
isValid: false,
error: null,
licenseKey: null,
planFeatures: null,
});
const update = (patch: Partial<LicenseState>) =>
setState((prev) => ({ ...prev, ...patch }));
const validate = useCallback(async (licenseKey: string): Promise<boolean> => {
update({ loading: true, error: null });
try {
const response = await fetch(`${API_BASE_URL}/validate-license`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-vendor-api-key': VENDOR_API_KEY,
},
body: JSON.stringify({
license_key: licenseKey,
product_key: PRODUCT_KEY,
platform: 'mobile',
}),
});
if (response.status === 429) {
update({ loading: false, error: 'Too many validation attempts. Please try again later.' });
return false;
}
const data = await response.json();
if (!data.success) {
await AsyncStorage.removeItem(CACHE_KEY);
update({ loading: false, isValid: false, error: 'Invalid or expired license key.' });
return false;
}
// Persist key + cache successful validation
await AsyncStorage.multiSet([
[KEY_STORAGE, licenseKey],
[CACHE_KEY, JSON.stringify({
license_key: licenseKey,
validated_at: Date.now(),
plan_features: data.plan_features,
} as CachedLicense)],
]);
update({
loading: false,
isValid: true,
licenseKey,
planFeatures: data.plan_features ?? null,
});
return true;
} catch (e) {
return handleOfflineFallback(e as Error);
}
}, []);
const handleOfflineFallback = async (err: Error): Promise<boolean> => {
try {
const cachedRaw = await AsyncStorage.getItem(CACHE_KEY);
if (cachedRaw) {
const cached: CachedLicense = JSON.parse(cachedRaw);
const isFresh = Date.now() - cached.validated_at < CACHE_TTL_MS;
if (isFresh) {
update({
loading: false,
isValid: true,
licenseKey: cached.license_key,
planFeatures: (cached.plan_features as Record<string, unknown>) ?? null,
error: null,
});
return true;
}
}
} catch (_) {}
update({ loading: false, isValid: false, error: `Network error: ${err.message}` });
return false;
};
// Auto-validate stored key on mount
useEffect(() => {
(async () => {
const stored = await AsyncStorage.getItem(KEY_STORAGE);
if (stored) {
await validate(stored);
}
})();
}, [validate]);
const deactivate = useCallback(async (): Promise<void> => {
const deviceId = await getDeviceId();
if (!state.licenseKey) return;
await fetch(`${API_BASE_URL}/deactivate-license`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-vendor-api-key': VENDOR_API_KEY,
},
body: JSON.stringify({
license_key: state.licenseKey,
product_key: PRODUCT_KEY,
device_id: deviceId,
}),
});
await AsyncStorage.multiRemove([KEY_STORAGE, CACHE_KEY]);
update({ isValid: false, licenseKey: null });
}, [state.licenseKey]);
return { ...state, validate, deactivate };
}
export async function getDeviceId(): Promise<string> {
const DeviceInfo = require('react-native-device-info').default;
return DeviceInfo.getUniqueId();
}
A complete activation screen with text input, validation button, loading spinner, and success/error feedback:
// src/screens/LicenseScreen.tsx
import React, { useState } from 'react';
import {
View, Text, TextInput, TouchableOpacity,
ActivityIndicator, StyleSheet, Alert,
} from 'react-native';
import { useLicense } from '../licensing/useLicense';
type Props = {
onActivated: () => void;
};
export default function LicenseScreen({ onActivated }: Props) {
const { validate, deactivate, isValid, loading, error } = useLicense();
const [key, setKey] = useState('');
const handleValidate = async () => {
if (!key.trim()) {
Alert.alert('Missing Key', 'Please enter your license key.');
return;
}
const ok = await validate(key.trim());
if (ok) onActivated();
};
if (isValid) {
return (
<View style={styles.container}>
<Text style={styles.title}>✓ License Active</Text>
<TouchableOpacity style={styles.buttonSecondary} onPress={deactivate}>
<Text style={styles.buttonText}>Deactivate This Device</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.title}>Activate Your License</Text>
<TextInput
style={styles.input}
placeholder="XXXXX-XXXXX-XXXXX-XXXXX"
placeholderTextColor="#667"
autoCapitalize="characters"
autoCorrect={false}
value={key}
onChangeText={setKey}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<TouchableOpacity
style={[styles.buttonPrimary, loading && styles.buttonDisabled]}
onPress={handleValidate}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Activate</Text>
)}
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 24, backgroundColor: '#0E1B2C' },
title: { fontSize: 22, fontWeight: '700', color: '#EFEAE0', marginBottom: 16, textAlign: 'center' },
input: {
borderWidth: 1, borderColor: '#C7A34C', borderRadius: 8,
padding: 14, color: '#EFEAE0', marginBottom: 12, textAlign: 'center',
letterSpacing: 2,
},
buttonPrimary: { backgroundColor: '#C7A34C', borderRadius: 8, padding: 14, alignItems: 'center' },
buttonSecondary: { backgroundColor: '#14263B', borderRadius: 8, padding: 14, alignItems: 'center' },
buttonDisabled: { opacity: 0.6 },
buttonText: { color: '#081220', fontWeight: '600' },
error: { color: '#e05c5c', marginBottom: 12, textAlign: 'center' },
});
Persist the license key with AsyncStorage so users only activate once per device. The hook above already handles auto-validation on launch via its useEffect, but here is the storage logic isolated for clarity:
// src/licensing/storage.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
const KEY_STORAGE = '@unifiedlicensing/license_key';
export async function saveLicenseKey(key: string): Promise<void> {
await AsyncStorage.setItem(KEY_STORAGE, key);
}
export async function loadLicenseKey(): Promise<string | null> {
return AsyncStorage.getItem(KEY_STORAGE);
}
export async function clearLicense(): Promise<void> {
await AsyncStorage.removeItem(KEY_STORAGE);
}
// Usage at app startup:
export async function bootstrapLicensing(onValid: () => void, onInvalid: () => void) {
const key = await loadLicenseKey();
if (!key) {
onInvalid(); // show LicenseScreen
return;
}
// Re-validate silently against the server
const response = await fetch('https://api.unifiedlicensing.com/api/v1/validate-license', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-vendor-api-key': globalThis.__VENDOR_API_KEY__,
},
body: JSON.stringify({ license_key: key, product_key: 'my-product-key', platform: 'mobile' }),
});
const data = await response.json();
if (data.success) {
onValid();
} else {
await clearLicense();
onInvalid();
}
}
Bind each installation to the license by calling /activate-device. Use DeviceInfo.getUniqueId() as the device_id:
// src/licensing/device.ts
import DeviceInfo from 'react-native-device-info';
import { Platform } from 'react-native';
import { API_BASE_URL, PRODUCT_KEY, VENDOR_API_KEY } from './config';
export async function activateDevice(licenseKey: string): Promise<{ success: boolean; message?: string }> {
const deviceId = await DeviceInfo.getUniqueId();
const response = await fetch(`${API_BASE_URL}/activate-device`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-vendor-api-key': VENDOR_API_KEY,
},
body: JSON.stringify({
license_key: licenseKey,
product_key: PRODUCT_KEY,
device_id: `${deviceId}`,
}),
});
const data = await response.json();
if (!response.ok || !data.success) {
return { success: false, message: data.message ?? 'Device activation failed.' };
}
return { success: true };
}
export async function deactivateDevice(licenseKey: string): Promise<void> {
const deviceId = await DeviceInfo.getUniqueId();
await fetch(`${API_BASE_URL}/deactivate-license`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-vendor-api-key': VENDOR_API_KEY,
},
body: JSON.stringify({
license_key: licenseKey,
product_key: PRODUCT_KEY,
device_id: `${deviceId}`,
}),
});
}
export function describeCurrentDevice() {
return `${Platform.OS === 'ios' ? 'iOS' : 'Android'} · ${DeviceInfo.getModel()}`;
}
Recommended flow: validate first, then activate. Only store the key locally after both succeed:
const isValid = await useLicense().validate(userInputKey);
if (isValid) {
const activation = await activateDevice(userInputKey);
if (activation.success) {
await saveLicenseKey(userInputKey); // safe to persist now
}
}
Users expect apps to work in airplane mode or on unreliable connections. Cache successful validations and fall back gracefully:
// src/licensing/offline.ts
import NetInfo from '@react-native-community/netinfo';
import AsyncStorage from '@react-native-async-storage/async-storage';
const CACHE_KEY = '@unifiedlicensing/cached_license';
const MAX_OFFLINE_DAYS = 7;
export type OfflineCache = {
license_key: string;
validated_at: number;
};
export async function cacheValidation(licenseKey: string): Promise<void> {
const entry: OfflineCache = { license_key: licenseKey, validated_at: Date.now() };
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(entry));
}
export async function getCachedValidation(): Promise<OfflineCache | null> {
const raw = await AsyncStorage.getItem(CACHE_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as OfflineCache;
} catch {
return null;
}
}
export function isCacheFresh(cached: OfflineCache): boolean {
const ageMs = Date.now() - cached.validated_at;
return ageMs < MAX_OFFLINE_DAYS * 24 * 60 * 60 * 1000;
}
export async function checkConnectivity(): Promise<boolean> {
const net = await NetInfo.fetch();
return Boolean(net.isConnected);
}
// Combined gate used at startup
export async function canAccessApp(
onlineValidate: () => Promise<boolean>
): Promise<'valid' | 'invalid' | 'grace'> {
const online = await checkConnectivity();
if (online) {
return (await onlineValidate()) ? 'valid' : 'invalid';
}
const cached = await getCachedValidation();
if (cached && isCacheFresh(cached)) {
return 'grace'; // allow access within grace window
}
return 'invalid'; // offline too long, no fresh cache
}
| Scenario | Behavior |
|---|---|
| Online, valid license | Server validates, cache refreshed |
| Online, invalid license | Access denied, local cache cleared |
| Offline, fresh cache (< 7 days) | Grace period access allowed |
| Offline, stale/no cache | Access denied until connection restored |
Check trial status with the /check-trial endpoint using a trial_token issued when the user started their trial:
// src/licensing/trial.ts
import { API_BASE_URL, PRODUCT_KEY, VENDOR_API_KEY } from './config';
export type TrialStatus = {
success: boolean;
trial_active?: boolean;
days_remaining?: number;
expired?: boolean;
};
export async function checkTrial(trialToken: string): Promise<TrialStatus> {
const response = await fetch(`${API_BASE_URL}/check-trial`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-vendor-api-key': VENDOR_API_KEY,
},
body: JSON.stringify({
trial_token: trialToken,
product_key: PRODUCT_KEY,
}),
});
return response.json();
}
// src/components/TrialBanner.tsx
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { checkTrial } from '../licensing/trial';
export default function TrialBanner({ trialToken }: { trialToken: string }) {
const [status, setStatus] = useState<{ active: boolean; days: number } | null>(null);
useEffect(() => {
let mounted = true;
(async () => {
const result = await checkTrial(trialToken);
if (mounted) {
setStatus({
active: Boolean(result.trial_active),
days: result.days_remaining ?? 0,
});
}
})();
return () => { mounted = false; };
}, [trialToken]);
if (!status?.active) return null;
return (
<View style={styles.banner}>
<Text style={styles.text}>
Free trial · {status.days} day{status.days === 1 ? '' : 's'} remaining.
Upgrade any time!
</Text>
</View>
);
}
const styles = StyleSheet.create({
banner: { backgroundColor: '#3FA76B', padding: 10 },
text: { color: '#081220', textAlign: 'center', fontWeight: '600' },
});
expired: true), route users back to LicenseScreen and clear the trial token so they cannot reuse it.Robust error handling distinguishes network failures, rate limits, and invalid credentials:
// src/licensing/errors.ts
import { Alert } from 'react-native';
export type LicensingError =
| { kind: 'network'; message: string }
| { kind: 'quota'; retryAfterSeconds: number }
| { kind: 'invalid_license'; message: string }
| { kind: 'unknown'; status: number };
export async function performValidation(body: object): Promise<
{ ok: true; data: any } | { ok: false; error: LicensingError }
> {
try {
const response = await fetch('https://api.unifiedlicensing.com/api/v1/validate-license', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-vendor-api-key': globalThis.__VENDOR_API_KEY__ ?? '',
},
body: JSON.stringify(body),
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get('Retry-After') ?? '60');
return { ok: false, error: { kind: 'quota', retryAfterSeconds: retryAfter } };
}
const data = await response.json();
if (!response.ok) {
return { ok: false, error: { kind: 'unknown', status: response.status } };
}
if (!data.success) {
return { ok: false, error: { kind: 'invalid_license', message: data.message ?? 'Invalid license.' } };
}
return { ok: true, data };
} catch (err) {
return {
ok: false,
error: { kind: 'network', message: err instanceof Error ? err.message : 'Connection failed' },
};
}
}
export function showErrorToUser(error: LicensingError): void {
switch (error.kind) {
case 'network':
Alert.alert('No Connection', 'Could not reach the licensing server. Check your internet and try again.');
break;
case 'quota':
Alert.alert('Slow Down', `Too many attempts. Try again in ${error.retryAfterSeconds}s.`);
break;
case 'invalid_license':
Alert.alert('Invalid License', error.message);
break;
case 'unknown':
Alert.alert('Unexpected Error', `Server returned ${error.status}. Please contact support.`);
break;
}
}
| Error | Cause | User Action |
|---|---|---|
| Network failure | No connectivity / timeout | Offer retry + offline grace mode |
| HTTP 429 | Rate limit exceeded | Wait per Retry-After header |
success: false | Invalid/expired/revoked key | Show activation screen again |
| Other HTTP codes | Server issue or bad request | Generic support message |
Mobile apps ship to hostile environments. Harden your integration:
react-native-config) and consider proxying calls through your own backend where possible.react-native-obfuscating-transformer to raise the cost of reverse engineering:# Install the obfuscation transformer
npm install --save-dev react-native-obfuscating-transformer
# metro.config.js
const obfuscatingTransformer = require('react-native-obfuscating-transformer');
module.exports = {
transformer: {
getTransformOptions: async () => ({
transform: { experimentalImportSupport: false, inlineRequires: false },
}),
},
// Wrap the default transformer
...(obfuscatingTransformer({
upstreamTransformer: require('metro-react-native-babel-transformer'),
obfuscatorOptions: {
compact: true,
controlFlowFlattening: true,
deadCodeInjection: true,
stringArrayEncoding: ['rc4'],
},
})),
};
react-native-ssl-pinning or OkHttp interceptors on Android) to prevent man-in-the-middle attacks on licensing traffic.react-native-keychain (iOS Keychain / Android Keystore) over AsyncStorage for anything sensitive like tokens.x-vendor-api-key header authenticates every call.A full App.tsx wiring everything together with context, gating, and graceful states:
// App.tsx
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { View, Text, ActivityIndicator, StyleSheet } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useLicense } from './src/licensing/useLicense';
import { getDeviceId } from './src/licensing/useLicense';
import LicenseScreen from './src/screens/LicenseScreen';
import TrialBanner from './src/components/TrialBanner';
const API_BASE = 'https://api.unifiedlicensing.com/api/v1';
const PRODUCT_KEY = 'my-product-key';
const VENDOR_API_KEY = 'ul_your_vendor_api_key'; // inject via build config in production
type LicenseContextValue = {
isValid: boolean;
licenseKey: string | null;
deactivate: () => Promise<void>;
};
const LicenseContext = createContext<LicenseContextValue | null>(null);
export function useLicenseContext(): LicenseContextValue {
const ctx = useContext(LicenseContext);
if (!ctx) throw new Error('useLicenseContext must be used inside LicenseProvider');
return ctx;
}
function PremiumDashboard() {
const { licenseKey, deactivate } = useLicenseContext();
return (
<View style={styles.premium}>
<TrialBanner trialToken={licenseKey ?? ''} />
<Text style={styles.heading}>Welcome to Pro!</Text>
<Text style={styles.body}>
Your license is active on this device. All premium features are unlocked.
</Text>
<Text onPress={deactivate} style={styles.link}>Deactivate device</Text>
</View>
);
}
function BootLoading() {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#C7A34C" />
<Text style={styles.body}>Checking license…</Text>
</View>
);
}
export default function App() {
const { loading, isValid, licenseKey, planFeatures, validate, deactivate } = useLicense();
const [booting, setBooting] = useState(true);
useEffect(() => {
(async () => {
const storedKey = await AsyncStorage.getItem('@unifiedlicensing/license_key');
if (storedKey) {
await validate(storedKey);
}
setBooting(false);
})();
}, [validate]);
if (booting || loading) return <BootLoading />;
return (
<LicenseContext.Provider value={{ isValid, licenseKey, deactivate }}>
{isValid ? <PremiumDashboard /> : <LicenseScreen onActivated={() => undefined} />}
</LicenseContext.Provider>
);
}
const styles = StyleSheet.create({
centered: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#081220' },
premium: { flex: 1, justifyContent: 'center', padding: 24, backgroundColor: '#0E1B2C' },
heading: { fontSize: 26, fontWeight: '700', color: '#E4C468', textAlign: 'center', marginBottom: 12 },
body: { color: '#A9B2C3', textAlign: 'center', lineHeight: 22 },
link: { color: '#C7A34C', textAlign: 'center', marginTop: 24, textDecorationLine: 'underline' },
});
This example demonstrates: boot-time silent validation, gated premium content via context, an activation fallback screen, and device deactivation — all against real UnifiedLicensing endpoints.
react-native-device-info requires native modules and does not run in Expo Go. Options: eject to a bare workflow, use a development build (Expo prebuild), or replace the device ID with a UUID you generate once and persist in SecureStore (expo-secure-store). Everything else in this guide — pure fetch() calls — works identically in Expo.
The API behaves identically on both platforms since you explicitly pass platform: "mobile". The main difference is device ID semantics: Android's ANDROID_ID survives app reinstalls, while iOS's identifierForVendor resets if the user removes all of your apps. Handle the "device not recognized" case by prompting re-activation on iOS.
Device activation limits this. Each license supports a bounded number of bound devices (visible as quota_remaining). Additional devices are rejected by /activate-device until one is released via /deactivate-license.
Yes — a single lightweight POST on cold start is cheap and keeps revoked/expired keys out. Combine with a 24-hour offline cache so brief outages do not lock paying customers out.
The user taps "Deactivate" on the old device (calls /deactivate-license), then activates normally on the new one. If they no longer have the old device, provide a support path or a self-service portal where they can release devices remotely.
Yes. All examples here are written in strict-compatible TypeScript. Type the API responses explicitly (as shown in TrialStatus) and narrow union results like LicensingError with discriminated unions.
Open the vendor dashboard → select your product → Licenses tab. Each license shows its bound devices, last-seen dates, and usage history.