← Back to Guides

Android Integration

Add license validation to your Android app using Kotlin and OkHttp.

1. Quick Start

The simplest way to validate a license on Android:

// Minimal validation call
val client = OkHttpClient()
val body = JSONObject().apply {
    put("license_key", "YOUR-LICENSE-KEY")
    put("product_key", "YOUR-PRODUCT-KEY")
    put("platform", "mobile")
}

val request = Request.Builder()
    .url("https://api.unifiedlicensing.com/api/v1/validate-license")
    .post(body.toString().toRequestBody("application/json".toMediaType()))
    .addHeader("x-vendor-api-key", "ul_YOUR_API_KEY")
    .build()

val response = client.newCall(request).execute()
val json = JSONObject(response.body!!.string())
val valid = json.optBoolean("success", false)

2. Setup

Get two values from your vendor dashboard:

The API key is safe to ship in your app. It identifies your vendor account, not a specific user.

3. Project Setup

Add the INTERNET permission to AndroidManifest.xml:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    ...
</manifest>

Add OkHttp to your build.gradle:

dependencies {
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}

4. License Manager

A reusable class that handles validation, caching, and errors:

import android.content.Context
import android.provider.Settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.util.concurrent.TimeUnit

class LicenseManager(private val context: Context) {

    private val client = OkHttpClient.Builder()
        .connectTimeout(10, TimeUnit.SECONDS)
        .readTimeout(10, TimeUnit.SECONDS)
        .build()

    companion object {
        private const val BASE_URL = "https://api.unifiedlicensing.com/api/v1"
        private const val PREFS = "ul_license"
    }

    suspend fun validate(
        licenseKey: String,
        productKey: String,
        apiKey: String
    ): LicenseResult = withContext(Dispatchers.IO) {
        try {
            val body = JSONObject().apply {
                put("license_key", licenseKey)
                put("product_key", productKey)
                put("platform", "mobile")
                put("machine_id", getDeviceId())
            }

            val request = Request.Builder()
                .url("$BASE_URL/validate-license")
                .post(body.toString().toRequestBody("application/json".toMediaType()))
                .addHeader("x-vendor-api-key", apiKey)
                .build()

            val response = client.newCall(request).execute()
            val json = JSONObject(response.body!!.string())

            if (json.optBoolean("success", false)) {
                val result = LicenseResult.Valid(
                    licenseInfo = json.optJSONObject("license"),
                    quotaRemaining = json.optInt("quota_remaining")
                )
                cacheResult(licenseKey, result)
                result
            } else {
                LicenseResult.Invalid(
                    error = json.optString("error", "Validation failed")
                )
            }
        } catch (e: Exception) {
            getCachedResult(licenseKey) ?: LicenseResult.Error(e.message ?: "Network error")
        }
    }

    fun getDeviceId(): String {
        return Settings.Secure.getString(
            context.contentResolver,
            Settings.Secure.ANDROID_ID
        ) ?: "unknown"
    }

    private fun cacheResult(key: String, result: LicenseResult) {
        val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
        prefs.edit().putString(key, result.toJson()).apply()
    }

    private fun getCachedResult(key: String): LicenseResult? {
        val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
        val json = prefs.getString(key, null) ?: return null
        return try {
            val obj = JSONObject(json)
            if (obj.optBoolean("valid")) LicenseResult.Valid(null, 0)
            else null
        } catch (e: Exception) { null }
    }
}

sealed class LicenseResult {
    data class Valid(val licenseInfo: JSONObject?, val quotaRemaining: Int) : LicenseResult()
    data class Invalid(val error: String) : LicenseResult()
    data class Error(val message: String) : LicenseResult()

    fun isValid() = this is Valid
    fun toJson() = JSONObject().apply {
        put("valid", isValid())
    }.toString()
}

5. Store License Key

Persist the key in SharedPreferences and auto-validate on launch:

class LicenseStorage(context: Context) {
    private val prefs = context.getSharedPreferences("ul_keys", Context.MODE_PRIVATE)

    fun saveKey(key: String) = prefs.edit().putString("license_key", key).apply()

    fun getKey(): String? = prefs.getString("license_key", null)

    fun clear() = prefs.edit().remove("license_key").apply()
}

// In your Activity or ViewModel:
val storage = LicenseStorage(this)
val savedKey = storage.getKey()

if (savedKey != null) {
    val result = manager.validate(savedKey, productKey, apiKey)
    if (result.isValid()) {
        unlockFeatures()
    } else {
        showLicenseScreen()
    }
} else {
    showLicenseScreen()
}

6. Device Binding

Bind licenses to specific devices using Android's ANDROID_ID:

// Activate device on first validation
val deviceId = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)

val activateBody = JSONObject().apply {
    put("license_key", licenseKey)
    put("product_key", productKey)
    put("device_id", deviceId)
}

val activateRequest = Request.Builder()
    .url("$BASE_URL/activate-device")
    .post(activateBody.toString().toRequestBody("application/json".toMediaType()))
    .addHeader("x-vendor-api-key", apiKey)
    .build()

client.newCall(activateRequest).execute()
ANDROID_ID is unique per device + signing key. It persists across app reinstalls but resets on factory reset. It's the best device identifier for license binding on Android.

7. Offline Support

Cache the last successful validation and use it when offline:

class OfflineValidator(context: Context) {
    private val prefs = context.getSharedPreferences("ul_cache", Context.MODE_PRIVATE)

