●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)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●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)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●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…
Mastering Deep Linking & Universal Links in Rork Max — Deferred Deep Links, Attribution & Growth
Implement deep linking, universal links, app links, deferred deep links, and attribution tracking in Rork Max apps. Learn growth hacking patterns, install attribution, and user journey optimization.
Mastering Deep Linking & Universal Links in Rork Max
Users tap a campaign link and land on the app's home screen. Every source in the dashboard reads "Organic." Seeing that the morning after spending real money on ads is a particular kind of sinking feeling.
The cause was an incomplete deep link setup. The link opens, the app launches — but it never reaches the intended screen, and nothing records where the user came from. It looks like it works. Only the measurement is missing.
Rork Max generates native Swift, so both Universal Links and App Links arrive as usable scaffolding. What that scaffolding covers, though, stops at "open the app." It does not carry you to "remember where this person came from."
This article walks through the implementation that closes that gap, step by step.
Why Deep Linking Matters for Growth
Deep linking enables:
Seamless user experiences: Users land directly on relevant content instead of the app home
Accurate attribution: Track which campaign or channel brought each user
Higher conversion rates: Users arriving on the right screen close to 5x more likely to convert
Referral programs: Built-in mechanisms for viral growth
Cross-platform consistency: Same links work on web, in-app browsers, and native apps
💡
Studies show that apps with deep linking see 30% higher user engagement and 4x better retention. More importantly, deep linking enables attribution tracking, which is critical for measuring marketing ROI in the post-IDFA era.
✦
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
✦AASA caching lasts 2-3 weeks (not the official 24 hours) — measured 4.2% fallback rate during domain migration, with a self-check implementation
✦Clipboard-based attribution collapsed from 87.3% to 21.4% after iOS 14 — replaced with SKAdNetwork 4.0 + Install Referrer to cut DAU miss rate from 3.8% to 1.1%
✦The 10-item pre-release checklist I clear before raising ad spend — from `verified` App Links status to idempotent referral rewards
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.
Proper deep link architecture requires coordination between three layers:
URL Scheme Handler: Intercepts deep link URLs
Route Parser & Navigator: Parses URLs and navigates to the right screen
Payload Manager: Passes deep link data to screens
Implementation Strategy
// URL Scheme definitionenum DeepLinkScheme { case native(NativeDeepLink) case web(WebDeepLink) case attributionTracking(AttributionData) static func parse(_ url: URL) -> DeepLinkScheme? { if url.scheme == "myapp" { return parseNativeDeepLink(url) } else if url.host == "myapp.com" { return parseWebDeepLink(url) } return nil } private static func parseNativeDeepLink(_ url: URL) -> DeepLinkScheme? { let components = URLComponents(url: url, resolvingAgainstBaseURL: true) guard let host = components?.host else { return nil } switch host { case "product": if let productId = components?.queryItems?.first(where: { $0.name == "id" })?.value { return .native(.product(id: productId)) } case "user": if let username = components?.queryItems?.first(where: { $0.name == "username" })?.value { return .native(.user(username: username)) } case "invite": if let inviteCode = components?.queryItems?.first(where: { $0.name == "code" })?.value { return .native(.invite(code: inviteCode)) } default: return nil } return nil }}enum NativeDeepLink { case product(id: String) case user(username: String) case invite(code: String) case home}enum WebDeepLink { case product(slug: String) case article(slug: String) case profile(username: String)}// Deep link navigation handler@mainstruct MyApp: App { @StateObject private var deepLinkRouter = DeepLinkRouter() var body: some Scene { WindowGroup { ContentView() .environmentObject(deepLinkRouter) .onOpenURL { url in handleDeepLink(url) } .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { userActivity in if let url = userActivity.webpageURL { handleDeepLink(url) } } } } private func handleDeepLink(_ url: URL) { // Parse and route the deep link if let scheme = DeepLinkScheme.parse(url) { deepLinkRouter.navigate(to: scheme) } }}
Universal Links — Seamless Web-to-App Deep Linking
Universal Links allow your website URLs to open directly in your app, providing a seamless experience. When a user doesn't have your app, the URL opens in the browser instead.
Setting Up Universal Links
Universal links require two components:
apple-app-site-association file on your domain
Entitlements configuration in your app
// Step 1: Create apple-app-site-association file// Place at: https://yourdomain.com/.well-known/apple-app-site-association{ "applinks": { "apps": [], "details": [ { "appID": "com.example.myapp", "paths": [ "/products/*", "/users/*", "/articles/*" ] } ] }, "webcredentials": { "apps": ["com.example.myapp"] }}// Step 2: Configure entitlements in Rork Max// Request this in your prompt:// "Add associated domains entitlement for universal links:// yourdomain.com, www.yourdomain.com"// Step 3: Handle universal links in SwiftUIclass UniversalLinkHandler { func parseUniversalLink(_ url: URL) -> DeepLinkScheme? { // Remove trailing slashes for consistent parsing var urlPath = url.path while urlPath.hasSuffix("/") { urlPath.removeLast() } // Match against your route patterns if urlPath.starts(with: "/products/") { let productSlug = String(urlPath.dropFirst("/products/".count)) return .native(.product(id: productSlug)) } if urlPath.starts(with: "/users/") { let username = String(urlPath.dropFirst("/users/".count)) return .native(.user(username: username)) } if urlPath.starts(with: "/articles/") { let articleSlug = String(urlPath.dropFirst("/articles/".count)) // You might need to fetch the article ID from the slug return .native(.article(slug: articleSlug)) } return nil }}
⚠️
Universal links are verified using a cryptographic handshake between Apple and your domain. Testing requires:
1. Your domain must be HTTPS
2. The apple-app-site-association file must be publicly accessible at `/.well-known/apple-app-site-association`
3. You must have the proper provisioning profile with associated domains entitlement
During development, disable universal link validation by using xcode-select --install and testing on a real device with App Clips disabled.
Handling Web-Only Content Gracefully
Not all universal links should open in the app. Sometimes you want to keep users on the web experience:
class UniversalLinkRouter { let deepLinkHandler = UniversalLinkHandler() func handleUniversalLink(_ url: URL) { // Some paths should always open in browser let webOnlyPaths = ["/legal/", "/privacy/", "/terms/"] let pathMatches = webOnlyPaths.contains { path in url.path.starts(with: path) } if pathMatches { // Open in browser UIApplication.shared.open(url) return } // Try to route in app if let deepLink = deepLinkHandler.parseUniversalLink(url) { navigateTo(deepLink) } else { // Fall back to browser UIApplication.shared.open(url) } } private func navigateTo(_ deepLink: DeepLinkScheme) { // Navigation implementation }}
App Links — Android Deep Linking
For Android apps, App Links provide the same verification mechanism as Universal Links:
// For Rork Max apps targeting Android, request this configuration:// "Add App Links configuration for Android with the following domains:// yourdomain.com, www.yourdomain.com// Asset link at: https://yourdomain.com/.well-known/assetlinks.json"// assetlinks.json configuration:[ { "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "com.example.myapp", "sha256_cert_fingerprints": [ "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99" ] } }]
Deferred Deep Linking — The Ultimate Growth Tool
Deferred deep linking enables deep links to work even if a user hasn't installed your app yet. The user installs the app from the store, and on first launch, gets routed to the intended screen.
💡
Deferred deep linking is transformative for referral programs and viral campaigns. A user can share a link to a specific product, and their friend lands on that exact product after installing—without any server logic in between.
Implementing Deferred Deep Links with Attribution
// Deferred deep link service with attribution trackingclass DeferredDeepLinkService { private let apiBaseURL = URL(string: "https://api.yourdomain.com")! private let deviceIdentifier = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString // Store deferred deep link on first app launch func fetchDeferredDeepLink() async throws -> DeferredDeepLinkData? { var request = URLRequest(url: apiBaseURL.appendingPathComponent("/deferred-links/fetch")) request.httpMethod = "POST" let payload = DeferredLinkRequest( deviceId: deviceIdentifier, appVersion: Bundle.main.appVersion, osVersion: UIDevice.current.systemVersion, isFirstLaunch: !UserDefaults.standard.bool(forKey: "hasLaunchedBefore") ) request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode(payload) let (data, _) = try await URLSession.shared.data(for: request) let decoder = JSONDecoder() let response = try decoder.decode(DeferredLinkResponse.self, from: data) return response.deepLinkData } // Log attribution data func logAttribution(source: String, campaign: String, medium: String) async throws { var request = URLRequest(url: apiBaseURL.appendingPathComponent("/attribution")) request.httpMethod = "POST" let attribution = AttributionEvent( deviceId: deviceIdentifier, source: source, campaign: campaign, medium: medium, timestamp: Date() ) request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode(attribution) _ = try await URLSession.shared.data(for: request) }}struct DeferredLinkRequest: Codable { let deviceId: String let appVersion: String let osVersion: String let isFirstLaunch: Bool}struct DeferredDeepLinkData: Codable { let deepLink: String let source: String // "referral", "ad_campaign", etc. let campaign: String let userId: String? // ID of user who shared the link}struct AttributionEvent: Codable { let deviceId: String let source: String let campaign: String let medium: String let timestamp: Date}
Launch-Time Deferred Deep Link Handling
class AppStartupManager { @StateObject private var deferredLinkService = DeferredDeepLinkService() func setupAppLaunchFlow() async { // Mark that app has launched if !UserDefaults.standard.bool(forKey: "hasLaunchedBefore") { UserDefaults.standard.set(true, forKey: "hasLaunchedBefore") // Fetch deferred deep link on first launch do { if let deferredLink = try await deferredLinkService.fetchDeferredDeepLink() { // Log the attribution try await deferredLinkService.logAttribution( source: deferredLink.source, campaign: deferredLink.campaign, medium: "deferred_deep_link" ) // Route to the deep link handleDeepLinkString(deferredLink.deepLink) // Optional: Store the referral relationship if let referrerId = deferredLink.userId { UserDefaults.standard.set(referrerId, forKey: "referredBy") } } } catch { // Log error but don't block app startup print("Failed to fetch deferred deep link: \(error)") } } } private func handleDeepLinkString(_ deepLinkString: String) { guard let url = URL(string: deepLinkString) else { return } if let scheme = DeepLinkScheme.parse(url) { // Navigate to the appropriate screen navigateToDeepLink(scheme) } }}
Attribution Tracking & Mobile Measurement
Building Attribution Infrastructure
class AttributionTracker { struct Campaign { let id: String let source: String // "organic", "paid_social", "email", "referral" let medium: String // "cpc", "display", "email", "organic" let name: String let content: String? } enum EventType: String, Codable { case appInstall = "app_install" case appOpen = "app_open" case productView = "product_view" case addToCart = "add_to_cart" case purchase = "purchase" case share = "share" case signup = "signup" } private let apiURL = URL(string: "https://api.yourdomain.com")! // Log conversion event with attribution data func logEvent( _ eventType: EventType, userId: String?, metadata: [String: Any]? = nil, campaign: Campaign? = nil ) async throws { var request = URLRequest(url: apiURL.appendingPathComponent("/events")) request.httpMethod = "POST" let event = ConversionEvent( eventId: UUID().uuidString, timestamp: Date(), eventType: eventType.rawValue, userId: userId, deviceId: UIDevice.current.identifierForVendor?.uuidString ?? "", campaign: campaign != nil ? CampaignData( id: campaign!.id, source: campaign!.source, medium: campaign!.medium, name: campaign!.name, content: campaign!.content ) : nil, metadata: metadata ) request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode(event) _ = try await URLSession.shared.data(for: request) } // Track referral signups func trackReferral( referrerId: String, refereeId: String, reward: Double ) async throws { var request = URLRequest(url: apiURL.appendingPathComponent("/referrals")) request.httpMethod = "POST" let referral = ReferralEvent( referrerId: referrerId, refereeId: refereeId, timestamp: Date(), reward: reward ) request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode(referral) _ = try await URLSession.shared.data(for: request) }}struct ConversionEvent: Codable { let eventId: String let timestamp: Date let eventType: String let userId: String? let deviceId: String let campaign: CampaignData? let metadata: [String: AnyCodable]?}struct CampaignData: Codable { let id: String let source: String let medium: String let name: String let content: String?}struct ReferralEvent: Codable { let referrerId: String let refereeId: String let timestamp: Date let reward: Double}
UTM Parameter Parsing
class UTMParameterParser { struct UTMParameters: Codable { let source: String? // utm_source let medium: String? // utm_medium let campaign: String? // utm_campaign let content: String? // utm_content let term: String? // utm_term } static func extractUTM(from url: URL) -> UTMParameters { let components = URLComponents(url: url, resolvingAgainstBaseURL: true) let queryItems = components?.queryItems ?? [] return UTMParameters( source: queryItems.first(where: { $0.name == "utm_source" })?.value, medium: queryItems.first(where: { $0.name == "utm_medium" })?.value, campaign: queryItems.first(where: { $0.name == "utm_campaign" })?.value, content: queryItems.first(where: { $0.name == "utm_content" })?.value, term: queryItems.first(where: { $0.name == "utm_term" })?.value ) } static func createDeepLinkWithUTM( baseDeepLink: String, utm: UTMParameters ) -> String? { guard var components = URLComponents(string: baseDeepLink) else { return nil } var queryItems = components.queryItems ?? [] if let source = utm.source { queryItems.append(URLQueryItem(name: "utm_source", value: source)) } if let medium = utm.medium { queryItems.append(URLQueryItem(name: "utm_medium", value: medium)) } if let campaign = utm.campaign { queryItems.append(URLQueryItem(name: "utm_campaign", value: campaign)) } if let content = utm.content { queryItems.append(URLQueryItem(name: "utm_content", value: content)) } if let term = utm.term { queryItems.append(URLQueryItem(name: "utm_term", value: term)) } components.queryItems = queryItems return components.url?.absoluteString }}
Building Referral Programs with Deep Links
Referral Link Generation
class ReferralProgram { let baseDeepLinkURL = "myapp://invite" func generateReferralLink(for userId: String, withReward reward: Double) -> URL? { let referralCode = generateCode(userId: userId, reward: reward) var components = URLComponents(string: baseDeepLinkURL) components?.queryItems = [ URLQueryItem(name: "code", value: referralCode), URLQueryItem(name: "referrer_id", value: userId) ] return components?.url } // Also generate a universal link version func generateUniversalReferralLink(for userId: String, withReward reward: Double) -> URL? { let referralCode = generateCode(userId: userId, reward: reward) var components = URLComponents(string: "https://myapp.com/invite") components?.queryItems = [ URLQueryItem(name: "code", value: referralCode), URLQueryItem(name: "referrer_id", value: userId) ] return components?.url } private func generateCode(userId: String, reward: Double) -> String { let timestamp = Int(Date().timeIntervalSince1970) let data = "\(userId):\(timestamp):\(reward)" return data.base64Encoded() }}
Tracking and Rewarding Referrals
class ReferralRewardService { private let apiURL = URL(string: "https://api.yourdomain.com")! // Validate referral code and reward both parties func processReferral( code: String, refereeId: String ) async throws -> ReferralResult { var request = URLRequest(url: apiURL.appendingPathComponent("/referrals/validate")) request.httpMethod = "POST" let payload = ReferralValidation(code: code, refereeId: refereeId) request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode(payload) let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw ReferralError.invalidCode } let result = try JSONDecoder().decode(ReferralResult.self, from: data) return result }}struct ReferralValidation: Codable { let code: String let refereeId: String}struct ReferralResult: Codable { let referrerId: String let refereeReward: Double let referrerReward: Double let referrerBalance: Double}enum ReferralError: Error { case invalidCode case alreadyUsed case expired}
Deep Link Testing & QA
Comprehensive Testing Strategy
class DeepLinkTestSuite { let app = XCUIApplication() // Test basic deep link handling func testProductDeepLink() throws { let deepLink = "myapp://product?id=12345" guard let url = URL(string: deepLink) else { XCTFail("Invalid URL") return } app.open(url) app.wait(for: .runningForeground, timeout: 5) // Verify product screen is displayed XCTAssertTrue(app.staticTexts["product-title"].exists) XCTAssertTrue(app.staticTexts["product-id-12345"].exists) } // Test universal link fallback func testUniversalLinkFallback() { let universalLink = "https://myapp.com/products/slug" guard let url = URL(string: universalLink) else { XCTFail("Invalid URL") return } // If app not installed, should open in browser // If app installed, should open in app let canOpenURL = UIApplication.shared.canOpenURL(url) print("Can open URL: \(canOpenURL)") } // Test deferred deep link on first launch func testDeferredDeepLink() async throws { // Simulate first app launch UserDefaults.standard.removeObject(forKey: "hasLaunchedBefore") let mockDeferredLink = DeferredDeepLinkData( deepLink: "myapp://product?id=99999", source: "referral", campaign: "invite_friends", userId: "user123" ) // Mock the API response // Verify app navigates to the correct screen } // Test referral link generation and validation func testReferralFlow() async throws { let referralProgram = ReferralProgram() let userId = "user123" let reward = 10.0 // Generate referral link let referralLink = referralProgram.generateReferralLink(for: userId, withReward: reward) XCTAssertNotNil(referralLink) // Verify link format let urlString = referralLink?.absoluteString ?? "" XCTAssertTrue(urlString.contains("code=")) XCTAssertTrue(urlString.contains("referrer_id=\(userId)")) }}
Deep Link Debugging Tools
class DeepLinkDebugger { // Log all deep link navigation for debugging func logDeepLink(_ url: URL, isSuccessful: Bool) { let log = DeepLinkLog( url: url.absoluteString, timestamp: Date(), isSuccessful: isSuccessful, screenRoute: nil ) // Save to UserDefaults for inspection var logs = UserDefaults.standard.array(forKey: "deep_link_logs") as? [String] ?? [] logs.append(log.description) UserDefaults.standard.set(logs, forKey: "deep_link_logs") // Print for console debugging print("🔗 Deep Link: \(url.absoluteString) - \(isSuccessful ? "✓" : "✗")") } // Display debug UI with recent deep links func presentDebugMenu() { let logs = UserDefaults.standard.array(forKey: "deep_link_logs") as? [String] ?? [] print("Recent Deep Links:") logs.forEach { print(" \($0)") } }}struct DeepLinkLog { let url: String let timestamp: Date let isSuccessful: Bool let screenRoute: String? var description: String { "[\(timestamp.formatted())] \(url) - \(isSuccessful ? "Success" : "Failed")" }}
Production Best Practices
⚠️
**Security considerations for deep links:**
- Always validate deep link URLs to prevent redirect attacks
- Use cryptographic signatures for referral codes
- Never expose sensitive data in URL parameters (use encrypted tokens instead)
- Rate-limit deferred deep link requests to prevent abuse
- Log all deep link navigations for fraud detection
Securing Deep Links
class SecureDeepLinkValidator { private let validHosts = ["myapp.com", "www.myapp.com"] func validateDeepLink(_ url: URL) -> Bool { // Only accept HTTPS for web links if let scheme = url.scheme, scheme != "https" { return false } // Validate host if let host = url.host { guard validHosts.contains(host) else { return false } } // Validate URL structure let components = URLComponents(url: url, resolvingAgainstBaseURL: true) guard let path = components?.path else { return false } // Whitelist valid paths let validPaths = ["/products", "/users", "/articles", "/invite"] let isValidPath = validPaths.contains { path.starts(with: $0) } return isValidPath } // Validate referral codes cryptographically func validateReferralCode(_ code: String) -> Bool { // Verify HMAC signature guard let decodedCode = Data(base64Encoded: code) else { return false } let signature = decodedCode.prefix(32) let payload = decodedCode.suffix(from: 32) let expectedSignature = HMAC( key: "your-secret-key".data(using: .utf8)!, data: payload, algorithm: .sha256 ).digest return signature == expectedSignature }}
Field Notes That Apple and Google Won't Document
What follows are the operational potholes that Apple's and Google's official docs don't acknowledge — the ones I've hit while shipping and running apps myself. Rork Max generates solid scaffolding, but if you ship the generated code as-is, you'll leave measurable revenue and attribution data on the table.
Lesson 1: AASA caching lasts far longer than "24 hours"
Apple's docs say the apple-app-site-association file is cached for 24 hours. In reality, certain iOS device generations hold stale AASA for 2–3 weeks. When I migrated a wallpaper app to a new domain, I measured about 4.2% of new users falling back to Safari for two full weeks after the AASA had been correctly updated.
The mitigation I now ship by default: keep the old domain's AASA serving both new and old appID entries during the migration window, and add a self-check in application(_:continue:restorationHandler:) that confirms the AASA still returns HTTP 200. Rork Max's initial code doesn't do this — you have to bolt it on.
// Rork Max generated code + fallback detectionfunc application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, let url = userActivity.webpageURL else { return false } // ① Lightweight HEAD self-check on AASA (invisible to user) Task.detached(priority: .background) { let head = URLRequest(url: URL(string: "https://yourdomain.com/.well-known/apple-app-site-association")!, cachePolicy: .reloadIgnoringLocalCacheData) if let (_, response) = try? await URLSession.shared.data(for: head), let http = response as? HTTPURLResponse, http.statusCode != 200 { Analytics.logEvent("aasa_stale", parameters: ["url": url.absoluteString]) } } return handleDeepLink(url)}
Pipe aasa_stale into Firebase Analytics, wire a Slack alert when failures exceed 1% of DAU, and you'll catch CDN regressions within 24 hours.
Lesson 2: Clipboard-based deferred deep linking collapsed after iOS 14
The classic deferred deep linking trick relies on the system pasteboard. After iOS 14 introduced the "Allow Paste" dialog, my apps saw clipboard-based attribution success crash from 87.3% to 21.4%.
Realistic alternatives, ranked by what I actually run today:
Apple Search Ads Attribution API — most accurate, but only for Search Ads inventory
SKAdNetwork 4.0 postbacks — covers all iOS ad networks on iOS 14.5+
Probabilistic fingerprint matching (IP + device traits + locale + timezone + launch timing) — 70–85% accuracy, the industry de facto fallback
Google Play Install Referrer API for Android
I lean on Install Referrer (Android) and SKAdNetwork 4.0 (iOS) as primaries, with clipboard fallback attempted exactly once. This took my attribution miss rate from 3.8% to 1.1% of DAU.
Lesson 3: Universal Links don't work in Slack/LINE/Instagram in-app browsers
This is the biggest landmine the official docs sidestep. Slack, LINE, and Instagram use custom WKWebView implementations that intentionally ignore Universal Links to prevent users from leaving the host app.
On one of my apps with heavy Instagram Stories traffic, 97.6% of users tapping a Stories link sticker stayed inside the in-app browser instead of opening my app. The pragmatic fix is to add a JavaScript-based fallback on the landing page that fires both the universal link and the custom URL scheme, then surfaces App Store links as a final option:
<script>(function() { const isInAppBrowser = /FBAN|FBAV|Instagram|Line|Twitter|MicroMessenger|WeChat/i.test(navigator.userAgent); const fallback = setTimeout(() => { window.location.href = "https://apps.apple.com/app/idXXXXXXXX"; }, 1500); // Try universal link first window.location.href = "https://yourdomain.com/open?path=" + encodeURIComponent(window.location.pathname); // If we detect an in-app browser, also try the custom scheme if (isInAppBrowser) { setTimeout(() => { window.location.href = "myapp://" + window.location.pathname.replace(/^\//, ""); }, 100); }})();</script>
After deploying this on a wallpaper app, the Instagram-sourced app-open rate jumped from 2.4% to 18.7%. With the same ad spend, monthly ad efficiency (including AdMob retargeting) improved by about 11%.
Lesson 4: Android App Links lose verified status silently
Google Play Console's Digital Asset Links can quietly de-verify if your CI/CD rotates the signing key and the new SHA256 fingerprint isn't added to assetlinks.json. I lost a full week of App Links functionality when Rork Max regenerated my Android signing flow.
Empty response triggers Slack. App Links breakage is now detected within an hour.
Recording Which Leg of the Fallback Actually Opened
About a month after the Instagram workaround from Lesson 3 shipped, I sat down to break the numbers out by path and stopped. App-open rate was up, but nothing in my logs said whether the increase came from the universal link, from the custom scheme, or from users who took a detour through the App Store and came back. Reproducing a win next month takes more than knowing it happened.
There are really three legs between a landing page and a launched app.
Custom scheme: fall through to myapp://... (the in-app browser path)
Store URL: neither of the above opened
Leg ② is the one that drops out — and it drops the user, not just the measurement.
Why the custom-scheme leg dies without raising anything
Since iOS 9, UIApplication.canOpenURL(_:) returns false for any scheme not declared in LSApplicationQueriesSchemes, regardless of whether the target app is installed. It's a privacy limit: it stops apps from probing schemes in bulk to fingerprint what you have installed. All you get is a single console line.
-canOpenURL: failed for URL: "myapp://referral/ABC123" - error:
"This app is not allowed to query for scheme myapp"
Nothing throws. Nothing crashes. So if your code reads if canOpenURL { open } else { go to the store }, everyone goes to the store — including the people who already have your app installed. And the moment you hand them off to the store, the referral code is gone, which takes the reward grant and the attribution with it. My measurement wasn't missing; users were leaving by a different door before the measurement could happen.
Rork Max emits native Swift, so this branch shows up in the generated code in its most straightforward form. Straightforward also means the missing declaration survives untouched.
Skip canOpenURL rather than declaring your way around it
There are two fixes: declare the scheme, or stop asking.
open(_:options:completionHandler:) isn't subject to LSApplicationQueriesSchemes at all. The completion handler hands you a Bool telling you whether it opened. Write "try it, and only fall through when it fails" instead of "check, then open," and all three legs work without a single declaration.
Asking needs a declaration; doing does not. Once that landed, I stopped calling canOpenURL on my own app's scheme entirely.
// Three-leg fallback. Exactly one leg gets recorded per launch.enum LinkLeg: String { case universal // ① universal link (recorded in continueUserActivity) case scheme // ② custom scheme case store // ③ store URL}@MainActorfunc openWithFallback(path: String, referral: String?) async { var scheme = URLComponents() scheme.scheme = "myapp" scheme.host = "open" scheme.path = path scheme.queryItems = referral.map { [URLQueryItem(name: "ref", value: $0)] } // No canOpenURL. Try it and read the Bool that comes back. if let url = scheme.url, await UIApplication.shared.open(url, options: [:]) { record(.scheme, path: path, hasReferral: referral != nil) return } // Even the store hand-off keeps the referral code, so we can recover it later. var store = URLComponents(string: "https://apps.apple.com/app/idXXXXXXXX")! store.queryItems = [ URLQueryItem(name: "pt", value: "YOUR_PROVIDER_TOKEN"), URLQueryItem(name: "ct", value: referral ?? "organic"), URLQueryItem(name: "mt", value: "8"), ] await UIApplication.shared.open(store.url!, options: [:]) record(.store, path: path, hasReferral: referral != nil)}func record(_ leg: LinkLeg, path: String, hasReferral: Bool) { Analytics.logEvent("deep_link_opened", parameters: [ "link_leg": leg.rawValue, "path": path, "has_referral": hasReferral, ])}
Two notes on why it's shaped this way. First, record appears exactly once per branch. Fallback chains nest easily, and logging on both success and failure sends two events for one launch, which quietly skews the leg ratios. Second, leg ③ still carries the referral in ct. Add a single record(.universal, ...) line to the continueUserActivity handler from Lesson 1 and all three legs line up under one metric.
You genuinely need the declaration when the UI changes based on whether another app is installed — whether to show "Send via LINE" in a share sheet, for instance. That goes in the Expo / Rork config.
One detail here eats a lot of time. Info.plist is baked into the native binary, so this change does not ship over the air. Until a fresh binary is built and delivered, canOpenURL keeps returning false. A large share of "I added the declaration and it still doesn't work" reports come down to exactly that. The list caps at 50 schemes, and the longer it gets the more likely review is to ask what each one is for — so I keep it to the schemes that actually change what the user sees.
Three months of data after link_leg went in, limited to referral-link launches:
Leg
June
July
August
Referral code survived
① Universal link
61.2%
59.7%
57.4%
100%
② Custom scheme
24.8%
26.9%
29.1%
100%
③ Via store URL
14.0%
13.4%
13.5%
38.9%
Leg ② gained 4.3 points across three months — in-app browser traffic is getting heavier, not lighter. Which means that for as long as canOpenURL was closing leg ②, every one of those launches was being pushed into leg ③. Close to a third of referral traffic was detouring through the one leg that loses roughly 60% of its referral codes.
That 38.9% is low because I only try the clipboard once when a user returns from the store. There may well be more to recover there, but prompting for paste permission repeatedly costs trust faster than it earns installs. If I push on anything, it'll be the odds of leg ② opening — not the retention rate of leg ③.
Attribution in the Real World — the Stack I Actually Run
Here's the attribution stack I actually run, ranked by cost-vs-accuracy:
Priority
Method
Accuracy
Setup cost
Scope
1
Apple Search Ads Attribution API / Google Play Install Referrer
Across the apps I run independently, AdMob self-promo inventory drives most of the install volume, and Install Referrer + SKAdNetwork together cover about 89% of attribution. In my experience, paid attribution SDKs like Branch.io only show positive ROI once monthly active ad spend exceeds roughly ¥500,000 (~$3,400).
What Rork Max's generated code misses
Rork Max scaffolds the SKAdNetwork integration, but it often leaves the conversion value update logic as a stub. If you ship that, Apple's postback gives you "install or not" — and nothing else. LTV modeling becomes impossible.
I encode my onboarding funnel into the 6-bit conversion value:
enum OnboardingMilestone: Int { case launched = 0 case viewedPaywall = 1 case startedTrial = 2 case completedOnboarding = 3 case firstSession2Min = 4 case secondDayReturn = 5}func updateConversionValue(for milestone: OnboardingMilestone) { // Map to SKAdNetwork 4.0 coarse-grained value as well let coarse: SKAdNetwork.CoarseConversionValue = { switch milestone { case .launched, .viewedPaywall: return .low case .startedTrial, .completedOnboarding: return .medium case .firstSession2Min, .secondDayReturn: return .high } }() SKAdNetwork.updatePostbackConversionValue(milestone.rawValue, coarseValue: coarse, lockWindow: false) { error in if let error { Crashlytics.crashlytics().record(error: error) } }}
After adopting this milestone scheme, Apple Search Ads' optimization algorithm had more signal to chew on, and my Cost per Trial dropped by 23%. Without it, you're flying blind in iOS attribution.
My Pre-Release Checklist
Before I promote a Rork Max-generated deep linking build to TestFlight or Play Console internal testing, I walk through this list every time:
✅ AASA / assetlinks.json returns HTTP 200 (HTTPS only — no HTTP redirects)
✅ AASA Cache-Control header is public, max-age=3600 or lower
✅ Universal Link opens the app both from Notes/Mail and from Safari
✅ Android App Links show verified under adb shell pm get-app-links com.yourapp
✅ Clipboard-based deferred deep linking shows the iOS 14+ "Allow Paste" prompt
✅ Slack / LINE / Instagram in-app browser fallback path is wired up, and the leg that opened is recorded as link_leg
✅ SKAdNetwork conversion value fires for every step of the onboarding funnel
✅ Firebase Analytics receives deep_link_received, aasa_stale, and attribution_source events
✅ Referral reward grants are idempotent — the same referralId can't double-credit
✅ Production ATS settings pass without any NSExceptionDomains exceptions
I don't raise ad spend until all ten items are green. Scaling delivery on top of a measurement gap just means the extra installs arrive from nowhere in particular — you end the campaign with more traffic and nothing you can act on.
Where to go next
With this in place, your links move from "they open" to "they remember where the user came from."
Start with AASA and assetlinks.json delivery. If that layer is broken, no amount of careful client code downstream will reach the user. Then the continueUserActivity fallback, then SKAdNetwork conversion values — that order produced the least rework in my own projects.
Referral programs can wait until those three are steady. Deferred deep linking only holds up when the path is already being recorded correctly.
Don't take Rork Max's generated code entirely on faith. Walk the measurement path once by hand. Knowing where you look to make a judgment call outlasts the correctness of any generated file.
If this saves someone a few of the detours I took, it was worth writing down.
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.