RORK LABJP
SDK58 — The Expo SDK 58 beta is open. It ships the React Native 0.88 release candidate, and the beta period is stated as three to four weeks11/01 — For anyone who requested an extension, Google Play's target API deadline lands on November 1. Forty-four days outEASENV — A long-open report: secrets handed to a local build arrive as the literal variable name rather than its value, and the damage surfaces much laterNEW — The replacement the table recommended had already shut down. A record of reconciling all 74 rows of the deprecation listUISCENE — iOS 27 requires the new scene lifecycle. SDK 57 makes it something you opt into; it only becomes the default in 58CREDIT — What "AI errors don't cost credits" actually covers becomes clear once you record a day of asking for the same fix more than onceSDK58 — The Expo SDK 58 beta is open. It ships the React Native 0.88 release candidate, and the beta period is stated as three to four weeks11/01 — For anyone who requested an extension, Google Play's target API deadline lands on November 1. Forty-four days outEASENV — A long-open report: secrets handed to a local build arrive as the literal variable name rather than its value, and the damage surfaces much laterNEW — The replacement the table recommended had already shut down. A record of reconciling all 74 rows of the deprecation listUISCENE — iOS 27 requires the new scene lifecycle. SDK 57 makes it something you opt into; it only becomes the default in 58CREDIT — What "AI errors don't cost credits" actually covers becomes clear once you record a day of asking for the same fix more than once
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.

Rork569SKAdNetwork3ATT9Ad OperationsExpo209Attribution5

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 $15 for lifetime access
View Membership →

Related Articles

Business2026-06-29
Designing a Referral System Without a Heavy Backend
Add a referral program to your Rork (Expo) app without standing up a fleet of servers. Covers code generation, deferred deep-link attribution on first launch, idempotent reward fulfillment that never double-pays, and basic guards against self-referral and device farming, all with working code.
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.
📚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