    fun cacheValidation(key: String, result: LicenseResult.Valid) {
        prefs.edit()
            .putString("key", key)
            .putLong("timestamp", System.currentTimeMillis())
            .putBoolean("valid", true)
            .apply()
    }

    fun getCached(key: String): Boolean {
        val cachedKey = prefs.getString("key", null)
        val timestamp = prefs.getLong("timestamp", 0)
        val valid = prefs.getBoolean("valid", false)
        val age = System.currentTimeMillis() - timestamp

        // Cache valid for 7 days
        return valid && cachedKey == key && age < 7 * 24 * 60 * 60 * 1000
    }
}

8. Trial Support

Check trial status using the /check-trial endpoint:

suspend fun checkTrial(
    trialToken: String,
    productKey: String,
    apiKey: String
): Boolean = withContext(Dispatchers.IO) {
    val body = JSONObject().apply {
        put("trial_token", trialToken)
        put("product_key", productKey)
    }

    val request = Request.Builder()
        .url("$BASE_URL/check-trial")
        .post(body.toString().toRequestBody("application/json".toMediaType()))
        .addHeader("x-vendor-api-key", apiKey)
        .build()

    val response = client.newCall(request).execute()
    val json = JSONObject(response.body!!.string())
    json.optBoolean("valid", false)
}

9. Background Validation

Use WorkManager to periodically re-validate licenses:

import androidx.work.*
import java.util.concurrent.TimeUnit

class LicenseWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        val prefs = applicationContext.getSharedPreferences("ul_keys", Context.MODE_PRIVATE)
        val key = prefs.getString("license_key", null) ?: return Result.success()

        val manager = LicenseManager(applicationContext)
        val result = manager.validate(key, "YOUR_PRODUCT_KEY", "ul_YOUR_API_KEY")

        return if (result.isValid()) Result.success() else Result.retry()
    }
}

// Schedule daily validation
val workRequest = PeriodicWorkRequestBuilder<LicenseWorker>(
    1, TimeUnit.DAYS
).setConstraints(
    Constraints.Builder()
        .setRequiredNetworkType(NetworkType.CONNECTED)
        .build()
).build()

WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "license_check",
    ExistingPeriodicWorkPolicy.KEEP,
    workRequest
)

10. Error Handling

HTTP StatusMeaningAction
200SuccessProcess license data
400Invalid requestCheck license_key and product_key format
401UnauthorizedVerify your API key (starts with ul_)
403ForbiddenAPI key may be revoked, check dashboard
404Not foundCheck API URL and endpoint path
429Quota exceededUse cached validation, upgrade plan
500Server errorRetry later, use cached validation

11. Security Best Practices

// build.gradle - inject keys at build time
android {
    defaultConfig {
        buildConfigField("String", "UL_API_KEY", "\"${project.findProperty('UL_API_KEY')}\"")
        buildConfigField("String", "UL_PRODUCT_KEY", "\"${project.findProperty('UL_PRODUCT_KEY')}\"")
    }
}

// gradle.properties (not committed to git)
UL_API_KEY=ul_xxxxxxxxxxxxxxxx
UL_PRODUCT_KEY=yourapp-product-xxxxx

12. Complete Example

Full Activity with license activation screen:

class LicenseActivity : AppCompatActivity() {

    private lateinit var manager: LicenseManager
    private lateinit var binding: ActivityLicenseBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityLicenseBinding.inflate(layoutInflater)
        setContentView(binding.root)

        manager = LicenseManager(this)

        // Auto-validate saved key
        val storage = LicenseStorage(this)
        storage.getKey()?.let { key ->
            lifecycleScope.launch {
                showLoading(true)
                val result = manager.validate(key, BuildConfig.UL_PRODUCT_KEY, BuildConfig.UL_API_KEY)
                showLoading(false)
                if (result.isValid()) {
                    navigateToMain()
                } else {
                    binding.inputKey.setText(key)
                }
            }
        }

        binding.btnActivate.setOnClickListener {
            val key = binding.inputKey.text.toString().trim()
            if (key.isEmpty()) {
                binding.error.text = "Enter a license key"
                return@setOnClickListener
            }

            lifecycleScope.launch {
                showLoading(true)
                val result = manager.validate(key, BuildConfig.UL_PRODUCT_KEY, BuildConfig.UL_API_KEY)
                showLoading(false)

                when (result) {
                    is LicenseResult.Valid -> {
                        LicenseStorage(this@LicenseActivity).saveKey(key)
                        navigateToMain()
                    }
                    is LicenseResult.Invalid -> binding.error.text = result.error
                    is LicenseResult.Error -> binding.error.text = result.message
                }
            }
        }
    }

    private fun showLoading(show: Boolean) {
        binding.progressBar.visibility = if (show) View.VISIBLE else View.GONE
        binding.btnActivate.isEnabled = !show
    }

    private fun navigateToMain() {
        startActivity(Intent(this, MainActivity::class.java))
        finish()
    }
}

13. FAQ

Does this work with Kotlin Multiplatform?

Yes — the HTTP calls are simple POST requests. Use ktor-client on commonMain and the same API endpoints.

What if the user reinstalls the app?

ANDROID_ID stays the same after reinstall (same signing key). The user's license key stored in SharedPreferences is lost, so they'll need to re-enter it. Consider backing up to cloud storage.

Can I use this with Jetpack Compose?

Absolutely. Wrap validation in a LaunchedEffect or ViewModel and observe the result as state.

How do I handle multiple products?

Pass different product_key values. Each product has its own key in the dashboard.