The first time I ran a user-acquisition campaign, the thing that threw me was this: installs were climbing, but I had no idea which spend was actually profitable. On iOS, unless you get ATT (App Tracking Transparency) consent, you can't use the IDFA to tie an ad click to an individual user. AdMob and the ad networks happily report install counts — but never whether those installs led to a paying user.
SKAdNetwork (SKAN) fills that gap. The catch is that SKAN never hands you user-level data; it returns an anonymous, aggregated "campaign × conversion value" number. And what that conversion value (an integer from 0 to 63) actually means is something we developers have to design ourselves. A surprising number of indie developers run ads with that field left blank.
I've shipped wallpaper and wellness apps on the App Store and Google Play for years, and the day I added a real conversion-value design, my ability to allocate ad budget changed completely. Here is that design, in a form you can drop straight into a Rork (Expo / React Native) app.
Treat the conversion value as a "6-bit budget"
A SKAN conversion value is 0 to 63 — just six bits. You have to pack "valuable in-app events" into that narrow band. The classic mistake is to assign every event you can think of to its own value and burn through all 64 slots immediately.
What I use instead is to partition the six bits by role:
- Top 2 bits (×16): revenue stage (no purchase / trial / first purchase / retained payer)
- Middle 2 bits (×4): engagement depth (launch only / reached core feature / day-2 return / notifications granted)
- Bottom 2 bits: auxiliary flags for traffic quality
With this split, a glance at the numbers in your SKAN dashboard lets you read things like "this spend reaches trial but never converts." The hierarchy of meaning is baked into the value itself.
For a subscription app, my encoding looks like this.
// conversionValue.ts — encode the revenue stage into six bits
export type RevenueStage =
| "install" // right after install
| "activated" // reached the core feature
| "trial_started" // free trial started
| "subscribed" // paid subscription confirmed
| "retained_d2"; // returned on day 2
// Top bits = revenue stage (the most important signal)
const STAGE_BITS: Record<RevenueStage, number> = {
install: 0b000000, // 0
activated: 0b000100, // 4
trial_started: 0b010000, // 16
subscribed: 0b110000, // 48
retained_d2: 0b001000, // 8 (OR with activated)
};
// Optionally tuck a rough revenue range into the low bits
function revenueBucket(yen: number): number {
if (yen >= 3000) return 0b11;
if (yen >= 1000) return 0b10;
if (yen > 0) return 0b01;
return 0b00;
}
export function buildConversionValue(
stage: RevenueStage,
yen = 0,
): number {
const cv = STAGE_BITS[stage] | revenueBucket(yen);
return Math.min(63, Math.max(0, cv)); // clamp into 0–63
}The crucial rule: a conversion value can be updated, but in practice only upward. Once you've sent 48 (subscribed), you can't drop back to 4 (launch only). So the highest-revenue stage must get the largest number. I got this backwards at first and watched a paying user's value get overwritten — and erased — by a first-day launch event. A painful lesson.
Calling the native postback from an Expo app
React Native / Expo has no built-in API for SKAN. updatePostbackConversionValue (the SKAN 4.0 API, iOS 16.1+) lives on the Objective-C / Swift side. Rork generates the JS layer, so you add a thin native bridge yourself.
An Expo Config Plugin plus a small Swift file does the job.
// SkanModule.swift — minimal module to update the SKAN 4.0 conversion value
import StoreKit
import ExpoModulesCore
public class SkanModule: Module {
public func definition() -> ModuleDefinition {
Name("Skan")
AsyncFunction("updateConversionValue") {
(value: Int, coarse: String, lockWindow: Bool, promise: Promise) in
if #available(iOS 16.1, *) {
let coarseValue: SKAdNetwork.CoarseConversionValue =
coarse == "high" ? .high : coarse == "medium" ? .medium : .low
SKAdNetwork.updatePostbackConversionValue(
value,
coarseValue: coarseValue,
lockWindow: lockWindow
) { error in
if let error = error {
promise.reject("SKAN_ERR", error.localizedDescription)
} else {
promise.resolve(nil)
}
}
} else {
SKAdNetwork.updateConversionValue(value) // pre-16.1 fine-value API
promise.resolve(nil)
}
}
}
}From JS you call it like this. Passing lockWindow: true at the moment a purchase confirms finalizes the measurement window early so the postback arrives sooner.
import { requireNativeModule } from "expo-modules-core";
import { buildConversionValue } from "./conversionValue";
const Skan = requireNativeModule("Skan");
export async function reportSubscribed(yen: number) {
const cv = buildConversionValue("subscribed", yen);
// Subscription is the final stage: lock the window for immediate aggregation
await Skan.updateConversionValue(cv, "high", true);
}
export async function reportActivated() {
const cv = buildConversionValue("activated");
await Skan.updateConversionValue(cv, "medium", false);
}The coarseValue (low / medium / high) still comes back even for campaigns too small to clear Apple's privacy threshold. For the modest spends typical of indie developers, the fine value (0–63) is frequently rounded to NULL for privacy, so always give the coarse value real meaning too. It's your practical insurance.