Skip to main content

Getting Started

Mobile & Embedded Quickstart

License a mobile app or embedded device by calling the REST API directly. No SDK required.

Why no SDK?
Licentric ships official SDKs for Python and TypeScript. For mobile and embedded, the API is small enough (4 endpoints handle the full runtime: validate, activate, deactivate, heartbeat) that a hand-written client is usually cleaner than a generic SDK. The examples below cover the only behaviors most apps need.

What you need

  • An HTTPS client (URLSession on iOS, OkHttp on Android, libcurl on embedded).
  • A JSON parser (the platform default is fine — JSONDecoder, org.json, jq).
  • A SHA-256 implementation (in standard library on every modern platform).
  • A stable per-device identifier — see the fingerprint sections below.

iOS / macOS (Swift)

Validate a license key with a machine fingerprint. The endpoint returns 200 with { valid, code, ... } on success and a 4xx with a typed code on error.

LicentricClient.swift
import Foundation

struct ValidationResult: Decodable {
    let valid: Bool
    let code: String
    let machine: Machine?

    struct Machine: Decodable {
        let id: String
        let fingerprint: String
    }
}

struct LicentricError: Error { let code: String; let message: String }

func validateLicense(key: String, fingerprint: String) async throws -> ValidationResult {
    var request = URLRequest(url: URL(string: "https://licentric.com/api/v1/licenses/validate-key")!)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.setValue("Licentric-iOS/1.0", forHTTPHeaderField: "User-Agent")

    let body: [String: Any] = ["key": key, "fingerprint": fingerprint]
    request.httpBody = try JSONSerialization.data(withJSONObject: body)

    let (data, response) = try await URLSession.shared.data(for: request)
    guard let http = response as? HTTPURLResponse else {
        throw LicentricError(code: "NETWORK_ERROR", message: "No HTTP response")
    }

    if (200...299).contains(http.statusCode) {
        let envelope = try JSONDecoder().decode([String: ValidationResult].self, from: data)
        return envelope["data"]!
    }

    // Map license-state codes to typed errors so callers can branch on type
    if let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
       let code = body["code"] as? String {
        throw LicentricError(code: code, message: body["error"] as? String ?? "Unknown")
    }
    throw LicentricError(code: "UNKNOWN", message: "HTTP \(http.statusCode)")
}

iOS device fingerprint

DeviceFingerprint.swift
import UIKit
import CryptoKit

/// Stable iOS device identifier — survives app reinstall as long as the
/// vendor-bundle identifier stays the same. For per-keychain stability
/// across app updates, store the result in the iOS Keychain on first use.
func deviceFingerprint() -> String {
    let raw = UIDevice.current.identifierForVendor?.uuidString ?? UIDevice.current.name
    let hash = SHA256.hash(data: Data(raw.utf8))
    return hash.compactMap { String(format: "%02x", $0) }.joined()
}

Android (Kotlin)

Same flow, OkHttp client. Catch LicentricError and branch on errorCode — values like LICENSE_REVOKED, LICENSE_SUSPENDED, and LICENSE_EXPIRED are documented in the errors reference.

LicentricClient.kt
import okhttp3.*
import org.json.JSONObject
import java.io.IOException

data class ValidationResult(val valid: Boolean, val code: String)

class LicentricError(val errorCode: String, message: String) : Exception(message)

class LicentricClient(private val baseUrl: String = "https://licentric.com") {
    private val client = OkHttpClient()

    @Throws(LicentricError::class)
    fun validate(licenseKey: String, fingerprint: String): ValidationResult {
        val body = JSONObject()
            .put("key", licenseKey)
            .put("fingerprint", fingerprint)
            .toString()
            .toRequestBody("application/json".toMediaType())

        val request = Request.Builder()
            .url("$baseUrl/api/v1/licenses/validate-key")
            .post(body)
            .header("User-Agent", "Licentric-Android/1.0")
            .build()

        client.newCall(request).execute().use { response ->
            val responseBody = response.body?.string() ?: throw LicentricError("EMPTY", "no body")
            if (response.isSuccessful) {
                val data = JSONObject(responseBody).getJSONObject("data")
                return ValidationResult(data.getBoolean("valid"), data.getString("code"))
            }
            // Map license-state codes (e.g. "LICENSE_REVOKED") to typed errors
            val errorJson = JSONObject(responseBody)
            throw LicentricError(
                errorJson.optString("code", "UNKNOWN"),
                errorJson.optString("error", "Validation failed"),
            )
        }
    }
}

Android device fingerprint

DeviceFingerprint.kt
import android.content.Context
import android.provider.Settings
import java.security.MessageDigest

/// Stable Android device identifier — Settings.Secure.ANDROID_ID is unique
/// per app-signing-key + per-user since Android 8.0. For pre-O fallback,
/// see Google Play Services Instance ID.
fun deviceFingerprint(context: Context): String {
    val androidId = Settings.Secure.getString(
        context.contentResolver,
        Settings.Secure.ANDROID_ID,
    ) ?: "unknown"
    val digest = MessageDigest.getInstance("SHA-256").digest(androidId.toByteArray())
    return digest.joinToString("") { "%02x".format(it) }
}

Embedded Linux (shell)

For headless devices that already have curl + jq, validation is a few lines. /etc/machine-id provides a stable per-host fingerprint.

validate.sh
# Headless Linux device example — busybox + curl + jq
LICENSE_KEY="LIC-XXXX-XXXX-XXXX-XXXX"
FP=$(cat /etc/machine-id | sha256sum | awk '{print $1}')

response=$(curl -s -w "\n%{http_code}" \
  -X POST https://licentric.com/api/v1/licenses/validate-key \
  -H "Content-Type: application/json" \
  -d "{\"key\":\"$LICENSE_KEY\",\"fingerprint\":\"$FP\"}")

http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | sed '$d')

if [ "$http_code" = "200" ]; then
  valid=$(echo "$body" | jq -r '.data.valid')
  code=$(echo "$body" | jq -r '.data.code')
  echo "License is $code (valid=$valid)"
else
  echo "Validation failed: HTTP $http_code"
  echo "$body" | jq -r '.code + ": " + .error'
fi
Cache validation results
Don't validate on every launch — cache the result for 5 minutes (matches the Licentric SDK default). Persist the cached envelope to the device keychain or a tamper-resistant file. When the network is unavailable, return the cached result if it's within the grace period (default 15 min after TTL expiry). See Validation Caching for the full pattern.

Error handling

Treat license-state error codes as fail-immediately — surface the message to the end user without retrying. Treat network errors and 5xx responses as retry-eligible — fall back to the cached result if available.

  • LICENSE_REVOKED — show “License revoked. Contact support.” Do not retry.
  • LICENSE_SUSPENDED — show “License on hold.” Polling later may recover.
  • LICENSE_EXPIRED — show renewal flow.
  • RATE_LIMITED — back off using the Retry-After header.
  • UNAUTHORIZED, VALIDATION_ERROR — bug in the request; fix client code.
Verify offline license files in any language
For long-lived offline use (kiosks, air-gapped devices), call POST /api/v1/licenses/{id}/checkoutfrom your backend, deliver the signed file to the device, and verify it locally with the platform's Ed25519 implementation. iOS: CryptoKit. Android: BouncyCastle. Embedded: libsodium. The signature format is documented in Offline Licensing.