●SDK58 — The Expo SDK 58 beta is open. It adopts the iOS 27 UIScene lifecycle, and R8 is now on by default for Android release builds●SWIFT — Rork no longer creates new Expo projects. New apps are Swift, Kotlin or React, and existing Expo projects keep building as before●11/01 — Forty-two days remain for anyone who filed an extension on Google Play's target API level. Miss it and new users on newer devices stop seeing the app●EXPOGO — Scanning the QR code in Expo Go stopped working for some people. Since September 3, iOS requires you to be signed in on both the terminal and the app●NEW — What I changed when prebuild stopped and asked for the standard Swift AppDelegate●XCODE27 — Xcode 27 is not required yet. The latest EAS Build image is still 26.6, and the 27 image is listed as coming soon●SDK58 — The Expo SDK 58 beta is open. It adopts the iOS 27 UIScene lifecycle, and R8 is now on by default for Android release builds●SWIFT — Rork no longer creates new Expo projects. New apps are Swift, Kotlin or React, and existing Expo projects keep building as before●11/01 — Forty-two days remain for anyone who filed an extension on Google Play's target API level. Miss it and new users on newer devices stop seeing the app●EXPOGO — Scanning the QR code in Expo Go stopped working for some people. Since September 3, iOS requires you to be signed in on both the terminal and the app●NEW — What I changed when prebuild stopped and asked for the standard Swift AppDelegate●XCODE27 — Xcode 27 is not required yet. The latest EAS Build image is still 26.6, and the 27 image is listed as coming soon
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. What follows is the exact stack I now run on multiple indie apps — how I pick between AppsFlyer and Adjust, and how the pieces get wired into a Rork (Expo + React Native) project that 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.
Before the working version, here's the one I shipped first. It reads perfectly well, and it has two holes in it.
// ❌ My first attempt. Looks fine, loses measurement in two places.let cumulativeScore = 0;export async function reportEvent(event: Event): Promise<void> { const next = Math.min(63, cumulativeScore + SCORE_MAP[event]); if (next > cumulativeScore) { cumulativeScore = next; appsFlyer.updateConversionValueWithErrorCallback(next, (e) => console.warn(e)); }}
The first hole is that cumulativeScore lives in process memory. Kill the app, reopen it the next morning, and it starts from 0 — so every value you compute from there is lower than what you already reported. SKAN accepts monotonically increasing values only, so the low value is dropped. No error, no warning. The shape of the failure is oddly specific: everything up to onboarding looks right, and the 32-point trial signal never arrives.
The second hole is that only fine is ever set. Under SKAN 4.0, fine can only ride the first postback (activity up to two days post-install); postbacks two and three can carry coarse only. If you never set a coarse value, those postbacks come back as coarse: none. That is the most common reason a dashboard shows a healthy window 0 and nothing after it. I had written a whole section about coarse buckets while my code was still SKAN 3.0.
Designing for 4.0 while implementing 3.0 fails silently — the report just goes empty.
Here's the version that closes both.
// src/lib/conversion-value.tsimport AsyncStorage from '@react-native-async-storage/async-storage';import appsFlyer from 'react-native-appsflyer';import { updateSkanConversionValue, type CoarseValue } from './skan';type Event = | 'app_open' | 'onboarding_completed' | 'first_content_view' | 'account_created' | 'trial_started';const SCORE_MAP: Record<Event, number> = { app_open: 1, onboarding_completed: 4, first_content_view: 8, account_created: 16, trial_started: 32,};const STORE_KEY = 'skan:cumulative-fine';// Derive coarse from where fine landed. Tune the cutoffs to your own LTV distribution.function toCoarse(fine: number): CoarseValue { if (fine >= 32) return 'high'; // reached trial if (fine >= 8) return 'medium'; // first content view and beyond return 'low';}async function readScore(): Promise<number> { const raw = await AsyncStorage.getItem(STORE_KEY); const parsed = Number(raw); return Number.isFinite(parsed) ? parsed : 0;}export async function reportEvent(event: Event, payload?: Record<string, unknown>): Promise<void> { // 1. Send the regular event for dashboard analytics appsFlyer.logEvent(event, payload ?? {}, () => {}, (err) => { console.warn('[Attribution] logEvent error', err); }); // 2. Read the cumulative score from storage — a module variable resets to 0 on relaunch const current = await readScore(); const next = Math.min(63, current + SCORE_MAP[event]); if (next <= current) return; await AsyncStorage.setItem(STORE_KEY, String(next)); // 3. Update fine and coarse together; close the window once the trial signal lands await updateSkanConversionValue({ fine: next, coarse: toCoarse(next), lockWindow: event === 'trial_started', });}
updateSkanConversionValue is a thin bridge over SKAdNetwork.updatePostbackConversionValue(_:coarseValue:lockWindow:), available from iOS 16.1. It's about thirty lines with the Expo Modules API, so there's no need to wait for your MMP's SDK to expose it.
// modules/skan/ios/SkanModule.swift — a thin Expo Modules bridgeimport ExpoModulesCoreimport StoreKitpublic class SkanModule: Module { public func definition() -> ModuleDefinition { Name("Skan") AsyncFunction("update") { (fine: Int, coarse: String, lockWindow: Bool, promise: Promise) in // Below iOS 16.1 there is no coarse value at all, so send fine only. guard #available(iOS 16.1, *) else { if #available(iOS 15.4, *) { SKAdNetwork.updatePostbackConversionValue(fine) { error in if let error { promise.reject("skan", error.localizedDescription) } else { promise.resolve(nil) } } } else { promise.resolve(nil) } return } let coarseValue: SKAdNetwork.CoarseConversionValue = { switch coarse { case "high": return .high case "medium": return .medium default: return .low } }() SKAdNetwork.updatePostbackConversionValue(fine, coarseValue: coarseValue, lockWindow: lockWindow) { error in if let error { promise.reject("skan", error.localizedDescription) } else { promise.resolve(nil) } } } }}
One boundary matters here. If you've enabled a server-side conversion-value product such as AppsFlyer's SKAN Conversion Studio, do not also update values from the app. Whichever writes last wins, and debugging that is miserable. Pick one owner for the conversion value — the app or the server — and keep it. I keep it in the app today, though the server side gets more attractive as the number of media partners grows, so I expect to revisit that.
Setting lockWindow: true sends the postback without waiting out the rest of the window. Reserve it for a point where you know the score can't move again, such as trial start; setting it eagerly throws away measurement opportunities for everything that follows.
Expected output: open Cohort Report → SKAN Postbacks in the AppsFlyer dashboard and you'll see a fine distribution in postback window 0, and low/medium/high coarse distributions in windows 1 and 2. As long as windows 1 and 2 read none, your coarse value still isn't landing.
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.
Keeping the cumulative score in memory only. As above, a relaunch resets it to 0 and every later update is dropped by the monotonic guard. To reproduce it locally: reach onboarding-completed, force-quit the app, relaunch, then push through to trial start. If the postback's fine is still stuck at 4, you're in this hole.
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.ts — react-native-adjust v5import { Adjust, AdjustConfig, AdjustEvent } from 'react-native-adjust';export function initAdjust(appToken: string) { const config = new AdjustConfig( appToken, __DEV__ ? AdjustConfig.EnvironmentSandbox : AdjustConfig.EnvironmentProduction, ); config.setLogLevel(__DEV__ ? AdjustConfig.LogLevelDebug : AdjustConfig.LogLevelError); config.setAttributionCallbackListener((attribution) => { console.log('[Adjust] attribution', attribution); }); // v4 used Adjust.create(config). v5 renamed this to initSdk. Adjust.initSdk(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);}
Check your major version before you copy anything. react-native-adjust v5 moved the entry point: the v4 Adjust.create(config) is now Adjust.initSdk(config), and pasting an older sample gets you Adjust.create is not a function at launch. Session-level callback parameters were renamed too — addSessionCallbackParameter became addGlobalCallbackParameter, while the event-level addCallbackParameter stayed put. Adjust's SDK v5 migration guide lists the rest.
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.initSdk 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 AsyncStorage from '@react-native-async-storage/async-storage';import Purchases, { CustomerInfo } from 'react-native-purchases';import { reportEvent } from './conversion-value';const SENT_KEY_PREFIX = 'skan:sent:';// The listener also fires on renewals. This is the gate that keeps each signal to one send.async function sendOnce(event: 'trial_started' | 'paid_started', payload: Record<string, unknown>) { const key = `${SENT_KEY_PREFIX}${event}`; if (await AsyncStorage.getItem(key)) return; await AsyncStorage.setItem(key, '1'); await reportEvent(event, payload);}export function bindRevenueCatToSKAN() { Purchases.addCustomerInfoUpdateListener((info: CustomerInfo) => { const active = info.entitlements.active['pro']; if (!active) return; const payload = { product: active.productIdentifier }; // periodType returns 'TRIAL' / 'NORMAL' / 'INTRO' — mind the casing if (String(active.periodType).toUpperCase() === 'TRIAL') { void sendOnce('trial_started', payload); } else { void sendOnce('paid_started', payload); } });}
In the earlier version, both branches sent trial_started. The comment promised a clamp to the top of the range for the first paid period; the code sent the same event either way. SKAN's monotonic guard swallows the duplicate without an error, so the only symptom is a high bucket that never grows. Read both branches of a conditional out loud — that's the habit I've settled on after this one.
customerInfo also fires on renewals. Sending every time is mostly harmless thanks to the monotonic guard, but your event counts inflate and you lose the ability to tell where the first purchase actually was. Persisting a "sent" flag, as above, pays off the first time you have to reconstruct a cohort from logs. Don't forget to add paid_started to SCORE_MAP.
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.