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
- A surface with a component for the screen the decision applies to. This recipe assumes the slug
mobile-appand anarticlecomponent. Leave the component's CSS selector empty. Selectors target HTML on Web Content surfaces. A native app readsproperties. See Add a surface. - 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
userIdentifieridentity, so your app sends auserJwtfor signed-in readers and an anonymous identifier for everyone else. - An Android project with the
android.permission.INTERNETpermission 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:
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:
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:
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.
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.
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
hideContentfromarticle.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:
{
"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
- Make a Surface Decision in an iOS app
- Surfaces and components
- Component workflows
- Surface Decision API reference
- Browser SDK, which makes the same call from a web page
Decision based on Cookies
Read cookies forwarded by the MonetizationOS proxy in a Surface component workflow and decide what to show.
Make a Surface Decision in an iOS app
Call the Surface Decision API from Swift with a public key, then apply the component properties it returns without blocking your first render.