MonetizationOS Docs

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

  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. Xcode 16 or later, targeting iOS 17 or later for async/await and 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:

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. 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.

AnonymousIdentity.swift
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.

MonetizationOS.swift
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.

ArticleViewModel.swift
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 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, 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:

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