MonetizationOS Docs

Make a Surface Decision in an Android app

What you'll build

An Android app that calls the Surface Decision API from Kotlin for each article and applies the properties it gets back. The app carries a stable anonymous identity, so metering works across launches. How you act on the properties is up to you.

1. Prerequisites

  1. A surface with a component for the screen the decision applies to. This recipe assumes the slug mobile-app and an article component. Leave the component's CSS selector empty. Selectors target HTML on Web Content surfaces. A native app reads properties. See Add a surface.
  2. A public key for that environment, from its API Keys screen. Secret keys can't ship in an app binary. A public key also rejects the userIdentifier identity, so your app sends a userJwt for signed-in readers and an anonymous identifier for everyone else.
  3. An Android project with the android.permission.INTERNET permission in its manifest.

2. Return the properties your app reads

Whatever your component workflow returns under properties reaches the app verbatim. This recipe reads a flag and a message:

Component Workflow
const :  = async ({  }) => {
  return {
    : {
      : !.,
      : "Subscribe to read the full article",
    },
  };
};

export default ;

Customer status is only this recipe's example. Any workflow logic works, provided your app decodes the shape the workflow returns.

3. Add the dependencies

This recipe uses OkHttp for the request and kotlinx.serialization for the payloads. Add the serialization plugin and both libraries to your module's build script:

build.gradle.kts
plugins {
    kotlin("plugin.serialization") version "2.1.0"
}

dependencies {
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
}

4. Store an anonymous identifier

Metering counts against an identifier, so generate one on first use and keep it:

AnonymousIdentity.kt
import android.content.SharedPreferences
import java.util.UUID

class AnonymousIdentity(private val preferences: SharedPreferences) {
    /**
     * The stored identifier, generating and persisting one on first use.
     * `UUID.toString()` is already lowercase. Keep that one canonical form if you
     * later reuse the identifier to tie Play Billing purchases to the same reader.
     */
    fun current(): String = synchronized(this) {
        preferences.getString(KEY, null) ?: UUID.randomUUID().toString().also { created ->
            preferences.edit().putString(KEY, created).apply()
        }
    }

    private companion object {
        const val KEY = "mos.anonymousIdentifier"
    }
}

If you're porting from iOS, note that this identifier doesn't survive an uninstall the way a Keychain item does. Android clears app storage on uninstall, and Auto Backup may or may not restore it. A reader can reset their free-article count by reinstalling, so enforce anything that matters server-side.

5. Call the Surface Decision API

Model only the fields you act on. The response also carries features, customer, and surfaceBehavior, which ignoreUnknownKeys discards. Every field below is nullable, so an unprovisioned surface or a partial response falls back to a default instead of failing the decode.

MonetizationOS.kt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit

@Serializable
data class DecisionRequest(
    val surfaceSlug: String,
    val identity: Identity,
    val resource: Resource,
) {
    @Serializable
    data class Identity(
        val anonymousIdentifier: String? = null,
        val userJwt: String? = null,
    )

    @Serializable
    data class Resource(val id: String, val fullPath: String)
}

@Serializable
data class SurfaceDecision(
    val componentBehaviors: Map<String, ComponentBehavior> = emptyMap(),
) {
    @Serializable
    data class ComponentBehavior(val properties: Properties? = null) {
        @Serializable
        data class Properties(
            val hideContent: Boolean? = null,
            val upsellMessage: String? = null,
        )
    }
}

