RORK LABJP
MAX — Rork Max generates native Swift for iPhone, iPad, Apple Watch, Apple TV, and Vision Pro, with 2-click App Store publishing and no Xcode requiredSTACK — Standard Rork builds cross-platform mobile apps with React Native (Expo); choosing between the two by use case is the key decisionFOCUS — Unlike web-first tools such as Bolt or Lovable, Rork specializes in native iOS and Android app generationBUGS — A hands-on review reports Rork resolved about 70% of bugs without manual help, with the remaining 30% needing edits in the exported codebaseFUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz)PRICING — It is free to start, with paid plans from $25/month, so you can try before committingMAX — Rork Max generates native Swift for iPhone, iPad, Apple Watch, Apple TV, and Vision Pro, with 2-click App Store publishing and no Xcode requiredSTACK — Standard Rork builds cross-platform mobile apps with React Native (Expo); choosing between the two by use case is the key decisionFOCUS — Unlike web-first tools such as Bolt or Lovable, Rork specializes in native iOS and Android app generationBUGS — A hands-on review reports Rork resolved about 70% of bugs without manual help, with the remaining 30% needing edits in the exported codebaseFUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz)PRICING — It is free to start, with paid plans from $25/month, so you can try before committing
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.

Rork415SKAdNetwork3ATT8Ad OperationsExpo84Attribution4

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-06-14
Win Back Lapsed Users with App Store In-App Events — Deep Link Implementation for Rork (Expo) Apps
An implementation memo on bringing lapsed users back to a Rork (Expo) app using App Store in-app events. Covers event card design, universal link routing, and measurement, from a solo developer's operational view.
Dev Tools2026-06-16
Landing Users on the Right Screen Right After Install — Deferred Deep Links for Rork Apps
When someone follows a campaign link and installs through the store, the 'where did they come from' context is gone by launch time. Here is how to implement deferred deep linking in a Rork-built app without any third-party SDK.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →