Android SDK · 1.0.0

Android SDK

The client for Keywarden on Android: store purchases, license keys, spend-tier gifts and metered trials behind one signed bundle that verifies offline. Kotlin and Java, minSdk 23, no AndroidX, no coroutines, no HTTP library.

Install

Three modules, all under the dev.keywarden group:

ModuleWhat it isScope
keywarden-coreThe client: Keywarden, Config, sync, has(), licenses, meters, offline bundle verification. Depends on the Kotlin stdlib only.implementation
keywarden-play-billingGoogle Play Billing bridge (PlayPurchaseSync). Exposes keywarden-core as api. Play Billing is compileOnly — your app brings its own com.android.billingclient:billing (the SDK is built against 9.1.0).implementation
keywarden-core-testingFakeKeywarden and MockTransport for your own tests.testImplementation

Requirements: minSdk 23 (Android 6.0), JDK 17, Android Gradle Plugin 8.x. The SDK itself is built with AGP 8.13.2, Gradle 8.13, Kotlin 2.1.20 and compileSdk 36.

Getting the artifacts Maven Central: not yet published

The coordinates below are final, but the artifacts are not on Maven Central yet — the dev.keywarden namespace is still pending verification. Until then, build from source: the SDK lives in sdks/android of the Keywarden repository. Pick one of two interim setups.

Option A — composite build (what KeyFlow, the first production app, uses). Clone the repository next to your app and include the SDK build from your settings.gradle.kts; Gradle substitutes the modules for the Maven coordinates, so switching to Maven Central later means deleting this block.

// settings.gradle.kts
includeBuild("../keywarden/sdks/android") {
    dependencySubstitution {
        substitute(module("dev.keywarden:keywarden-core")).using(project(":keywarden-core"))
        substitute(module("dev.keywarden:keywarden-play-billing")).using(project(":keywarden-play-billing"))
        substitute(module("dev.keywarden:keywarden-core-testing")).using(project(":keywarden-core-testing"))
    }
}

The included build runs on your Gradle and resolves its own plugins (AGP 8.13.2, Kotlin 2.1.20) from google()/mavenCentral()/gradlePluginPortal(). Using the same AGP and Kotlin versions in your app avoids plugin-classpath conflicts.

Option B — local Maven repository. Publish once per machine (and in CI), then resolve from mavenLocal():

# in keywarden/sdks/android
./gradlew publishToMavenLocal     # → ~/.m2/repository/dev/keywarden/…

// settings.gradle.kts (repositories live here when you use FAIL_ON_PROJECT_REPOS)
dependencyResolutionManagement {
    repositories {
        mavenLocal()
        google()
        mavenCentral()
    }
}

Either way, the dependencies are the same:

// app/build.gradle.kts
dependencies {
    implementation("dev.keywarden:keywarden-core:1.0.0")
    implementation("dev.keywarden:keywarden-play-billing:1.0.0")     // only if you sell on Google Play
    implementation("com.android.billingclient:billing:9.1.0")         // your own Billing dependency
    testImplementation("dev.keywarden:keywarden-core-testing:1.0.0")
}

Manifest: the INTERNET permission is yours to declare

None of the modules declares a permission — network access is your app's disclosure to make. Add it yourself, or every call ends as UNKNOWN with error code NETWORK:

<uses-permission android:name="android.permission.INTERNET" />

R8 / ProGuard

Nothing to add. The SDK uses no reflection; each module ships a consumer rules file that R8 applies automatically: keywarden-core and keywarden-core-testing keep generic Signature attributes, and keywarden-play-billing adds -dontwarn com.android.billingclient.** so an app without Play Billing on its classpath doesn't get missing-class warnings.

Configure

Configure once, in Application.onCreate, then use Keywarden.get() everywhere. The values come from the app's Setup page in the dashboard: the public key (pk_live_…), the signing key's kid and its SPKI (base64).

// Java — App.java
public class App extends Application {
    @Override public void onCreate() {
        super.onCreate();
        Keywarden.configure(new Config.Builder(this)
                .apiKey(BuildConfig.KEYWARDEN_PK)             // pk_live_… — public, identifies the app
                .publicKey("k1", "MFkwEwYHKoZI…")             // pinned; verifies bundles offline
                .appSlug("my-app")                            // bundles for another app are rejected
                .deviceInfo(DeviceInfoPolicy.NONE)            // the default: install id only
                .build());
    }
}
// Kotlin — App.kt
class App : Application() {
    override fun onCreate() {
        super.onCreate()
        Keywarden.configure(
            Config.Builder(this)
                .apiKey(BuildConfig.KEYWARDEN_PK)
                .publicKey("k1", "MFkwEwYHKoZI…")
                .appSlug("my-app")
                .build()
        )
    }
}
// app/build.gradle.kts — inject the key at build time (e.g. from a CI secret)
android {
    buildFeatures { buildConfig = true }
    defaultConfig {
        buildConfigField("String", "KEYWARDEN_PK", "\"${System.getenv("KEYWARDEN_PK") ?: ""}\"")
    }
}

Keywarden.configure(config) builds the client, installs it process-wide and returns it; calling it again replaces (and shuts down) the previous client — network calls on a stale reference then report UNKNOWN with CLIENT_CLOSED instead of throwing. Keywarden.get() throws IllegalStateException before configure — guard with Keywarden.isConfigured() if some builds (forks, local builds without a key) skip configuration.

Config.Builder options

