◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 26 days left. If your app calls it, check the migration target●SDK 58 — Moves to React Native 0.88. R8 is now on by default in Android release builds, and iOS prebuild generates a SceneDelegate●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 26 days left. If your app calls it, check the migration target●SDK 58 — Moves to React Native 0.88. R8 is now on by default in Android release builds, and iOS prebuild generates a SceneDelegate●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Business
⟐ Business/2026-06-16Advanced

Making Your Rork App's Ad ROI Visible — Mapping SKAdNetwork Conversion Values to Revenue

If you run acquisition ads for a Rork-built app but can't tell which campaign is profitable, the reason is almost always that you never mapped SKAdNetwork conversion values to revenue. Here is the 6-bit design, taken all the way to implementation.

Rork611SKAdNetwork3ATT9Ad OperationsExpo234Attribution5

✦ Premium Article

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.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦An encoding table that assigns the moment revenue happens across the 0–63 six-bit range
✦A native bridge to call updatePostbackConversionValue from an Expo app
✦The lock-window timing that cut my conversion-value loss from the 30s to the low 10s of percent
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ Dev Tools2026-04-26
Mobile Attribution for Rork Apps — A Production Guide to AppsFlyer, Adjust, SKAdNetwork 4.0, and ATT
A complete walkthrough of wiring AppsFlyer or Adjust into a Rork (Expo + React Native) app, designing SKAdNetwork 4.0 conversion values, building a high-opt-in ATT prompt, and integrating deep links — with copy-paste code and the pitfalls I hit in production.
⟐ Business2026-08-25
I Split Rork and Claude Code by Who Owns the Build Environment
How to decide between Rork and a terminal coding agent using data from your own repository instead of impressions of generated code. Includes a script that counts which layer your maintenance work lands in, and a check that catches native settings silently disappearing on regeneration.
⟐ Business2026-04-26
Rork's Free Plan and Free Trial — What You Actually Keep When the Credits Run Out
What ends a Rork free trial is messages, not days. Export while it is still free, verify it builds outside Rork with a small script, then decide about paying.