React Native Integration Guide

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.

1. Quick Start

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');
  }
}
Tip: Always send platform: "mobile" from React Native apps so quota tracking is attributed correctly per platform.

2. Setup

Before writing any code, you need two credentials:

Get both from the vendor dashboard: Open Vendor Dashboard

Create your API client module

// 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 ?? '';
CredentialWhere to Find ItFormat
Vendor API KeyDashboard → Settings tabul_...
Product KeyDashboard → Products tabProduct slug

3. Dependencies

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)
Note: On Android, 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.

4. License Manager Hook

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

5. License Screen Component

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

6. Storing License Key

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();
  }
}
Tip: Re-validate on every cold start rather than trusting the stored flag alone. Users who receive refunds or have licenses revoked will be blocked on next launch.

7. Device Binding

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

8. Offline Support

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
}
ScenarioBehavior
Online, valid licenseServer validates, cache refreshed
Online, invalid licenseAccess denied, local cache cleared
Offline, fresh cache (< 7 days)Grace period access allowed
Offline, stale/no cacheAccess denied until connection restored

9. Trial Support

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

Trial countdown component

// 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' },
});
Tip: When the trial expires (expired: true), route users back to LicenseScreen and clear the trial token so they cannot reuse it.

10. Error Handling

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;
  }
}
ErrorCauseUser Action
Network failureNo connectivity / timeoutOffer retry + offline grace mode
HTTP 429Rate limit exceededWait per Retry-After header
success: falseInvalid/expired/revoked keyShow activation screen again
Other HTTP codesServer issue or bad requestGeneric support message

11. Security Best Practices

Mobile apps ship to hostile environments. Harden your integration:

# 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'],
    },
  })),
};
Important: Client-side licensing deters casual piracy but cannot stop determined attackers. Design your backend APIs to also verify licenses server-to-server when serving premium content.

12. Complete Example

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.

13. FAQ

Does this work with Expo?

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.

Are there differences between iOS and Android?

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.

What happens if a user shares their license key?

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.

Should I validate on every app open?

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.

How do I move a license to a new phone?

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.

Can I use this with TypeScript strict mode?

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.

Where do I see device activations?

Open the vendor dashboard → select your product → Licenses tab. Each license shows its bound devices, last-seen dates, and usage history.