OptionDefaultWhat it does / when to set it
Config.Builder(Context)—Keeps only context.getApplicationContext(), so passing an Activity doesn't leak it.
apiKey(String)requiredThe app's pk_live_…. build() throws IllegalArgumentException when it is missing or blank — a blank key would 401 every call and silently look like "no rights".
baseUrl(String)https://api.keywarden.devAPI root, without /v1; a trailing slash is trimmed. Must be https://. Plain http:// is accepted only for localhost, 127.0.0.1 and 10.0.2.2 (the emulator's host). Anything else throws at build().
publicKey(String kid, String spkiBase64)required (at least one)Pins one verification key. Call it once per key you trust (several calls = several keys, e.g. during a rotation). build() throws IllegalStateException when no key is pinned — without one no bundle could ever verify and nothing would ever be granted. Each SPKI is parsed at build(); an invalid one throws IllegalArgumentException, naming the kid. A bundle whose kid isn't pinned is treated as forged — the SDK never fetches keys.
appSlug(String)null (not checked)Expected app claim in bundles. Set it; leave it unset only in a multi-app consumer.
deviceInfo(DeviceInfoPolicy)NONEWhat the SDK reports besides the install id and platform: "android". NONE: nothing more. APP_VERSION: plus your versionName. FULL: plus "Android <release>" and "<manufacturer> <model>". Pick what your privacy policy discloses.
tamperCheck(TamperCheck)nullYour own integrity check (boolean isIntact()), folded into every has()/entitlements()/source()/expiresAt() answer: when it returns false, nothing is held. Called on the calling thread on every read — keep it cheap and cache its result. See Security.
integrityTokenProvider(IntegrityTokenProvider)nullSupplies a Play Integrity token (String token(String requestHash)), called on the SDK's IO thread. Used only by verifyPlayPurchase; the token must be bound to requestHash — see Play Integrity. If it returns null or throws, the request is sent without a token.
existingInstallId(String)null (a random UUID is generated)Adopts an install id your app already has, so server-side trial counters and gifts stay attached to the same device. Ignored once the SDK has stored an id of its own. Must be 8–128 characters (the server's limits): build() throws IllegalArgumentException otherwise, without echoing the value; null or blank means "nothing to adopt". See Migration.
bundleClockSkewSeconds(long)60Leeway applied to the bundle's own exp only, so a device clock that runs slightly fast doesn't lock out a paying user. Never applied to a per-entitlement expiry (a subscription's end date is compared exactly).
storage(Storage)SharedPreferencesStorageWhere the install id, last bundle, spend summary and local deny-list live. The default is app-private SharedPreferences file keywarden, in device-protected storage on API 24+ (readable before first unlock, for direct-boot services). new SharedPreferencesStorage(context, deviceProtected, fileName) changes either. Writes use a synchronous commit().
transport(Transport)HttpUrlConnectionTransportThe SDK's only way out to the network: Response execute(Request), called on the IO thread. The default uses a 10 s connect / 15 s read timeout and refuses response bodies over 1 MiB. Swap it for your own HTTP stack or MockTransport.

Sync & entitlement state

The SDK splits cleanly into local reads (synchronous, offline, main-thread safe) and network calls (asynchronous, one callback each).

sync

Call sync on launch and when the app returns to the foreground. It is the heartbeat that keeps floating license seats alive and the way new rights, refunds and revocations reach the device: every successful sync returns a freshly signed bundle.

Keywarden.get().sync((verdict, error) -> {
    // main thread
    if (verdict == Verdict.GRANTED) {
        render(Keywarden.get().entitlements());
    } else {
        // UNKNOWN: keep showing what the user already has; retry later.
        Log.w("App", "sync: " + error.code + " (" + error.httpStatus + ")");
    }
});
Keywarden.get().sync { verdict, error ->
    if (verdict == Verdict.GRANTED) render(Keywarden.get().entitlements())
}

Kotlin note: every callback interface (SyncCallback, VerifyCallback, ActivateCallback, ValidateCallback, DeactivateCallback, MeterCallback) is a fun interface, as are EntitlementListener, TamperCheck and IntegrityTokenProvider — pass a Kotlin lambda, or a Java lambda. PlayPurchaseSync.Listener has two methods and still needs an object : expression.

The second overload, sync(List<String> ownedStoreProducts, boolean storeQueryOk, SyncCallback), also reports what the store lists as owned. Pass storeQueryOk = true only when the store query succeeded for every product type — otherwise the list is ignored server-side and nothing is dropped. PlayPurchaseSync calls it for you.

Reading state

MethodReturns
has(String entitlement)true when this device currently holds the entitlement. The argument is the entitlement key from your catalog, not necessarily a store product id.
entitlements()Every entitlement has() would answer true for (Set<String>).
source(String entitlement)Source.STORE, Source.LICENSE or Source.GIFT; null when not held. When several rows grant it, STORE wins over LICENSE over GIFT.
expiresAt(String entitlement)Epoch milliseconds, or null when it doesn't expire or isn't held. If any granting row never expires, the answer is null; otherwise the latest expiry wins.
support()Spend summary from the last successful call, persisted across restarts: totalMicros, currency (the server's currency), next (productId, priceMicros, remainingMicros) and paidTotal (currency, micros — what the user actually paid, in their currency). null before the first one. Display only; the server makes the gift decision.
hasVerifiedBundle()true when the SDK holds a bundle whose signature it verified against a pinned key — i.e. the server has handed this install authoritative state at least once. Not a rights check: an empty, expired or fully revoked bundle is still true here. Re-evaluated on every process start, so a key change that invalidates the stored bundle turns it back to false.
installId()This install's random id. Secret — see Security.

When does has() answer true? Only when all of these hold: a verified bundle is stored; its exp hasn't passed (plus bundleClockSkewSeconds); a row for the entitlement exists whose own exp hasn't passed; that row isn't a STORE row denied by revokeLocal, or a LICENSE row suppressed by a successful deactivate; and tamperCheck (if set) returns true.

Offline window. A bundle is valid for the app's token TTL — 7 days (604800 s) by default, set per app via tokenTtlSeconds in the Management API (1 hour to 365 days). A device that can't sync for longer than that keeps its stored bundle but stops getting true from has() until the next successful sync. Expired is not the same as forged: nothing is deleted.

Listeners

private final EntitlementListener onRights = entitlements -> render(entitlements);

@Override protected void onStart() { super.onStart(); Keywarden.get().addListener(onRights); }
@Override protected void onStop()  { Keywarden.get().removeListener(onRights); super.onStop(); }

onEntitlementsChanged(Set<String>) runs on the main thread, and only when the effective set actually changed as a result of sync, verifyPlayPurchase, activate, validate, deactivate or revokeLocal. It is not a timer: an entitlement or bundle expiring with the clock, or tamperCheck changing its answer, changes has() without a notification. Listeners are held strongly — remove them.

Threading

  • Local reads (has, entitlements, source, expiresAt, support, installId, hasVerifiedBundle) are synchronous and never touch the network or wait on disk writes — call them on the UI thread, on every screen. Signatures are verified when a bundle arrives (and once at startup), not per read.
  • Network calls run on a single SDK thread, keywarden-io, one request at a time in call order. Every callback is posted to the main thread, exactly once — including on network errors and unexpected exceptions (reported as UNKNOWN).
  • Never block the main thread waiting for a callback (a latch, Future.get()): the callback needs that thread and will never arrive.
  • Keywarden.configure does a little synchronous work on the calling thread: it reads the SDK's preferences, verifies the stored bundle's signature once, and on first launch writes the new install id. revokeLocal persists synchronously too.

Verdicts: GRANTED, REJECTED, UNKNOWN

Every network call reports a Verdict plus, when it isn't GRANTED, a KeywardenException carrying code (the server's error code, or a client-side code below), httpStatus (0 when the request never got a response) and a message. error is null exactly when the verdict is GRANTED.

A verdict is a report, not a state change. Local rights only change when a new signed bundle verifies, or through the explicit paths listed below — so a network hiccup can never lock a paying user out. REJECTED means the store or license said no; UNKNOWN means "couldn't tell": keep what the user has and retry later.

The mapping is exact: REJECTED needs both the status and the code to match. Everything else that isn't a 200 {"ok":true} is UNKNOWN.

ResponseVerdictEffect on stored state
200 {"ok":true} with a bundle that verifiesGRANTEDThe new bundle replaces the stored one — including when it lists fewer entitlements; that is how refunds, revoked licenses and lapsed subscriptions reach the device. Spend summary updated; a deactivate suppression is lifted. sync(owned, true) also clears the revokeLocal deny-list; verifyPlayPurchase lifts the deny entry for that one product.
200 {"ok":true} without a bundle (or "bundle": null)GRANTEDNothing changes. activate/validate return no bundle when the device holds no entitlements at all. This is why hasVerifiedBundle() exists.
402 PURCHASE_INVALID · 402 VOIDEDREJECTEDNothing changes by itself. Call revokeLocal(productId) to drop the purchase's rights immediately (see Google Play); the next sync's bundle reflects it anyway.
410 LICENSE_REVOKED · LICENSE_EXPIRED · LICENSE_SUSPENDEDREJECTEDNothing changes by itself. The next sync returns a bundle without the license's rows.
400 VALIDATION · 401 UNAUTHORIZED · 403 INTEGRITY_FAILED · 404 NOT_FOUND · 409 NO_SEATS_AVAILABLE · 409 DEVICE_LIMIT_REACHED · 429 RATE_LIMITED · 500 INTERNAL · 502 STORE_UNREACHABLEUNKNOWNNothing changes. Use error.code to show a message (e.g. "all seats in use").
Any other status/code pair (e.g. 402 with an unexpected code)UNKNOWNNothing changes.

Client-side error codes, produced by the SDK itself:

CodehttpStatusVerdictMeaning
NETWORK0UNKNOWNNo HTTP response: no connectivity, DNS, TLS, timeout, a response body over 1 MiB, a missing INTERNET permission, or any exception thrown by the Transport.
BUNDLE_VERIFICATION_FAILED200UNKNOWNThe server answered 200 {"ok":true} but its bundle didn't verify: kid not pinned, wrong SPKI, app ≠ appSlug, dev ≠ this install id, or a forged response. Nothing is stored.
BUNDLE_STALE200UNKNOWNThe bundle verified, but its iat is older than the newest bundle this install has already applied — a replayed or rolled-back response. It is ignored (not applied, not stored), so an old bundle can never bring back a right a newer one removed. The SDK also remembers the latest signed time it has seen, so moving the device clock backwards can't revive an expired entitlement.
INTERNAL0UNKNOWNAn unexpected exception inside the call (for example your TamperCheck threw). The server's own INTERNAL comes with httpStatus 500.
CLIENT_CLOSED0UNKNOWNThe call was made on a client that has been shut down — usually a reference kept from before a second Keywarden.configure, which replaces and shuts down the previous client. Nothing is sent and nothing changes; take the current client from Keywarden.get().
HTTP_<status>statusUNKNOWNA non-success response whose body carried no error code (e.g. a proxy's HTML page), or a 200 that wasn't {"ok":true}.

Everything that can take a right away

  1. A newly verified bundle that no longer lists it (any successful sync, verifyPlayPurchase, activate or validate).
  2. revokeLocal(productIds) — STORE rows only.
  3. A deactivate that released at least one seat — all LICENSE rows, until the next verified bundle.
  4. Time: the entitlement's own exp, or the bundle's exp (plus skew) without a fresh sync.
  5. tamperCheck returning false.

Nothing else — no status code, no network error, no malformed response.

Google Play purchases

Prerequisite: the app's Google Play service account is uploaded, the app's Android package name is set, and your products are in the catalog (mapped to entitlements; consumables marked as consumable).

verifyPlayPurchase

The primitive: send one purchase token to POST /v1/store/verify. On GRANTED the returned bundle is applied and rights are visible through has().

for (String productId : purchase.getProducts()) {
    Keywarden.get().verifyPlayPurchase(productId, purchase.getPurchaseToken(),
            /* isSubscription = */ false,
            (verdict, error) -> { /* GRANTED | REJECTED (VOIDED, PURCHASE_INVALID) | UNKNOWN */ });
}

Most apps shouldn't call it by hand. A correct integration has to verify every purchase, notice purchases Play no longer lists, and never mistake a failed or half-finished query for "owns nothing". That is what PlayPurchaseSync does.

PlayPurchaseSync

new PlayPurchaseSync(KeywardenApi client, Set<String> knownStoreProducts, Set<String> consumableSkus). Feed it both queryPurchasesAsync results — in-app and subscriptions — for each query round. It then:

  1. calls verifyPlayPurchase for every PURCHASED purchase of a known product (pending purchases grant nothing). Each (productId, token) is sent at most once at a time; after GRANTED it isn't sent again by this instance. UNKNOWN and REJECTED are retried on the next round;
  2. waits until both halves of the same round have answered — syncing after one would either leave subscriptions stale or delete every one-time purchase;
  3. if both queries succeeded, calls revokeLocal for every known, non-consumable product Play didn't list;
  4. calls sync(owned, storeQueryOk), where storeQueryOk is true only if both queries returned BillingResponseCode.OK.

The round token. queryPurchasesAsync can't be cancelled, and you re-query on every resume and reconnect, so overlapping rounds are normal. onQueryStarted() opens a new round and returns its token; pass that token back with each response. A response carrying any other token — or a second response for a half that is already recorded — is ignored for the owned/revoke decision (its purchases are still verified: verification can only grant). Tokens start above 0, so an uninitialised int never matches.

Don't compute queryOk yourself. Hand the raw BillingResult to onInappPurchases(round, result, purchases) / onSubsPurchases(round, result, purchases). If you need the same answer elsewhere, use PlayPurchaseSync.queryOk(result) (false for null or any non-OK code). Tests without Play types can call onInapp(round, List<OwnedPurchase>, queryOk) / onSubs(…) directly.

Consumables must be in both sets: knownStoreProducts (or they are never verified) and consumableSkus (Play stops listing them once consumed, so they are excluded from the owned list and never revoked). Consume only after the server has seen the purchase: an unconsumed purchase is listed again on the next round, so an UNKNOWN just retries.

A complete BillingClient integration

public final class PlayBilling implements PurchasesUpdatedListener {
    // Every product id you sell on Play: one-time, subscriptions and consumables.
    private static final Set<String> PRODUCTS = new HashSet<>(Arrays.asList(
            "pro_unlock", "remove_ads", "plus_monthly", "coins_500"));
    private static final Set<String> CONSUMABLES = Collections.singleton("coins_500");

    private final BillingClient client;
    private final PlayPurchaseSync purchaseSync;
    private final Map<String, Purchase> unconsumed = new ConcurrentHashMap<>();

    public PlayBilling(Context context) {
        // Keywarden.configure(...) must have run already.
        purchaseSync = new PlayPurchaseSync(Keywarden.get(), PRODUCTS, CONSUMABLES);
        purchaseSync.setListener(new PlayPurchaseSync.Listener() {
            @Override public void onPurchaseVerified(String productId, Verdict verdict) {
                if (verdict == Verdict.REJECTED) {
                    // 402 VOIDED / PURCHASE_INVALID. Play may still list a refunded purchase,
                    // so drop its rights locally now — effective offline, too.
                    Keywarden.get().revokeLocal(Collections.singletonList(productId));
                } else if (verdict == Verdict.GRANTED) {
                    Purchase p = unconsumed.remove(productId);
                    if (p != null) consume(p);   // recorded server-side; now safe to consume
                }
                // UNKNOWN: nothing to do — the next round verifies it again.
            }
            @Override public void onSyncCompleted(Verdict verdict) {
                // Rights changes also arrive through EntitlementListener.
            }
        });
        client = BillingClient.newBuilder(context)
                .setListener(this)
                .enablePendingPurchases(PendingPurchasesParams.newBuilder()
                        .enableOneTimeProducts().build())
                .build();
    }

    public void connect() {
        client.startConnection(new BillingClientStateListener() {
            @Override public void onBillingSetupFinished(BillingResult result) {
                if (result.getResponseCode() == BillingClient.BillingResponseCode.OK) refresh();
            }
            @Override public void onBillingServiceDisconnected() { /* reconnect with backoff */ }
        });
    }

    /** On connect, in every onResume, and after every completed purchase flow. */
    public void refresh() {
        final int round = purchaseSync.onQueryStarted();
        client.queryPurchasesAsync(
                QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build(),
                (result, purchases) -> {
                    housekeeping(purchases);
                    purchaseSync.onInappPurchases(round, result, purchases);
                });
        client.queryPurchasesAsync(
                QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build(),
                (result, purchases) -> {
                    housekeeping(purchases);
                    purchaseSync.onSubsPurchases(round, result, purchases);
                });
    }

    @Override public void onPurchasesUpdated(BillingResult result, List<Purchase> purchases) {
        // A purchase flow returns a partial picture: start a new round instead.
        if (result.getResponseCode() == BillingClient.BillingResponseCode.OK) refresh();
    }

    private void housekeeping(List<Purchase> purchases) {
        for (Purchase p : purchases) {
            if (p.getPurchaseState() != Purchase.PurchaseState.PURCHASED) continue;
            boolean consumable = false;
            for (String id : p.getProducts()) {
                if (CONSUMABLES.contains(id)) { unconsumed.put(id, p); consumable = true; }
            }
            // Play refunds purchases that aren't acknowledged within 3 days; Keywarden doesn't
            // acknowledge for you. Consumables are acknowledged by consuming them.
            if (!consumable && !p.isAcknowledged()) acknowledge(p);
        }
    }

    private void acknowledge(Purchase p) {
        client.acknowledgePurchase(AcknowledgePurchaseParams.newBuilder()
                .setPurchaseToken(p.getPurchaseToken()).build(), result -> { });
    }

    private void consume(Purchase p) {
        client.consumeAsync(ConsumeParams.newBuilder()
                .setPurchaseToken(p.getPurchaseToken()).build(),
                (result, token) -> { /* credit the coins in your own state */ });
    }
}

Keywarden verifies and records consumable purchases; it does not keep a coin balance for you. PlayPurchaseSync.Listener callbacks run on the main thread, and an exception thrown inside them is swallowed so it can't break the SDK's own callback chain.

Refunds and revokeLocal

  • Server side: Keywarden polls Play's voided-purchases list, so a refund stops counting on the server without any app involvement. The device loses the right on its next successful sync.
  • Refunded but still listed by Play: verification returns 402 VOIDED → REJECTED. PlayPurchaseSync does not revoke for you on a verdict; do it in onPurchaseVerified as above.
  • No longer listed: after a round where both queries succeeded, PlayPurchaseSync calls revokeLocal for the missing products and syncs with storeQueryOk = true, so the server marks those purchases unlisted. A failed or partial round drops nothing.
  • revokeLocal(Collection<String> productIds) takes store product ids. It is persistent, effective immediately (even offline), and only ever removes rights — a tampered client gains nothing by calling it. It hides STORE rows whose product id matches (or whose entitlement id matches, for a row without a product id); GIFT and LICENSE rows are untouched. The deny-list is cleared by the next GRANTED sync(owned, true) that brings a verified bundle; a GRANTED verifyPlayPurchase lifts the entry for that product (so a re-purchase unlocks immediately).
  • Gift tiers are only ever removed on the verification path (a refund); sync can add a gift but never takes one away.

Play Integrity

Optional. With an integrityTokenProvider, every verifyPlayPurchase asks it for a token on the SDK's IO thread, passing a request hash that binds the token to that one verification. The server refuses a token whose requestDetails don't match: requestPackageName must be the app's package name, requestHash (Standard API) or nonce (Classic API) must equal the hash, and timestampMillis must be within 5 minutes. A token minted for another purchase, another device or earlier is useless. Returning null or throwing sends the request without a token, so devices without Play Services are never locked out.

Standard Integrity API (recommended). Prepare a token provider once, then pass the hash verbatim to setRequestHash:

class App : Application() {
    @Volatile private var integrity: StandardIntegrityTokenProvider? = null

    override fun onCreate() {
        super.onCreate()
        IntegrityManagerFactory.createStandard(this)
            .prepareIntegrityToken(
                PrepareIntegrityTokenRequest.builder().setCloudProjectNumber(CLOUD_PROJECT_NUMBER).build()
            )
            .addOnSuccessListener { integrity = it }

        Keywarden.configure(
            Config.Builder(this)
                .apiKey(BuildConfig.KEYWARDEN_PK)
                .publicKey("k1", "MFkwEwYHKoZI…")
                .integrityTokenProvider { requestHash ->
                    // SDK IO thread: blocking is fine, keep the wait short.
                    val provider = integrity ?: return@integrityTokenProvider null
                    val request = StandardIntegrityTokenRequest.builder().setRequestHash(requestHash).build()
                    Tasks.await(provider.request(request), 5, TimeUnit.SECONDS).token()
                }
                .build()
        )
    }
}

Classic Integrity API. Pass the same string, unchanged, as the nonce: IntegrityTokenRequest.builder().setNonce(requestHash). Its 64 lowercase hex characters are already valid URL-safe, non-wrapping Base64 within Classic's 16–500 character limit — don't re-encode it.

The binding contract (kw-integrity-v1), for anyone verifying tokens on their own backend: requestHash is the lowercase hex SHA-256 of the UTF-8 string "kw-integrity-v1|" + packageName + "|" + productId + "|" + purchaseToken + "|" + installId, where packageName is the running app's package name (the applicationId, suffix included). For com.example.app, pro_unlock, play-token-1 and c0ffee00-1234-4a5b-9c6d-7e8f90abcdef it is 637d312d7bb0e841560c951085b27c24ed6e36bb9ecf4ac663da1421e467230e. The purchase token and install id are secrets: never log them, or the hash next to them.

License keys in the app

Keys look like KW-7F3K-9QRT-2MXP-VBC4. Pass whatever the user typed: the server normalizes spaces, dashes and lower case (8–64 characters). See License keys for issuing, seats and statuses.

Keywarden.get().activate(typedKey, (verdict, activation, error) -> {
    if (verdict == Verdict.GRANTED) {
        // Seat taken (or refreshed — activating the same device again is idempotent).
        showActivated(activation.licensePrefix, activation.seatsUsed, activation.seatsMax, activation.expiresAt);
        secureStore.saveLicenseKey(typedKey);    // the SDK doesn't keep the key; deactivate needs it
        return;
    }
    switch (error.code) {
        case "LICENSE_REVOKED":    showError("This key was revoked."); break;
        case "LICENSE_EXPIRED":    showError("This key has expired."); break;
        case "LICENSE_SUSPENDED":  showError("This key is suspended. Contact support."); break;
        case "NOT_FOUND":          showError("We couldn't find that key. Check for typos."); break;
        case "VALIDATION":         showError("That doesn't look like a license key."); break;
        case "NO_SEATS_AVAILABLE": showError("All seats are in use. Deactivate another device first."); break;
        case "RATE_LIMITED":       showError("Too many attempts. Wait a minute and try again."); break;
        default:                   showError("Couldn't reach the license server. Try again."); // NETWORK, 5xx, …
    }
});
CallCallbackNotes
activate(key, cb)(Verdict, Activation, KeywardenException)Activation: id, licensePrefix (e.g. KW-7F3K — safe to display), expiresAt (epoch ms or null), seatsUsed, seatsMax. Only activate can return 410 → REJECTED. 409 NO_SEATS_AVAILABLE is UNKNOWN: the rights this device already holds are untouched.
validate(key, cb)(Verdict, LicenseStatus, KeywardenException)Reads status without taking a seat; if this device already holds one, its heartbeat is refreshed. Returns GRANTED for revoked, suspended and expired licenses too — read status (active | suspended | revoked | expired), plus expiresAt, seatsUsed, seatsMax, entitlements, activated (does this device hold a seat).
deactivate(key, cb)(Verdict, int released, KeywardenException)Releases this device's seat on that key. When released > 0, every LICENSE-sourced right on this device is hidden immediately (the response carries no bundle) until the next verified bundle arrives. If the device holds more than one license, call sync afterwards to bring the others back. released == 0 changes nothing.

UI guidance. Show licensePrefix, never the full key. The SDK doesn't surface the seat numbers from a 409 body — call validate if you want to show "3 of 3 seats in use". Rate limits apply per key and client IP (10 attempts/min), so don't validate on every keystroke. For floating licenses, keep calling sync on launch and resume: a seat without a heartbeat for heartbeatTtlSeconds is released.

Metered features & trials

Server-side counters — "try it 3 times" — defined per app under Metered features in the dashboard. Each counter is per device (per install id).

Keywarden.get().meter("export_pdf").consume((verdict, state, error) -> {
    if (verdict == Verdict.GRANTED && state.allowed) {
        exportPdf();
        showToast(state.left + " of " + state.limit + " free exports left");
    } else if (verdict == Verdict.GRANTED) {
        showPaywall();                       // counter exhausted: allowed == false
    } else {
        showOfflineMessage();                // UNKNOWN: your offline policy
    }
});

// Read without spending — e.g. to label a button.
Keywarden.get().meter("export_pdf").peek((verdict, state, error) -> {
    if (verdict == Verdict.GRANTED) exportButton.setText("Export (" + state.left + " left)");
});
  • MeterState: feature, limit, used, left, allowed, period (lifetime | daily | monthly), resetsAt (epoch ms or null).
  • An exhausted counter is still GRANTED — with allowed == false. Always check both.
  • consume is atomic server-side: concurrent calls can never spend past the limit.
  • An unknown feature key is 404 NOT_FOUND → UNKNOWN. Check error.code during development so a typo doesn't look like a network problem.
  • There is no local cache of counters: peek needs the network too. Decide what UNKNOWN means for your feature (allow without counting, or block) — and if a consume came back UNKNOWN, the unit may or may not have been spent.
  • Periods don't reset yet: counters are currently lifetime counters whatever period says, and resetsAt is always null.
  • Counters follow the install id: a reinstall or "clear data" starts a new install with fresh counters, unless you carry the id over with existingInstallId.

Testing

FakeKeywarden — test your app code without a server

An in-memory client from keywarden-core-testing. Install it where production code calls Keywarden.get(). It never touches the network, needs no Android runtime, and runs every callback synchronously on the calling thread.

public class PaywallTest {
    private final FakeKeywarden fake = new FakeKeywarden();

    @After public void tearDown() { Keywarden.reset(); }

    @Test public void proUserSeesNoPaywall() {
        fake.grant("pro_unlock");                                // Source.STORE, no expiry
        fake.grant("remove_ads", Source.GIFT, null);
        Keywarden.install(fake);
        assertFalse(new Paywall().shouldShow());
    }

    @Test public void offlineSyncKeepsRights() {
        fake.grant("pro_unlock");
        fake.nextVerdict = Verdict.UNKNOWN;
        fake.nextError = new KeywardenException("NETWORK", 0, "offline");
        Keywarden.install(fake);
        new StartupFlow().run();
        assertEquals(1, fake.syncCalls.size());
        assertTrue(Keywarden.get().has("pro_unlock"));
    }
}
MemberPurpose
grant(ent), grant(ent, source, expiresAtMillis), revokeGrant(ent), clear()The truth for local reads (has, entitlements, source, expiresAt).
nextVerdict, nextErrorWhat every network-shaped call reports. nextError must be non-null exactly when nextVerdict isn't GRANTED — the real client can't produce anything else, so the fake throws IllegalArgumentException otherwise.
activation, licenseStatus, released, meterStateResults for activate, validate, deactivate, meters. Must stay null/0 unless nextVerdict is GRANTED.
tampered, verifiedBundle, installId, supporttampered = true empties every local read. verifiedBundle is what hasVerifiedBundle() returns — deliberately independent of grants, so "GRANTED but no bundle arrived" is testable.
syncCalls, verifyCalls, revoked, meterCallsRecorded calls (SyncCall, VerifyCall, MeterCall; Java reads them via getOwnedStoreProducts(), getStoreQueryOk(), …). License calls aren't recorded.
callbacksArrive, flushCallbacks(), pendingCallbackCount()Set callbacksArrive = false to queue callbacks; flush later, or never, to test your timeout and spinner handling.
emitChange()Fires your EntitlementListeners with the current set.

The fake deliberately doesn't re-implement the deny-list, expiry, signature checks or the verdict mapping: revokeLocal is only recorded, and you set outcomes directly. Those rules are covered by the SDK's own test suite.

MockTransport — exercise the real client

To test how your code reacts to real HTTP outcomes, script responses into the real client. It needs an Android runtime (Robolectric) for Context and org.json. Error paths are easy to script; a GRANTED path needs a bundle signed for the test install id by a key you pin.

val transport = MockTransport()
    .on("/v1/store/verify", 402, """{"ok":false,"error":{"code":"VOIDED","message":"refunded"}}""")

val client = DefaultKeywardenClient(
    Config.Builder(ApplicationProvider.getApplicationContext())
        .apiKey("pk_test")
        .publicKey("k1", TEST_SPKI_BASE64)       // build() requires at least one pinned key
        .transport(transport).build(),
    Dispatcher.direct(),     // run requests and callbacks inline, on the test thread
    Clock.SYSTEM,
)

client.verifyPlayPurchase("pro_unlock", "token-1", false) { verdict, error ->
    assertEquals(Verdict.REJECTED, verdict)
    assertEquals("VOIDED", error!!.code)
}
assertEquals("play", JSONObject(transport.lastRequest("/v1/store/verify")!!.body!!).getString("store"))

on(pathSuffix, status, body) queues a response for a path, enqueue(status, body) for the next request of any path, failWith(throwable) simulates "no network" (null to stop), and requests / lastRequest(pathSuffix) show what was sent. An unscripted request gets 599 with code MOCK_UNSCRIPTED rather than a silent success. Dispatcher and Clock live in dev.keywarden.internal: public for tests, not a stable API.

Migrating an existing app

KeyFlow moved from a hand-written license client to this SDK without resetting anyone's trial or gift. The pattern generalizes:

  1. Keep the device identity. Server-side meters and earned gifts are keyed by install id. If your app already generated a random per-install id, pass it with existingInstallId(...). It is adopted only when the SDK has no stored id yet, and ignored afterwards — so it's safe to pass on every launch. It must be 8–128 characters: build() throws IllegalArgumentException for any other length, because the server would reject every call from that install as 400 VALIDATION. If your old id was a hardware id, an e-mail or anything guessable, don't carry it over: it is a secret capability.
  2. Pin the key your installed base already trusts. Use the kid and SPKI from the Setup page — for an app imported from an older license server that can be an imported key rather than k1 (KeyFlow's is legacy).
  3. Ship without a key safely. If some builds have no pk_live_…, skip configure and guard every call with Keywarden.isConfigured(), instead of configuring with a blank key.
  4. Bridge old local grants, then retire them exactly once. Until the SDK has become the authority, an offline first launch after the upgrade may have only your old storage as evidence. Retire the old storage when a sync is GRANTED and hasVerifiedBundle() is true — GRANTED alone doesn't prove a bundle arrived.
  5. If you keep a live-Play fallback (an in-memory "Play says owned this session"), let a server REJECTED override it: Play keeps listing some refunded purchases.
// Application.onCreate
if (BuildConfig.KEYWARDEN_PK.isEmpty()) return;          // no key in this build: SDK stays unconfigured
Keywarden.configure(new Config.Builder(this)
        .apiKey(BuildConfig.KEYWARDEN_PK)
        .appSlug("my-app")
        .publicKey(LEGACY_KID, LEGACY_SPKI_B64)
        .existingInstallId(oldPrefs.getString("install_id", null))
        .storage(new SharedPreferencesStorage(this, true))
        .tamperCheck(() -> ReleaseSignature.matches(this))    // your own check, cached
        .build());

// after a sync
Keywarden.get().sync((verdict, error) -> {
    if (verdict == Verdict.GRANTED && Keywarden.get().hasVerifiedBundle()) {
        legacyStore.retireOnce();                       // irreversible: gate it on a verified bundle
    }
});

Security checklist

  • Never ship an sk_live_… key in an app. Only pk_live_… belongs in the binary; it identifies the app and grants nothing.
  • Treat the install id as a secret. pk_live_… plus a device's install id lets anyone act as that device — pull its bundle, spend its trial counters. Don't log it, send it to analytics or put it in a URL. The SDK generates a random UUID and keeps it in app-private storage.
  • Pin the SPKI; don't fetch keys. The SDK only trusts keys passed to publicKey(...). Don't build a "download the key at startup" path: whoever controls the network would supply their own.
  • Set appSlug so a bundle issued for another of your apps is rejected. Bundles for another install id are always rejected.
  • Gate features on has(), every time. Don't copy its answer into your own preferences, a database or a static flag: anything you persist is editable on a rooted device, while the SDK re-verifies its stored bundle on every start.
  • Use tamperCheck — typically "is this APK signed with my release certificate?" — so a re-signed build answers false from every read, instead of a single if that's trivial to remove. Remember that debug builds are signed with a different key.
  • Supply integrityTokenProvider if you use Play Integrity, and bind each token to the requestHash it receives (how). Depending on the app's integrity settings on the server, a failed check is 403 INTEGRITY_FAILED — reported as UNKNOWN, never as a lockout.
  • Exclude the SDK's state from backups — see below. A restored copy of the install id is a second device with the same identity.
  • Keep https://. The SDK refuses plain HTTP except for local development hosts.
  • Plan key rotation. Pin every key you trust with one publicKey call each. Rotating a signing key on the server makes the new key sign new bundles immediately; app versions that don't pin it get BUNDLE_VERIFICATION_FAILED on every sync and keep their last good bundle only until its exp (the token TTL). So: rotate, immediately ship an update that pins both kids, and keep the old key pinned until old bundles are gone. The server refuses to retire a key used in the last 30 days.

Backups

The default storage is the app-private preferences file keywarden.xml (device-protected storage on API 24+, regular storage below). App-private isn't backup-proof: with android:allowBackup="true" (the Android default) Auto Backup, device-to-device transfer and, on Android 6–11, adb backup copy it — install id included. Nothing to do if your app sets android:allowBackup="false"; otherwise exclude the file (both domains, because of the API 24 split). If you pass your own Storage or file name, exclude that instead.

<!-- res/xml/keywarden_backup_rules.xml — Android 6–11, or targetSdk 30 and lower -->
<full-backup-content>
    <exclude domain="sharedpref" path="keywarden.xml"/>
    <exclude domain="device_sharedpref" path="keywarden.xml"/>
</full-backup-content>

<!-- res/xml/keywarden_data_extraction_rules.xml — Android 12+ with targetSdk 31+ -->
<data-extraction-rules>
    <cloud-backup>
        <exclude domain="sharedpref" path="keywarden.xml"/>
        <exclude domain="device_sharedpref" path="keywarden.xml"/>
    </cloud-backup>
    <device-transfer>
        <exclude domain="sharedpref" path="keywarden.xml"/>
        <exclude domain="device_sharedpref" path="keywarden.xml"/>
    </device-transfer>
</data-extraction-rules>

<!-- AndroidManifest.xml -->
<application
    android:fullBackupContent="@xml/keywarden_backup_rules"
    android:dataExtractionRules="@xml/keywarden_data_extraction_rules"
    tools:targetApi="31">

After a restore without that file, the app starts as a new install: a new install id, no bundle, and has() is false until the first successful sync. Play purchases come back as PlayPurchaseSync re-verifies what the Google account owns; license keys must be activated again (taking a seat); trial counters start fresh.

What an attacker with a rooted device can and can't do

Can'tCan
Forge or edit a bundle — there's no private key on the device, and there's no alg field to downgrade.Patch your APK so has() — or the code calling it — always says yes. No client-side check survives a determined reverse engineer; tamperCheck raises the cost, it doesn't remove the possibility.
Copy a paying device's bundle onto another device (the dev claim must match the local install id).Read their own install id and pk_live_… and call the API as their own device.
Grant rights by editing preferences — the stored bundle is re-verified at startup.Wind the clock back so their last bundle and time-limited rows never expire locally. The server's view is unaffected, and any successful sync replaces the bundle.
Raise their spend total or unlock a gift tier — both are computed server-side from verified purchases.Clear app data or reinstall to get a new install id, and with it fresh trial counters. Counters are per install by design (no hardware ids).
Keep a refunded purchase on the server, or free up seats on someone else's license.Stay offline to keep a refunded purchase for up to one token TTL — unless your app revokes it locally first.
Use a purchase token Play never issued — verification asks Google.Replay their own genuine purchase token from another install. A purchase isn't bound to a single install (it follows the Google account across that user's devices); a refund still revokes it everywhere.

Troubleshooting & FAQ

IllegalStateException from Keywarden.get(). configure hasn't run in this process — often a ContentProvider, a BroadcastReceiver or a test that runs before Application.onCreate finished, or a build without a key. Guard with Keywarden.isConfigured().

IllegalStateException from build(). No publicKey is pinned. Copy the kid and SPKI from the app's Setup page.

IllegalArgumentException from build(). Missing apiKey, a baseUrl that isn't https://, an existingInstallId outside 8–128 characters, or a publicKey whose SPKI doesn't parse (the message names the kid). Messages are in English and meant for developers; match on the exception type (and on KeywardenException.code for call results), not on the text.

Sync says GRANTED but has() is false. In order of likelihood: you're asking for a product id instead of the entitlement key; the product isn't mapped to an entitlement in the catalog; tamperCheck returns false (debug signing key?); the device clock is far ahead, so the bundle looks expired; the product is on the revokeLocal deny-list; or the entitlement's own expiry has passed. hasVerifiedBundle() tells you whether a bundle is present at all.

Every sync is UNKNOWN with BUNDLE_VERIFICATION_FAILED. The server signs with a key you haven't pinned (a rotation, or a copy-paste of the wrong SPKI/kid), or appSlug doesn't match the app's slug.

Every call is UNKNOWN with NETWORK. Missing INTERNET permission; no connectivity; a captive portal; or, in local development against http://10.0.2.2, Android 9+ blocking cleartext traffic — allow it for debug builds with a network security config.

UNAUTHORIZED (401). The pk_live_… is wrong, revoked, or belongs to a deleted app — or BuildConfig got an empty value in this build variant.

VALIDATION (400). On verifyPlayPurchase: the product id isn't sold by this app, the app has no Android package name, or the app requires a Play Integrity token and none was sent. On licenses: a key that can't be a license key.

RATE_LIMITED (429). Limits are 60 requests/min per IP, 600/min per app and 10/min per license key and IP. The SDK doesn't expose the Retry-After header, so back off yourself — and don't call sync or peek in a loop.

PlayPurchaseSync verifies purchases but never syncs. Both queries must report back with the token from the same onQueryStarted(). Check that you query both INAPP and SUBS (even if you sell no subscriptions), and that you pass the round captured before the queries rather than a field that a later round overwrote.

A refunded purchase is still unlocked. Play may still list it. Call revokeLocal on REJECTED in onPurchaseVerified; otherwise it disappears at the next successful sync after the server learns of the refund.

A purchase never unlocks after the purchase flow. PlayPurchaseSync only sees what you query: start a new round from onPurchasesUpdated. A PENDING purchase grants nothing until it becomes PURCHASED.

Callbacks never fire in a unit test. The real client posts callbacks to the main looper. Use FakeKeywarden, or construct DefaultKeywardenClient with Dispatcher.direct().

Can I call configure twice? Yes, but the first client is shut down. Objects that captured the old client (for example a PlayPurchaseSync built with Keywarden.get()) must be recreated: their calls don't crash, but every one reports UNKNOWN with CLIENT_CLOSED.

Does the SDK work without Google Play? Yes. keywarden-core has no Play dependency: license keys, meters and sync work on any Android 6.0+ device.