class MonetizationOS(
    private val publicKey: String,
    private val surfaceSlug: String,
    private val siteBaseUrl: String,
    private val identity: AnonymousIdentity,
    private val client: OkHttpClient = OkHttpClient.Builder()
        // The decision never blocks the UI, so it should fail fast. OkHttp sets
        // no overall call timeout by default, only per-phase ones.
        .callTimeout(8, TimeUnit.SECONDS)
        .build(),
) {
    private val json = Json {
        explicitNulls = false
        ignoreUnknownKeys = true
    }

    suspend fun decide(articlePath: String, userJwt: String? = null): SurfaceDecision =
        withContext(Dispatchers.IO) {
            // Send exactly one identity field: the JWT for a signed-in reader,
            // otherwise the stored identifier.
            val identityPayload = if (userJwt != null) {
                DecisionRequest.Identity(userJwt = userJwt)
            } else {
                DecisionRequest.Identity(anonymousIdentifier = identity.current())
            }

            val payload = json.encodeToString(
                DecisionRequest(
                    surfaceSlug = surfaceSlug,
                    identity = identityPayload,
                    resource = DecisionRequest.Resource(articlePath, siteBaseUrl + articlePath),
                ),
            )

            val request = Request.Builder()
                .url(DECISIONS_URL)
                .header("Authorization", "Bearer $publicKey")
                .post(payload.toRequestBody(JSON_MEDIA_TYPE))
                .build()

            client.newCall(request).execute().use { response ->
                val body = response.body?.string()
                check(response.isSuccessful && body != null) {
                    "Surface decision failed: ${response.code}"
                }
                json.decodeFromString<SurfaceDecision>(body)
            }
        }

    private companion object {
        const val DECISIONS_URL = "https://api.monetizationos.com/api/v1/surface-decisions"
        val JSON_MEDIA_TYPE = "application/json".toMediaType()
    }
}

explicitNulls = false omits the identity field you left unset instead of sending it as null.

Anything you add to resource beyond id is passed to the workflow's resource argument, so send whatever your logic needs. fullPath lets a workflow match on the same URL as the web surface.

6. Apply the decision

Render the article from whatever default the app already knows, then apply the decision when it arrives. Awaiting the call before the first composition means a slow connection delays the screen and a dropped one leaves it empty.

ArticleViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

data class ArticleState(val hideContent: Boolean, val upsellMessage: String? = null)

class ArticleViewModel(
    private val article: Article,
    private val monetizationOS: MonetizationOS,
) : ViewModel() {
    // Seeded from the app's own default so the first composition needs no network.
    private val _state = MutableStateFlow(ArticleState(hideContent = article.premium))
    val state: StateFlow<ArticleState> = _state.asStateFlow()

    fun refreshDecision(userJwt: String? = null) {
        viewModelScope.launch {
            _state.value = try {
                val decision = monetizationOS.decide("/articles/${article.slug}", userJwt)
                val properties = decision.componentBehaviors["article"]?.properties
                ArticleState(
                    hideContent = properties?.hideContent ?: article.premium,
                    upsellMessage = properties?.upsellMessage,
                )
            } catch (cancellation: CancellationException) {
                throw cancellation // never swallow coroutine cancellation
            } catch (error: Exception) {
                // No decision, so fall back to the app's own default: gate premium,
                // open everything else.
                ArticleState(hideContent = article.premium)
            }
        }
    }
}

Call refreshDecision again whenever the reader signs in or out, so the decision matches the current identity.

When to hold the render instead

A decision that hides or truncates arrives after the content has composed. That's fine for a banner or a meter. For content the reader shouldn't see, it's too late. Two options, and you can use both:

  • Seed pessimistically. The view model above seeds hideContent from article.premium, so premium content is gated before any network call and the decision can only open it. Use this wherever the app knows enough to guess safely.
  • Hold the gated region. When it can't guess, compose the screen but keep the article body behind a placeholder until the decision arrives. The Browser SDK does the web equivalent with its anti-flicker cloak.

Bound the wait either way. The cloak lifts on a timer whether or not the decision arrives, and a native hold should do the same. Phones lose signal mid-request, and a hold with no ceiling leaves the reader looking at a placeholder.

Result

The decision returns the properties your component workflow set, keyed by the component slug:

Response
{
  "status": "success",
  "eventId": "941c5b52-39ff-4dab-9031-e39ef37e061a",
  "identity": {
    "authType": "anonymous",
    "identifier": "9f2c41e0-5b3a-4d81-9c77-1a0e6b8d3f42",
    "isAuthenticated": false,
    "jwtClaims": {}
  },
  "customer": { "isCustomer": false, "hasProducts": false, "customerIdentifiers": [] },
  "features": {},
  "surfaceBehavior": {},
  "componentsSkipped": false,
  "componentBehaviors": {
    "article": {
      "properties": {
        "hideContent": true,
        "upsellMessage": "Subscribe to read the full article"
      },
      "metadata": { "cssSelector": null }
    }
  }
}

The identifier echoed back is the one your app generated, and every later call from that install sends the same value.

Production surfaces usually return more than a flag. A workflow can resolve a whole experience into a named property, which the app decodes by its type and renders natively. Nothing else on this page changes.

Additional resources

On this page