Add license validation to your Android app using Kotlin and OkHttp.
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)
Get two values from your vendor dashboard:
ul_yourapp-productname-xxxxxAdd 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")
}
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()
}
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()
}
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()
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
}
}
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)
}
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
)
| HTTP Status | Meaning | Action |
|---|---|---|
| 200 | Success | Process license data |
| 400 | Invalid request | Check license_key and product_key format |
| 401 | Unauthorized | Verify your API key (starts with ul_) |
| 403 | Forbidden | API key may be revoked, check dashboard |
| 404 | Not found | Check API URL and endpoint path |
| 429 | Quota exceeded | Use cached validation, upgrade plan |
| 500 | Server error | Retry later, use cached validation |
gradle.properties// 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
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()
}
}
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.