Make a Surface Decision in an iOS app
What you'll build
An iOS app that calls the Surface Decision API from Swift 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. - Xcode 16 or later, targeting iOS 17 or later for
async/awaitand Observation.
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. Store an anonymous identifier in the Keychain
Metering counts against an identifier, so it needs to survive an uninstall. Keychain items do. UserDefaults doesn't, so a reader could clear their free-article count by reinstalling.
import Foundation
import Security
enum AnonymousIdentity {
private static let service = "com.example.news"
private static let account = "mos.anonymousIdentifier"
private static let lock = NSLock()
/// The stored identifier, generating and persisting one on first use. Lowercased
/// so it stays byte-identical to Apple's `appAccountToken` representation if
/// you later reuse it to tie StoreKit purchases to the same identity.
static func current() -> String {
lock.lock()
defer { lock.unlock() }
if let existing = read() { return existing }
let created = UUID().uuidString.lowercased()
write(created)
return created
}
private static var query: [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
}
private static func read() -> String? {
var lookup = query
lookup[kSecReturnData as String] = true
lookup[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
guard SecItemCopyMatching(lookup as CFDictionary, &item) == errSecSuccess,
let data = item as? Data
else { return nil }
return String(data: data, encoding: .utf8)
}
private static func write(_ value: String) {
guard let data = value.data(using: .utf8) else { return }
if SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) == errSecItemNotFound {
var insert = query
insert[kSecValueData as String] = data
SecItemAdd(insert as CFDictionary, nil)
}
}
}Keychain failures return a fresh identifier instead of throwing. A lost identifier resets that reader's meter, which is a better outcome than failing to launch.
4. Call the Surface Decision API
Model only the fields you act on. The response also carries features, customer, and surfaceBehavior, which JSONDecoder ignores. Every field below is optional, so an unprovisioned surface or a partial response falls back to a default instead of failing the decode.
import Foundation
struct DecisionRequest: Encodable {
struct Identity: Encodable {
var anonymousIdentifier: String?
var userJwt: String?
}
struct Resource: Encodable {
var id: String
var fullPath: String
}
var surfaceSlug: String
var identity: Identity
var resource: Resource
}
struct SurfaceDecision: Decodable {
struct ComponentBehavior: Decodable {
struct Properties: Decodable {
var hideContent: Bool?
var upsellMessage: String?
}
var properties: Properties?
}
var componentBehaviors: [String: ComponentBehavior]?
}
enum MonetizationOS {
private static let url = URL(string: "https://api.monetizationos.com/api/v1/surface-decisions")!
private static let siteBaseURL = "https://news.example.com"
private static let publicKey = "pk_live_..."
private static let surfaceSlug = "mobile-app"
/// The decision never blocks the UI, so it should fail fast. `URLSession`
/// defaults to 60 seconds, long enough for a stalled request to look like
/// a hung app.
private static let timeout: TimeInterval = 8
static func decide(articlePath: String, userJwt: String? = nil) async throws -> SurfaceDecision {
// Send exactly one identity field: the JWT for a signed-in reader,
// otherwise the Keychain identifier.
let identity = DecisionRequest.Identity(
anonymousIdentifier: userJwt == nil ? AnonymousIdentity.current() : nil,
userJwt: userJwt
)
var request = URLRequest(url: url, timeoutInterval: timeout)
request.httpMethod = "POST"
request.setValue("Bearer \(publicKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(
DecisionRequest(
surfaceSlug: surfaceSlug,
identity: identity,
resource: .init(id: articlePath, fullPath: siteBaseURL + articlePath)
)
)
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(SurfaceDecision.self, from: data)
}
}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.
5. 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 frame means a slow connection delays the screen and a dropped one leaves it empty.
import Observation
@MainActor
@Observable
final class ArticleViewModel {
private let article: Article
/// Seeded from the app's own default so the first render needs no network.
private(set) var hideContent: Bool
private(set) var upsellMessage: String?
init(article: Article) {
self.article = article
self.hideContent = article.premium
}
func refreshDecision(userJwt: String? = nil) async {
do {
let decision = try await MonetizationOS.decide(
articlePath: "/articles/\(article.slug)",
userJwt: userJwt
)
let properties = decision.componentBehaviors?["article"]?.properties
hideContent = properties?.hideContent ?? article.premium
upsellMessage = properties?.upsellMessage
} catch {
// No decision, so fall back to the app's own default: gate premium,
// open everything else.
hideContent = article.premium
upsellMessage = nil
}
}
}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 painted. 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, render 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 Android app
- Surfaces and components
- Component workflows
- Surface Decision API reference
- Browser SDK, which makes the same call from a web page
Make a Surface Decision in an Android app
Call the Surface Decision API from Kotlin with a public key, then apply the component properties it returns without blocking your first render.
Fetch Content from an external system
Fetch data from an external API in a Surface component workflow and display it.