●MAX — Rork Max generates native Swift for every Apple platform, from iPhone to Vision Pro●NATIVE — It reaches native capabilities like AR/LiDAR, Metal 3D, Dynamic Island, Live Activities, and HealthKit●PUBLISH — Publish to the App Store in two clicks; Rork Max is $200/month●EXPO — Standard Rork builds iOS and Android together via React Native (Expo) and is free to start●PROMPT — Describe your app idea in plain English and Rork generates deployable, store-ready code●PRICE — Standard Rork's paid plans start at $25/month: build with it first, then consider Max for native features●MAX — Rork Max generates native Swift for every Apple platform, from iPhone to Vision Pro●NATIVE — It reaches native capabilities like AR/LiDAR, Metal 3D, Dynamic Island, Live Activities, and HealthKit●PUBLISH — Publish to the App Store in two clicks; Rork Max is $200/month●EXPO — Standard Rork builds iOS and Android together via React Native (Expo) and is free to start●PROMPT — Describe your app idea in plain English and Rork generates deployable, store-ready code●PRICE — Standard Rork's paid plans start at $25/month: build with it first, then consider Max for native features
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.
The first thing I felt right after the iOS 14.5 update was a strange disconnect: I was running ads, but I could no longer see which campaigns were actually working. Where I used to glance at the dashboard and just know, suddenly installs were halved overnight and CPA looked doubled. Users had not gone away — IDFA was simply unavailable, and my measurement was lying to me. It took weeks to track down.
Apps shipped with Rork are no different. If you skip SDK setup or rush the SKAdNetwork 4.0 conversion-value design, every dollar of ad spend becomes a guessing game. In this guide I'm sharing the exact stack I now run on multiple indie apps — from picking between AppsFlyer and Adjust, to wiring everything into a Rork (Expo + React Native) project that actually holds up in production.
Why iOS 14.5+ Made Attribution So Hard
ATT (App Tracking Transparency) effectively killed cross-app IDFA tracking. In its place came SKAdNetwork (SKAN), Apple's own privacy-preserving attribution: ad networks render an ad, the app fires a defined event after install, and Apple returns an anonymized conversion value to the network through its servers.
SKAN 4.0 expanded this dramatically. You can now report a fine value (0–63) and a coarse bucket (low/medium/high), and you get up to three postbacks across windows 0/1/2. That's a much richer LTV signal than 1.0 ever provided — but the schema design now matters several times more than before.
A common misconception is "I'll just bolt on AppsFlyer or Adjust and it will work." It won't. The conversion-value schema is yours to define. The ATT prompt is yours to design. The SDK is a chassis; without thoughtful schemas and consent flow, nothing useful comes out the other end.
AppsFlyer or Adjust? — A Practical Comparison
I've shipped the same app on both AppsFlyer and Adjust at different points, and they have clearly different sweet spots. My current take: if you're a solo developer adopting an MMP for the first time, start with AppsFlyer. If you're routing budget through agencies at scale, Adjust earns its keep.
AppsFlyer's dashboard is intuitive, the SKAN postback visualization is approachable, and there is a free tier that lets you start without a contract. OneLink (their deep-link service) integrates cleanly with TikTok and Meta dashboards. The trade-off is a slightly heavier SDK and the occasional Hermes build slowdown.
Adjust leans enterprise. Its data fidelity, audit logging, and built-in click-fraud protection are excellent, and it shines once monthly ad spend crosses a meaningful threshold. The catch: there's no free plan, pricing is volume-based, and event volume can balloon costs faster than you'd think.
When in doubt, start with AppsFlyer and migrate to Adjust if scale demands it. The SDKs differ, but if you wrap your attribution layer behind a single internal interface, swapping providers later is a contained refactor. This guide centers on AppsFlyer with Adjust-specific notes where they materially differ.
✦
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
✦If your installs have been going to 'null' or 'organic' since iOS 14.5, you'll leave with a working AppsFlyer/Adjust integration in your Rork app
✦You'll have a copy-paste pattern for SKAdNetwork 4.0 conversion values and an ATT consent UI that lifts opt-in rates from ~30% to ~60%
✦You'll be able to make confident spend decisions on TikTok, Meta, and Apple Search Ads using LTV-based campaign optimization loops
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.
Wiring AppsFlyer into a Rork (Expo + React Native) Project
Rork builds on Expo's managed workflow, so adding native modules means using Config Plugins. AppsFlyer ships an official react-native-appsflyer package, and from Expo SDK 51 onward Prebuild handles almost everything for you.
# Step 1: install the packagenpx expo install react-native-appsflyer# Step 2: register the Config Plugin in app.json (required)# It auto-injects SKAdNetworkItems into Info.plist
In app.json, register the plugin and the ATT usage description. Toggle isDebug on while developing — the AppsFlyer logs in the Xcode console make wiring problems obvious.
{ "expo": { "plugins": [ [ "react-native-appsflyer", { "shouldUseStrictMode": true } ] ], "ios": { "infoPlist": { "NSUserTrackingUsageDescription": "We use limited measurement data to deliver content and offers tailored to you. You can change this in Settings at any time." } } }}
The cardinal rule of SDK initialization: only initialize after the ATT status is finalized. If you initialize on app launch, IDFA is still locked, the SDK records empty IDs, and even a later opt-in won't backfill the early data.
// src/lib/attribution.tsimport appsFlyer from 'react-native-appsflyer';import { getTrackingPermissionsAsync, requestTrackingPermissionsAsync, PermissionStatus } from 'expo-tracking-transparency';import { Platform } from 'react-native';const APPSFLYER_DEV_KEY = process.env.EXPO_PUBLIC_APPSFLYER_DEV_KEY!;const APPSFLYER_APP_ID = process.env.EXPO_PUBLIC_APPSFLYER_APP_ID!; // numeric App Store IDexport async function initAttribution(): Promise<void> { // 1. Resolve ATT to a final state (granted or denied) — no-op if already determined if (Platform.OS === 'ios') { const current = await getTrackingPermissionsAsync(); if (current.status === PermissionStatus.UNDETERMINED) { // Show your pre-prompt UI before this call if you have one await requestTrackingPermissionsAsync(); } } // 2. Initialize the SDK only after ATT is finalized (order matters) return new Promise((resolve, reject) => { appsFlyer.initSdk( { devKey: APPSFLYER_DEV_KEY, isDebug: __DEV__, appId: APPSFLYER_APP_ID, onInstallConversionDataListener: true, // first-launch attribution payload onDeepLinkListener: true, // OneLink deep links timeToWaitForATTUserAuthorization: 60, // max seconds to wait for ATT }, (result) => { console.log('[Attribution] init success', result); resolve(); }, (error) => { console.warn('[Attribution] init failed', error); reject(error); }, ); });}
timeToWaitForATTUserAuthorization: 60 is doing real work. The system ATT dialog can take a moment to settle, and if the SDK initializes too eagerly, IDFA goes out empty. Giving the SDK runway to re-read IDFA after consent is what makes the integration robust.
Expected behavior: on first launch, the ATT dialog appears, the user taps Allow, and the install event is fired with both appsflyer_id and idfa correctly attached. You'll see [Attribution] init success in the Xcode console.
Designing SKAdNetwork 4.0 Conversion Values
The conversion-value schema is where you should spend the most design time in SKAN 4.0. fine is 0–63, coarse is low/medium/high. Once campaigns are live, changing the schema fragments your data — so commit to a thoughtful weighting before you start spending.
Here is the baseline I usually start from. Adjust the numbers to your app, but the shape — small score for activation events, larger jumps for monetization signals — generalizes well.
App open: 1 point
Onboarding completed: 4 points
First content view: 8 points
Account created: 16 points
Trial started: 32 points
Map the cumulative score to fine (cap at 63), and bucket coarse so the top 30% of LTV lands in high, the next 40% in medium, and the rest in low. coarse shines in postback window 1 (3–7 days post-install), which is exactly when subscription apps separate winners from losers.
The "must never decrease" property is critical. SKAN only accepts monotonically increasing values; downward updates are silently dropped. Sanity-check on paper that your max score never exceeds 63 and that no event's weight contradicts another.
Expected output: open Cohort Report → SKAN Postbacks in the AppsFlyer dashboard and you'll see the conversion-value distribution split across postback windows 0/1/2.
An ATT Pre-prompt that Actually Lifts Opt-in Rates
ATT opt-in rate is one of the highest-leverage numbers in your app. From what I've seen, raw ATT dialogs get 25–35% opt-in. A well-designed pre-prompt pushes that to 55–65%. The framing matters more than the wording.
The trick is to frame the ask around user benefit, not advertiser benefit. "Required for ads" tanks acceptance. "So we can show you content that fits you" lifts it. I've watched this play out in A/B tests too many times to be a coincidence.
// src/components/PrePromptATT.tsximport { useEffect, useState } from 'react';import { Modal, Text, View, Pressable, StyleSheet } from 'react-native';import { getTrackingPermissionsAsync, requestTrackingPermissionsAsync, PermissionStatus } from 'expo-tracking-transparency';import { initAttribution } from '../lib/attribution';export function PrePromptATT() { const [visible, setVisible] = useState(false); useEffect(() => { (async () => { const { status } = await getTrackingPermissionsAsync(); if (status === PermissionStatus.UNDETERMINED) setVisible(true); })(); }, []); const onAllow = async () => { setVisible(false); await requestTrackingPermissionsAsync(); // OS dialog appears here await initAttribution(); // SDK starts only after consent finalizes }; if (!visible) return null; return ( <Modal animationType="fade" transparent> <View style={styles.backdrop}> <View style={styles.card}> <Text style={styles.title}>Tailor what you see</Text> <Text style={styles.body}> Tap Allow on the next screen so we can show you content and offers that fit your interests. You can change this any time in Settings. </Text> <Pressable onPress={onAllow} style={styles.cta}> <Text style={styles.ctaText}>Continue</Text> </Pressable> </View> </View> </Modal> );}const styles = StyleSheet.create({ backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', padding: 24 }, card: { backgroundColor: '#fff', borderRadius: 16, padding: 24 }, title: { fontSize: 18, fontWeight: '700', marginBottom: 8 }, body: { fontSize: 14, lineHeight: 22, color: '#444', marginBottom: 16 }, cta: { backgroundColor: '#000', paddingVertical: 14, borderRadius: 12, alignItems: 'center' }, ctaText: { color: '#fff', fontSize: 15, fontWeight: '600' },});
Show this pre-prompt at the end of onboarding — after the user has felt the value of the app. Asking immediately on launch reads as "you're being measured before you've done anything," and decline rates climb.
Deep Linking with Universal Links, OneLink, and Adjust Tracker Links
Deep linking is non-negotiable for ad attribution. Universal Links alone don't connect ad-click → install, so pair them with AppsFlyer's OneLink (or Adjust's Tracker Links) to enable Deferred Deep Linking — routing the user to the intended page after the install completes.
// src/lib/deep-link.tsimport appsFlyer from 'react-native-appsflyer';import { router } from 'expo-router';let unsubscribeOnDeepLink: (() => void) | null = null;export function setupDeepLinking() { // Handle inbound OneLink and route to the right screen unsubscribeOnDeepLink = appsFlyer.onDeepLink((res) => { if (res.deepLinkStatus !== 'FOUND') return; const data = res.data as { deep_link_value?: string; sub1?: string }; // deep_link_value uses a "domain:id" convention (e.g. "campaign:summer-sale") if (data.deep_link_value?.startsWith('campaign:')) { const campaignId = data.deep_link_value.split(':')[1]; router.push(`/campaign/${campaignId}` as never); return; } if (data.deep_link_value?.startsWith('content:')) { const contentId = data.deep_link_value.split(':')[1]; router.push(`/content/${contentId}` as never); } }); // Capture first-launch conversion data as well, just in case appsFlyer.onInstallConversionData((res) => { if (res.data?.is_first_launch === 'true' && res.data?.deep_link_value) { const value = String(res.data.deep_link_value); console.log('[DeepLink] first launch with', value); } });}export function teardownDeepLinking() { unsubscribeOnDeepLink?.(); unsubscribeOnDeepLink = null;}
Encoding the destination as domain:id in deep_link_value keeps your routing logic stable across media partners. I keep a small set — campaign:, content:, referral: — and any new ad surface inherits that convention without app-side changes.
Before the first dollar of paid spend, run a full test cycle end-to-end. Discovering misconfiguration after spend has started is expensive in both money and trust.
My pre-launch checklist:
Register your test device in AppsFlyer's "Test Devices" panel (by IDFV or Custom ID).
Run a debug build and confirm logEvent calls show up in Live Events.
Trigger simulated SKAN postbacks under In-app Events → SKAN and verify your conversion values update as designed.
Use a TestFlight build on a separate device, tap a OneLink URL, and confirm Deferred Deep Linking lands on the right screen after install.
Once 1–4 pass, submit to the App Store.
// src/lib/attribution-debug.tsimport appsFlyer from 'react-native-appsflyer';/** * Debug helper for the final pre-release smoke test. * After registering your device under Test Devices, * call this once and confirm the events appear in Live Events. */export function fireDebugEvents() { if (!__DEV__) return; // never run in production builds appsFlyer.logEvent('debug_app_open', { source: 'self_test' }, () => {}, () => {}); appsFlyer.logEvent('debug_onboarding_completed', { variant: 'A' }, () => {}, () => {}); appsFlyer.logEvent('debug_account_created', { provider: 'apple' }, () => {}, () => {});}
Expected output: AppsFlyer's Live Events tab shows the three debug events within 1–2 minutes. That's confirmation that ATT, SDK init, and network reachability are all healthy.
Common Mistakes I've Made (So You Don't Have To)
If your numbers still look off, the cause is almost always one of the following. I've personally hit each of these — keep this list close at release time.
Asking for ATT too early. A launch-time prompt commonly sees 70%+ decline rates. Show it after the user has experienced the app's value — I anchor it to the final onboarding step.
Stale SKAdNetwork ID list. Each ad network has its own SKAdNetwork ID, and any missing entry in SKAdNetworkItems silently kills attribution from that network. react-native-appsflyer injects the latest set, but anything you've added by hand needs a manual refresh — audit it every six months.
Late-stage schema rewrites. SKAN values are monotonic. Reversing weights mid-flight orphans your historical data and turns reports into noise. Lock the schema before you spend.
Hermes / Expo Modules version drift. Skipping the react-native-appsflyer major bump after an Expo SDK upgrade unlinks the native module and crashes the app on launch. Upgrade Expo SDK and the AppsFlyer package together.
Debug events leaking into production. Forget the __DEV__ guard once and your "self test" events corrupt production conversion rates. Wrap debug helpers in if (__DEV__), or call setIsSandbox(true) to isolate them.
Bundle ID drift. I learned this one hard way — a rebrand-time Bundle ID change wasn't mirrored in AppsFlyer, and the first day's installs were lost. Always verify both sides on rename.
Optimization Loops with TikTok, Meta, and Apple Search Ads
Attribution earns its keep when you tune conversion values per channel and let LTV drive spend. Here's how I think about each major surface today.
TikTok Ads — strong for awareness, optimizes well on lower fine scores (open / onboarding completed). Creatives fatigue fast (2–3 weeks), so let coarse tell you which creatives keep producing high-LTV cohorts.
Meta Ads — strong for high-LTV acquisition. Optimizing on the coarse: high bucket raises CPI but ROAS often follows. Advantage+ App Campaigns + SKAN 4.0 has been my best performer.
Apple Search Ads — high intent, high conversion, high retention. Apple Search Ads receives SKAN postbacks directly; pairing the AppsFlyer integration with the Search Ads Attribution API yields the cleanest signal.
If you also run Apple Search Ads at scale, the keyword tiering pattern in Advanced Apple Search Ads Strategy pairs naturally with this attribution stack.
For per-channel conversion-value tuning, use SKAN 4.0's lockWindow to freeze values at postback window 1 and analyze each media partner as its own cohort. AppsFlyer's SKAN Cohort Builder makes this a few-clicks job.
Adjust-Specific Differences and Migration Tips
If you're switching from AppsFlyer to Adjust — or starting fresh with Adjust — the SDK surface looks similar, but conversion-value updates and the callback-parameter model differ in subtle ways. Adjust's addCallbackParameter lets you tag arbitrary key/values onto an event after the fact, which is genuinely useful when, say, only TikTok-sourced installs need a different conversion-value schema.
// src/lib/attribution-adjust.tsimport { Adjust, AdjustConfig, AdjustEvent } from 'react-native-adjust';export function initAdjust(appToken: string) { const config = new AdjustConfig(appToken, AdjustConfig.EnvironmentProduction); config.setLogLevel(__DEV__ ? AdjustConfig.LogLevelDebug : AdjustConfig.LogLevelError); config.setAttributionCallbackListener((attribution) => { console.log('[Adjust] attribution', attribution); }); Adjust.create(config);}export function trackPurchase(eventToken: string, amount: number, currency: string) { const event = new AdjustEvent(eventToken); event.setRevenue(amount, currency); // Conversion-value updates flow through the OS layer; do per-channel slicing via callback params event.addCallbackParameter('plan_id', 'pro_monthly'); Adjust.trackEvent(event);}
The most common stumbling block in an AppsFlyer → Adjust migration is having to re-implement the ATT consent flow. Both SDKs wrap the native ATT API, but their preferred initialization order differs. Adjust will internally wait for ATT to resolve even if you call Adjust.create early, which makes the launch sequence feel marginally snappier than the AppsFlyer equivalent. You may also need to update your CMP rules — Adjust expects "tracking" consent in slightly different terminology than AppsFlyer.
A migration checklist that has saved me time: keep both SDKs running in parallel for a 2-week shadow period, compare daily install totals, and only flip ad networks over to the new MMP after the numbers reconcile to within 2–3%. SKAN's per-device randomization means a small variance is normal; anything wider usually points to schema or token mismatches.
Tying RevenueCat / StoreKit Subscription Events to SKAN
For subscription apps, mapping the trial → paid → cancellation lifecycle into SKAN conversion values is what separates "I ran ads" from "I know which channels actually pay back." If you use RevenueCat, the cleanest wiring is to bridge Purchases.addCustomerInfoUpdateListener directly into your conversion-value updater.
// src/lib/revenuecat-skan-bridge.tsimport Purchases, { CustomerInfo } from 'react-native-purchases';import { reportEvent } from './conversion-value';export function bindRevenueCatToSKAN() { Purchases.addCustomerInfoUpdateListener((info: CustomerInfo) => { const active = info.entitlements.active['pro']; if (!active) return; if (active.periodType === 'trial') { void reportEvent('trial_started', { product: active.productIdentifier }); } else { // First paid period: ensure the cumulative score lands at the top of the SKAN range void reportEvent('trial_started', { product: active.productIdentifier }); } });}
A subtle gotcha: customerInfo fires on subscription renewals too, not just first purchase. If you naively call reportEvent('trial_started') on every renewal, you'll hit SKAN's monotonic-increase guard, the call becomes a no-op, and you'll think nothing's wrong. To send the conversion exactly once, gate the call by checking originalPurchaseDate and persisting an "already reported" flag in AsyncStorage.
The same pattern works for in-app consumables. Map small purchases to a +8 jump in fine, larger ones to +16, and you'll start to see realistic per-channel ROAS in the AppsFlyer or Adjust cohort builder within 2–3 weeks of consistent volume.
Setting Up Server-to-Server Postback Verification
SKAN postbacks are sent from Apple's servers directly to ad networks, but you can — and should — also receive a copy at your own endpoint. A self-hosted S2S landing zone unlocks two things: independent re-analysis later (without being locked into one MMP's reporting), and the ability to detect attribution anomalies that the MMP dashboard might smooth over.
Both AppsFlyer and Adjust offer S2S postback forwarding. A small Cloudflare Workers endpoint is plenty.
// Cloudflare Workers — SKAN postback receiverexport default { async fetch(request: Request, env: { SKAN_KV: KVNamespace }): Promise<Response> { if (request.method !== 'POST') return new Response('OK', { status: 200 }); const payload = await request.json<Record<string, unknown>>(); // Minimal schema, no PII — we only persist what the postback already contains const key = `${payload['source-app-id'] ?? 'unknown'}:${payload['transaction-id']}`; await env.SKAN_KV.put(key, JSON.stringify(payload), { expirationTtl: 60 * 60 * 24 * 90, // 90-day retention }); return new Response('stored', { status: 200 }); },};
Persist into KV (or D1 if you want SQL queries later). When MMP dashboards disagree with each other — which happens — having raw postbacks lets you settle the argument with first-party data. Expected behavior: each SKAN postback writes a JSON record keyed by source-app-id:transaction-id, visible via wrangler kv:key list or your preferred dashboard.
A nice side benefit: when you eventually outgrow the free AppsFlyer tier, you already have the foundation for an in-house reporting layer. The migration from "MMP dashboard" to "MMP + your own warehouse" can be done in days rather than months once the postback pipe exists.
How I Read the Numbers — My Weekly Operating Routine
To close, here's how I actually use the data day to day. Every Monday morning I block out 30 minutes for the dashboard. I look at exactly three things:
Whether last week's conversion-value distribution drifted from the trailing four-week average.
Whether the share of coarse: high matches my expectation for that mix of channels.
Whether any single channel is hitting the fine ceiling of 63 too quickly (an early sign that the schema is too coarse-grained for your top spenders).
I deliberately keep the list short. Drowning in granular reports slows decisions. A jump in low share usually means creatives are fatiguing, so I rotate ad sets that day. A jump in high share earns the channel a 20% budget bump and another week of observation. Fewer signals → faster decisions → more iterations per quarter. That cadence is what makes attribution work for solo developers in particular: you're not optimizing in the abstract, you're feeding a clear, repeatable loop.
If the numbers ever feel too noisy to act on, that's almost always a sign the conversion-value schema is wrong, not that the data is wrong. Revisit the weights, simulate them on paper against your last 30 days of in-app events, and ship a fresh schema before the next campaign refresh. I've shipped at least eight schema iterations on my own apps; the current one is far better than the first, and that's because the loop kept teaching me what mattered.
Closing Thoughts
Attribution looks intimidating from the outside, but once the wiring is in place it changes the speed and quality of every spend decision. Before you close this tab, install react-native-appsflyer in your Rork project and call initAttribution() once — that single working call is the highest-leverage step. Conversion-value design and the ATT pre-prompt are easier to refine once you can see live data.
Channel-side optimization assumes your measurement is honest. Lock down the SDK and conversion values first, then scale spend. With that order in place, even a solo developer can run an attribution stack on par with what an agency would build — and keep evolving it. I still revisit my schema almost monthly. Once you have the shape, the rest is craft.
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.