Introduction

Deep linking is the invisible infrastructure that connects your marketing campaigns, user sharing, and onboarding flows. When implemented correctly, users click a link and land exactly where they need to be—whether they’re opening your app for the first time or the hundredth.

In September 2024, deep linking isn’t optional for serious mobile apps. Every marketing email, every social media campaign, every referral link depends on deep linking to track attribution and deliver seamless user experiences. Get it wrong, and you’re sending users to your app’s home screen, losing context, and missing attribution data. Get it right, and you’ve built a frictionless path from discovery to conversion.

This guide covers everything you need to implement production-ready deep linking: universal links for iOS, app links for Android, deferred deep linking for new users, attribution tracking, and the inevitable debugging challenges you’ll face. We’ll focus on real-world implementation patterns that work across React Native and native apps, with special attention to Australian market considerations.

Why Deep Linking Matters for Your App

Why Deep Linking Matters for Your App Infographic

The User Experience Problem

Without deep linking, every link click follows this frustrating path:

  1. User clicks promotional link → App opens
  2. App shows home screen
  3. User manually navigates to intended content (if they remember what they clicked)
  4. Conversion opportunity lost

With proper deep linking:

  1. User clicks promotional link → App opens directly to relevant content
  2. User completes intended action
  3. Attribution tracked correctly
  4. Conversion rates improve 30-50%

The Attribution Problem

Marketing campaigns without deep linking attribution are flying blind. You’re spending money on user acquisition without knowing which channels drive actual in-app behavior. Deep linking solves this by:

  • Tracking which campaign brought users to your app
  • Measuring conversion from click to in-app action
  • Attributing revenue to specific marketing efforts
  • Optimizing spend based on actual performance data

For Australian apps especially, where marketing budgets are often smaller and competition is fierce, attribution accuracy directly impacts sustainability.

The Technical Reality

Modern deep linking requires three complementary systems:

URI Schemes: Simple but limited

  • Custom URLs like yourapp://product/123
  • Work only when app is installed
  • No fallback to web
  • Easiest to implement

Universal Links (iOS) / App Links (Android): Production standard

  • HTTPS URLs like https://yourapp.com.au/product/123
  • Seamless fallback to website
  • Better security and user trust
  • Require server configuration

Deferred Deep Links: New user acquisition

  • Preserve link context through app installation
  • Critical for paid acquisition campaigns
  • Require third-party service or custom implementation

Implementing URI Schemes: The Found

Implementing URI Schemes: The Foundation Infographic ation

URI schemes are the simplest deep linking method. While not production-ready on their own, they’re essential for handling app-to-app communication and serve as your fallback mechanism.

iOS URI Scheme Setup

1. Register Your URL Scheme

In Xcode, select your target → Info → URL Types → Add new URL Type:

  • Identifier: com.yourcompany.yourapp
  • URL Schemes: yourapp (must be unique)
  • Role: Editor

Alternatively, edit Info.plist directly:

<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>yourapp</string>
        </array>
        <key>CFBundleURLName</key>
        <string>com.yourcompany.yourapp</string>
    </dict>
</array>

Important: Choose a unique scheme. Generic names like shop or app will conflict with other apps. Use your brand name or a variation: awesomeapp, yourcompany, etc.

2. Handle Incoming Links

For UIKit apps, implement in AppDelegate.swift:

func application(_ app: UIApplication,
                 open url: URL,
                 options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {

    // Validate the URL scheme
    guard url.scheme == "yourapp" else {
        return false
    }

    // Parse URL components
    guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
        return false
    }

    // Route based on host and path
    switch components.host {
    case "product":
        if let productId = components.queryItems?.first(where: { $0.name == "id" })?.value {
            navigateToProduct(id: productId)
            return true
        }
    case "category":
        if let category = components.queryItems?.first(where: { $0.name == "name" })?.value {
            navigateToCategory(name: category)
            return true
        }
    case "user":
        if let userId = components.queryItems?.first(where: { $0.name == "id" })?.value {
            navigateToProfile(userId: userId)
            return true
        }
    default:
        // Unknown route - navigate to home
        navigateToHome()
    }

    return false
}

For SwiftUI apps using the App protocol:

import SwiftUI

@main
struct YourApp: App {
    @StateObject private var router = DeepLinkRouter()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(router)
                .onOpenURL { url in
                    handleDeepLink(url: url)
                }
        }
    }

    private func handleDeepLink(url: URL) {
        guard url.scheme == "yourapp" else { return }

        if let components = URLComponents(url: url, resolvingAgainstBaseURL: true) {
            router.route(to: DeepLink(from: components))
        }
    }
}

3. Test Your Implementation

# iOS Simulator
xcrun simctl openurl booted "yourapp://product?id=123"

# Physical device via Safari
# Navigate to yourapp://product?id=123 in Safari

Android URI Scheme Setup

1. Define Intent Filters

In AndroidManifest.xml, add intent filters to your main activity:

<activity
    android:name=".MainActivity"
    android:exported="true"
    android:launchMode="singleTask">

    {/* Existing launcher intent */}
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>

    {/* Deep link intent filter for products */}
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data
            android:scheme="yourapp"
            android:host="product" />
    </intent-filter>

    {/* Deep link intent filter for categories */}
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data
            android:scheme="yourapp"
            android:host="category" />
    </intent-filter>

    {/* Deep link intent filter for user profiles */}
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data
            android:scheme="yourapp"
            android:host="user" />
    </intent-filter>
</activity>

Important: android:launchMode="singleTask" ensures only one instance of your activity exists, preventing duplicate screens when handling deep links.

2. Handle Deep Links in Your Activity

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // Handle initial intent
        handleIntent(intent)
    }

    override fun onNewIntent(intent: Intent?) {
        super.onNewIntent(intent)
        // Handle new intent when activity is already running
        intent?.let { handleIntent(it) }
    }

    private fun handleIntent(intent: Intent) {
        val action = intent.action
        val data = intent.data

        // Only process VIEW intents with data
        if (action == Intent.ACTION_VIEW && data != null) {
            handleDeepLink(data)
        }
    }

    private fun handleDeepLink(uri: Uri) {
        when (uri.host) {
            "product" -> {
                val productId = uri.getQueryParameter("id")
                if (productId != null) {
                    navigateToProduct(productId)
                } else {
                    navigateToHome()
                }
            }
            "category" -> {
                val categoryName = uri.getQueryParameter("name")
                if (categoryName != null) {
                    navigateToCategory(categoryName)
                } else {
                    navigateToHome()
                }
            }
            "user" -> {
                val userId = uri.getQueryParameter("id")
                if (userId != null) {
                    navigateToProfile(userId)
                } else {
                    navigateToHome()
                }
            }
            else -> {
                // Unknown route - default to home
                navigateToHome()
            }
        }
    }

    private fun navigateToProduct(productId: String) {
        // Your navigation logic
        val fragment = ProductDetailFragment.newInstance(productId)
        supportFragmentManager.beginTransaction()
            .replace(R.id.container, fragment)
            .commit()
    }

    private fun navigateToCategory(category: String) {
        // Your navigation logic
    }

    private fun navigateToProfile(userId: String) {
        // Your navigation logic
    }

    private fun navigateToHome() {
        // Default navigation
    }
}

3. Test Your Implementation

# Android emulator or physical device
adb shell am start -W -a android.intent.action.VIEW -d "yourapp://product?id=123" com.yourcompany.yourapp

# Or create a test HTML file and open in Chrome
# <a href="yourapp://product?id=123">Test Deep Link</a>

Universal Links (iOS): Production-Ready Implementation Infographic Implementation

Universal Links are Apple’s solution for seamless deep linking using standard HTTPS URLs. When configured correctly, tapping https://yourapp.com.au/product/123 opens your app if installed, or your website if not.

Security: Apple verifies domain ownership via HTTPS, preventing URL hijacking

User Trust: HTTPS URLs look legitimate compared to custom schemes

SEO Benefits: Same URLs work for web and app, improving search rankings

Fallback: Graceful degradation to website when app isn’t installed

Implementation Steps

1. Configure Apple App Site Association (AASA) File

Create apple-app-site-association (no file extension) and host it at: https://yourapp.com.au/.well-known/apple-app-site-association

{
    "applinks": {
        "apps": [],
        "details": [
            {
                "appID": "TEAMID.com.yourcompany.yourapp",
                "paths": [
                    "/product/*",
                    "/category/*",
                    "/user/*",
                    "/promo/*",
                    "NOT /admin/*",
                    "NOT /api/*"
                ]
            }
        ]
    }
}

Critical requirements:

  • Served over HTTPS with valid SSL certificate
  • No redirects (Apple fetches file directly)
  • Content-Type: application/json or application/pkcs7-mime
  • Maximum file size: 128 KB
  • Must be accessible without authentication

Finding your Team ID and Bundle ID:

# Team ID: Found in Apple Developer account
# Or in Xcode: Select project → Signing & Capabilities → Team

# Bundle ID: Found in Xcode → General → Identity → Bundle Identifier
# Format: com.yourcompany.yourapp
# AppID format: TEAMID.BUNDLEID

Testing AASA file accessibility:

# Verify file is accessible
curl -I https://yourapp.com.au/.well-known/apple-app-site-association

# Should return 200 OK
# Content-Type: application/json

# Validate AASA file
# Apple's CDN caches your file here:
# https://app-site-association.cdn-apple.com/a/v1/yourapp.com.au

2. Configure Your iOS App

In Xcode → Signing & Capabilities → Add Capability → Associated Domains:

applinks:yourapp.com.au
applinks:www.yourapp.com.au

Important: Don’t include https:// or trailing slashes. Just the domain.

3. Handle Universal Links in Your App

For UIKit apps, implement in AppDelegate.swift:

func application(_ application: UIApplication,
                 continue userActivity: NSUserActivity,
                 restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {

    // Only handle web browsing activities
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
          let url = userActivity.webpageURL else {
        return false
    }

    return handleUniversalLink(url: url)
}

func handleUniversalLink(url: URL) -> Bool {
    guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
        return false
    }

    // Parse path components
    let pathComponents = components.path.split(separator: "/").map(String.init)

    guard !pathComponents.isEmpty else {
        navigateToHome()
        return true
    }

    switch pathComponents[0] {
    case "product":
        if pathComponents.count > 1 {
            let productId = pathComponents[1]
            navigateToProduct(id: productId)
            return true
        }
    case "category":
        if pathComponents.count > 1 {
            let categoryName = pathComponents[1]
            navigateToCategory(name: categoryName)
            return true
        }
    case "user":
        if pathComponents.count > 1 {
            let userId = pathComponents[1]
            navigateToProfile(userId: userId)
            return true
        }
    case "promo":
        if let code = components.queryItems?.first(where: { $0.name == "code" })?.value {
            applyPromoCode(code: code)
            return true
        }
    default:
        break
    }

    // If we can't handle the link, return false to open in Safari
    return false
}

For SwiftUI apps:

import SwiftUI

@main
struct YourApp: App {
    @StateObject private var router = DeepLinkRouter()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(router)
                .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { userActivity in
                    if let url = userActivity.webpageURL {
                        handleUniversalLink(url: url)
                    }
                }
        }
    }

    private func handleUniversalLink(url: URL) {
        if let deepLink = DeepLink.parse(from: url) {
            router.route(to: deepLink)
        }
    }
}

4. Test Universal Links

Critical: Universal Links only work when clicked from another app, not typed into Safari’s address bar or clicked from the same app.

Test methods:

# 1. Send yourself an email or iMessage with the link
# 2. Click the link - should open your app

# 3. Create a test webpage:
cat > test.html << 'EOF'
<!DOCTYPE html>
<html>
<head><title>Universal Link Test</title></head>
<body>
    <h1>Test Universal Links</h1>
    <a href="https://yourapp.com.au/product/123">Product 123</a><br>
    <a href="https://yourapp.com.au/category/electronics">Electronics Category</a>
</body>
</html>
EOF

# 4. Host this file and open on device
# 5. Click links - should open your app

Debugging Universal Links:

Long-press a link to see options. If you see “Open in [Your App]”, it’s configured correctly. If it always opens in Safari:

  1. Check AASA file is accessible at correct URL
  2. Verify Team ID and Bundle ID match exactly
  3. Confirm Associated Domains capability is enabled
  4. Check iOS 14+ Privacy settings: Settings → [Your App] → Universal Links
  5. Try deleting and reinstalling the app (AASA cache refresh)

App Links are Android’s equivalent to iOS Universal Links, providing verified HTTPS-based deep linking.

Implementation Steps

1. Generate Digital Asset Links File

First, get your app’s SHA-256 fingerprint:

# For debug keystore
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android

# For release keystore
keytool -list -v -keystore /path/to/your-release-key.keystore -alias your-key-alias

# Look for SHA256 fingerprint like:
# 14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5

Create assetlinks.json and host at: https://yourapp.com.au/.well-known/assetlinks.json

[{
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
        "namespace": "android_app",
        "package_name": "com.yourcompany.yourapp",
        "sha256_cert_fingerprints": [
            "14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"
        ]
    }
}]

Important: Include fingerprints for both debug and release builds during development:

"sha256_cert_fingerprints": [
    "14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5",
    "AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90"
]

2. Configure Android Manifest

<activity
    android:name=".MainActivity"
    android:exported="true"
    android:launchMode="singleTask">

    {/* App Links for products */}
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data
            android:scheme="https"
            android:host="yourapp.com.au"
            android:pathPrefix="/product" />
        <data
            android:scheme="https"
            android:host="www.yourapp.com.au"
            android:pathPrefix="/product" />
    </intent-filter>

    {/* App Links for categories */}
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data
            android:scheme="https"
            android:host="yourapp.com.au"
            android:pathPrefix="/category" />
    </intent-filter>

    {/* App Links for user profiles */}
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data
            android:scheme="https"
            android:host="yourapp.com.au"
            android:pathPrefix="/user" />
    </intent-filter>
</activity>

Key attribute: android:autoVerify="true" triggers automatic verification against your assetlinks.json file.

3. Handle App Links

The handling code is identical to URI schemes:

private fun handleDeepLink(uri: Uri) {
    val pathSegments = uri.pathSegments

    if (pathSegments.isEmpty()) {
        navigateToHome()
        return
    }

    when (pathSegments[0]) {
        "product" -> {
            if (pathSegments.size > 1) {
                val productId = pathSegments[1]
                navigateToProduct(productId)
            } else {
                navigateToHome()
            }
        }
        "category" -> {
            if (pathSegments.size > 1) {
                val categoryName = pathSegments[1]
                navigateToCategory(categoryName)
            } else {
                navigateToHome()
            }
        }
        "user" -> {
            if (pathSegments.size > 1) {
                val userId = pathSegments[1]
                navigateToProfile(userId)
            } else {
                navigateToHome()
            }
        }
        "promo" -> {
            val promoCode = uri.getQueryParameter("code")
            if (promoCode != null) {
                applyPromoCode(promoCode)
            } else {
                navigateToHome()
            }
        }
        else -> navigateToHome()
    }
}

4. Test and Verify

# Test App Link
adb shell am start -W -a android.intent.action.VIEW -d "https://yourapp.com.au/product/123" com.yourcompany.yourapp

# Check verification status
adb shell pm get-app-links com.yourcompany.yourapp

# Expected output:
# com.yourcompany.yourapp:
#   ID: <some-id>
#   Signatures: [14:6D:E9:...]
#   Domain verification state:
#     yourapp.com.au: verified

# Manual verification (if needed)
adb shell pm verify-app-links --re-verify com.yourcompany.yourapp

# Reset verification state (useful during debugging)
adb shell pm set-app-links --package com.yourcompany.yourapp 0 all

Debugging App Links:

If App Links aren’t working:

  1. Verify assetlinks.json is accessible: curl https://yourapp.com.au/.well-known/assetlinks.json
  2. Confirm SHA-256 fingerprint matches exactly (including colons)
  3. Check package name matches applicationId in build.gradle
  4. Ensure android:autoVerify="true" is present
  5. Test on Android 6.0+ (App Links require Android M+)

Deferred Deep Linking: Handling New User Acquisition

Deferred deep linking is critical for paid acquisition campaigns. It preserves link context through the app installation process, ensuring users see the content they clicked on even if they had to install your app first.

The User Journey

Standard Deep Link (App Installed):

  1. User clicks link → App opens → Content displayed

Deferred Deep Link (App Not Installed):

  1. User clicks link → Redirected to App Store
  2. User installs app
  3. User opens app for first time
  4. App retrieves original link context
  5. Content displayed as if user had clicked while app was installed

Implementation Options

For September 2024, three main platforms dominate deferred deep linking:

Pros:

  • Comprehensive attribution tracking
  • Free tier supports 10,000 monthly tracked users
  • Easy integration with marketing platforms
  • Australian data residency options available
  • Excellent documentation

Cons:

  • Paid plans required for scale (from USD $150/month)
  • Third-party dependency
  • Requires SDK integration

Implementation:

# Install Branch SDK
npm install react-native-branch --save
# or
# iOS: pod 'Branch'
# Android: implementation 'io.branch.sdk.android:library:5.+'
// React Native implementation
import branch from 'react-native-branch';

// In your App.js
useEffect(() => {
    // Subscribe to deep link events
    const unsubscribe = branch.subscribe({
        onOpenStart: ({uri, cachedInitialEvent}) => {
            console.log('Branch link opened:', uri);
        },
        onOpenComplete: ({error, params, uri}) => {
            if (error) {
                console.error('Error from Branch:', error);
                return;
            }

            // Handle deep link data
            if (params['+clicked_branch_link']) {
                // This is a Branch link
                if (params.$deeplink_path) {
                    // Navigate to the path
                    navigateToPath(params.$deeplink_path);
                }

                // Track campaign attribution
                if (params['~campaign']) {
                    analytics.track('campaign_attribution', {
                        campaign: params['~campaign'],
                        channel: params['~channel'],
                        feature: params['~feature']
                    });
                }
            }
        }
    });

    return () => {
        unsubscribe();
    };
}, []);

// Create Branch links
async function createShareLink(productId) {
    const branchUniversalObject = await branch.createBranchUniversalObject(
        `product/${productId}`,
        {
            title: 'Amazing Product',
            contentDescription: 'Check out this product!',
            contentImageUrl: 'https://yourapp.com.au/images/product.jpg',
            contentMetadata: {
                customMetadata: {
                    product_id: productId,
                    category: 'electronics'
                }
            }
        }
    );

    const linkProperties = {
        feature: 'share',
        channel: 'mobile_app'
    };

    const controlParams = {
        $desktop_url: `https://yourapp.com.au/product/${productId}`,
        $ios_url: `https://yourapp.com.au/product/${productId}`,
        $android_url: `https://yourapp.com.au/product/${productId}`,
        $deeplink_path: `product/${productId}`
    };

    const {url} = await branchUniversalObject.generateShortUrl(
        linkProperties,
        controlParams
    );

    return url; // Returns short URL like https://yourapp.app.link/abc123
}

iOS Native:

import Branch

// In AppDelegate
func application(_ application: UIApplication,
                 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

    Branch.getInstance().initSession(launchOptions: launchOptions) { params, error in
        if let error = error {
            print("Branch init error: \(error)")
            return
        }

        guard let params = params as? [String: AnyObject] else { return }

        // Check if this is a Branch link
        if let clickedBranchLink = params["+clicked_branch_link"] as? Bool,
           clickedBranchLink {

            // Get deep link path
            if let deepLinkPath = params["$deeplink_path"] as? String {
                self.navigateToPath(path: deepLinkPath)
            }

            // Track attribution
            if let campaign = params["~campaign"] as? String {
                Analytics.shared.track("campaign_attribution", properties: [
                    "campaign": campaign,
                    "channel": params["~channel"] ?? "",
                    "feature": params["~feature"] ?? ""
                ])
            }
        }
    }

    return true
}

Android Native:

import io.branch.referral.Branch

class MainActivity : AppCompatActivity() {

    override fun onStart() {
        super.onStart()

        Branch.sessionBuilder(this).withCallback { branchUniversalObject, linkProperties, error ->
            if (error != null) {
                Log.e("Branch", "Init error: ${error.message}")
                return@withCallback
            }

            linkProperties?.let { props ->
                // Check if this is a Branch link
                if (props.controlParams["+clicked_branch_link"] == "true") {

                    // Get deep link path
                    props.controlParams["\$deeplink_path"]?.let { path ->
                        navigateToPath(path)
                    }

                    // Track attribution
                    val campaign = props.controlParams["~campaign"]
                    if (campaign != null) {
                        Analytics.track("campaign_attribution", mapOf(
                            "campaign" to campaign,
                            "channel" to (props.controlParams["~channel"] ?: ""),
                            "feature" to (props.controlParams["~feature"] ?: "")
                        ))
                    }
                }
            }
        }.withData(intent.data).init()
    }
}

Important Update: Google announced Firebase Dynamic Links will be deprecated in August 2025. For new projects in September 2024, we recommend Branch or AppsFlyer instead.

If you’re already using Firebase Dynamic Links, plan migration before the August 2025 deadline.

Pros:

  • Enterprise-grade attribution platform
  • Deep integration with advertising networks
  • Advanced fraud prevention
  • Popular among Australian gaming and e-commerce apps

Cons:

  • Premium pricing (contact sales)
  • More complex setup than Branch
  • Overkill for simple apps

Implementation:

// React Native
import appsFlyer from 'react-native-appsflyer';

appsFlyer.initSdk({
    devKey: 'YOUR_DEV_KEY',
    isDebug: false,
    appId: 'YOUR_APP_ID', // iOS only
    onInstallConversionDataListener: true,
    onDeepLinkListener: true
});

// Handle deep links
appsFlyer.onDeepLink(res => {
    if (res?.deepLinkStatus === 'FOUND') {
        const deepLinkValue = res.data.deep_link_value;
        const campaign = res.data.campaign;

        // Navigate based on deep link
        navigateToPath(deepLinkValue);

        // Track attribution
        analytics.track('campaign_attribution', {
            campaign: campaign,
            media_source: res.data.media_source
        });
    }
});

Choosing the Right Solution

Use Branch if:

  • You need full-featured attribution on a budget
  • You’re a startup or SME
  • You want quick integration with minimal complexity
  • Free tier (10,000 users/month) is sufficient

Use AppsFlyer if:

  • You’re running significant paid acquisition campaigns (>$50K AUD/month)
  • You need advanced fraud detection
  • You require deep ad network integrations
  • Enterprise support is critical

Build custom solution if:

  • You have very specific attribution requirements
  • You need complete data ownership
  • You have engineering resources to maintain it
  • Your app doesn’t require deferred deep linking

A centralized routing system makes deep link handling maintainable and testable.

Router Architecture

// DeepLink.ts - Define your deep link types
enum DeepLinkType {
    Product = 'product',
    Category = 'category',
    User = 'user',
    Promo = 'promo',
    Search = 'search',
    Unknown = 'unknown'
}

interface DeepLinkParams {
    type: DeepLinkType;
    id?: string;
    params?: Record<string, string>;
}

class DeepLink {
    readonly type: DeepLinkType;
    readonly id?: string;
    readonly params: Record<string, string>;

    constructor(params: DeepLinkParams) {
        this.type = params.type;
        this.id = params.id;
        this.params = params.params || {};
    }

    static parse(url: string): DeepLink {
        try {
            const urlObj = new URL(url);
            const pathComponents = urlObj.pathname.split('/').filter(Boolean);

            if (pathComponents.length === 0) {
                return new DeepLink({ type: DeepLinkType.Unknown });
            }

            const type = pathComponents[0] as DeepLinkType;
            const id = pathComponents[1];

            // Parse query parameters
            const params: Record<string, string> = {};
            urlObj.searchParams.forEach((value, key) => {
                params[key] = value;
            });

            return new DeepLink({ type, id, params });
        } catch (error) {
            console.error('Failed to parse deep link:', error);
            return new DeepLink({ type: DeepLinkType.Unknown });
        }
    }
}

// DeepLinkRouter.ts - Handle navigation
class DeepLinkRouter {
    private navigation: any; // Your navigation instance

    constructor(navigation: any) {
        this.navigation = navigation;
    }

    async route(deepLink: DeepLink): Promise<boolean> {
        // Track deep link usage
        analytics.track('deep_link_opened', {
            type: deepLink.type,
            id: deepLink.id,
            params: deepLink.params
        });

        switch (deepLink.type) {
            case DeepLinkType.Product:
                return this.routeToProduct(deepLink);
            case DeepLinkType.Category:
                return this.routeToCategory(deepLink);
            case DeepLinkType.User:
                return this.routeToUser(deepLink);
            case DeepLinkType.Promo:
                return this.routeToPromo(deepLink);
            case DeepLinkType.Search:
                return this.routeToSearch(deepLink);
            default:
                return this.routeToHome();
        }
    }

    private async routeToProduct(deepLink: DeepLink): Promise<boolean> {
        if (!deepLink.id) {
            return this.routeToHome();
        }

        try {
            // Fetch product data to ensure it exists
            const product = await api.getProduct(deepLink.id);

            this.navigation.navigate('ProductDetail', {
                productId: deepLink.id,
                source: 'deep_link'
            });

            return true;
        } catch (error) {
            // Product not found - show error and navigate home
            Alert.alert(
                'Product Not Found',
                'This product may no longer be available.',
                [{ text: 'OK', onPress: () => this.routeToHome() }]
            );
            return false;
        }
    }

    private async routeToCategory(deepLink: DeepLink): Promise<boolean> {
        if (!deepLink.id) {
            return this.routeToHome();
        }

        this.navigation.navigate('Category', {
            categoryId: deepLink.id,
            source: 'deep_link'
        });

        return true;
    }

    private async routeToUser(deepLink: DeepLink): Promise<boolean> {
        if (!deepLink.id) {
            return this.routeToHome();
        }

        // Check if user is authenticated
        const isAuthenticated = await auth.isAuthenticated();

        if (!isAuthenticated) {
            // Save deep link for after authentication
            await storage.save('pending_deep_link', deepLink);

            this.navigation.navigate('Login', {
                message: 'Please log in to view this profile',
                redirect: true
            });

            return true;
        }

        this.navigation.navigate('Profile', {
            userId: deepLink.id,
            source: 'deep_link'
        });

        return true;
    }

    private async routeToPromo(deepLink: DeepLink): Promise<boolean> {
        const promoCode = deepLink.params.code;

        if (!promoCode) {
            return this.routeToHome();
        }

        try {
            // Validate and apply promo code
            const result = await api.applyPromoCode(promoCode);

            Alert.alert(
                'Promo Code Applied!',
                `You've saved ${result.discount}!`,
                [{ text: 'Start Shopping', onPress: () => this.routeToHome() }]
            );

            return true;
        } catch (error) {
            Alert.alert(
                'Invalid Promo Code',
                'This promo code is not valid or has expired.',
                [{ text: 'OK', onPress: () => this.routeToHome() }]
            );
            return false;
        }
    }

    private async routeToSearch(deepLink: DeepLink): Promise<boolean> {
        const query = deepLink.params.q;

        if (!query) {
            return this.routeToHome();
        }

        this.navigation.navigate('Search', {
            query: query,
            source: 'deep_link'
        });

        return true;
    }

    private routeToHome(): boolean {
        this.navigation.navigate('Home');
        return true;
    }
}

export { DeepLink, DeepLinkRouter, DeepLinkType };

Handling App State

Deep links arrive in different app states, requiring different handling strategies:

enum AppState {
    NotRunning,
    Background,
    Foreground
}

class DeepLinkHandler {
    private router: DeepLinkRouter;

    async handleDeepLink(url: string, appState: AppState) {
        const deepLink = DeepLink.parse(url);

        switch (appState) {
            case AppState.NotRunning:
                // App launched from deep link
                // Wait for app initialization before routing
                await this.waitForAppReady();
                await this.router.route(deepLink);
                break;

            case AppState.Background:
                // App brought to foreground from deep link
                // Safe to route immediately
                await this.router.route(deepLink);
                break;

            case AppState.Foreground:
                // Deep link opened while app active
                // Ask user before navigating (they might be in the middle of something)
                this.confirmNavigation(deepLink);
                break;
        }
    }

    private async waitForAppReady(): Promise<void> {
        // Wait for critical initialization
        await Promise.all([
            auth.initialize(),
            config.load(),
            analytics.initialize()
        ]);
    }

    private confirmNavigation(deepLink: DeepLink): void {
        Alert.alert(
            'Open Link?',
            'Would you like to view this content?',
            [
                {
                    text: 'Cancel',
                    style: 'cancel'
                },
                {
                    text: 'Open',
                    onPress: () => this.router.route(deepLink)
                }
            ]
        );
    }
}

Attribution Tracking for Australian Apps

Attribution tracking answers the crucial question: “Which marketing efforts actually drive app usage?”

What to Track

Essential attribution data:

  1. Campaign Source: Where did the user come from?

    • Facebook Ads
    • Google Ads
    • Email campaign
    • Organic social media
    • Referral link
  2. Campaign Medium: What type of campaign?

    • CPC (cost per click)
    • Email
    • Social
    • Referral
  3. Campaign Name: Specific campaign identifier

    • “summer-sale-2024”
    • “product-launch-sept”
    • “referral-program”
  4. User Journey: What happened after click?

    • Time to first open
    • Time to registration
    • Time to first purchase
    • Retention at Day 1, 7, 30

Implementing Attribution Tracking

// AttributionTracker.ts
interface AttributionData {
    source: string;
    medium: string;
    campaign: string;
    term?: string;
    content?: string;
    timestamp: number;
}

class AttributionTracker {
    private static ATTRIBUTION_KEY = '@attribution_data';
    private static ATTRIBUTION_WINDOW_DAYS = 30;

    async trackAttribution(deepLink: DeepLink): Promise<void> {
        const attribution: AttributionData = {
            source: deepLink.params.utm_source || 'direct',
            medium: deepLink.params.utm_medium || 'none',
            campaign: deepLink.params.utm_campaign || 'none',
            term: deepLink.params.utm_term,
            content: deepLink.params.utm_content,
            timestamp: Date.now()
        };

        // Save attribution data
        await AsyncStorage.setItem(
            AttributionTracker.ATTRIBUTION_KEY,
            JSON.stringify(attribution)
        );

        // Send to analytics
        analytics.identify({
            acquisition_source: attribution.source,
            acquisition_medium: attribution.medium,
            acquisition_campaign: attribution.campaign
        });

        // Track initial attribution event
        analytics.track('user_attributed', attribution);
    }

    async getAttribution(): Promise<AttributionData | null> {
        const data = await AsyncStorage.getItem(AttributionTracker.ATTRIBUTION_KEY);

        if (!data) {
            return null;
        }

        const attribution: AttributionData = JSON.parse(data);

        // Check if attribution is still within window
        const daysSinceAttribution =
            (Date.now() - attribution.timestamp) / (1000 * 60 * 60 * 24);

        if (daysSinceAttribution > AttributionTracker.ATTRIBUTION_WINDOW_DAYS) {
            return null;
        }

        return attribution;
    }

    async trackConversion(eventName: string, revenue?: number): Promise<void> {
        const attribution = await this.getAttribution();

        if (!attribution) {
            return;
        }

        // Track conversion event with attribution context
        analytics.track(eventName, {
            attribution_source: attribution.source,
            attribution_medium: attribution.medium,
            attribution_campaign: attribution.campaign,
            days_since_attribution:
                (Date.now() - attribution.timestamp) / (1000 * 60 * 60 * 24),
            revenue: revenue
        });
    }
}

// Usage example
const attributionTracker = new AttributionTracker();

// When deep link opens
await attributionTracker.trackAttribution(deepLink);

// When user makes purchase
await attributionTracker.trackConversion('purchase', 49.99);

// When user completes signup
await attributionTracker.trackConversion('signup_complete');

UTM Parameter Structure

For Australian marketing campaigns, use consistent UTM parameters:

Product launch campaign:
https://yourapp.com.au/product/123?utm_source=facebook&utm_medium=cpc&utm_campaign=sept-launch&utm_content=video-ad-1

Email campaign:
https://yourapp.com.au/promo?code=SAVE20&utm_source=newsletter&utm_medium=email&utm_campaign=weekly-deals-sept12

Influencer campaign:
https://yourapp.com.au/product/456?utm_source=instagram&utm_medium=influencer&utm_campaign=tech-reviewer-sept&utm_content=reviewer-name

Referral program:
https://yourapp.com.au/signup?ref=USER123&utm_source=referral&utm_medium=app&utm_campaign=referral-program

Common Challenges and Solutions

Symptoms:

  • Links open in browser instead of app
  • “Open in [App]” option doesn’t appear
  • Users complain links don’t work

iOS Debugging:

# 1. Check AASA file is accessible
curl https://yourapp.com.au/.well-known/apple-app-site-association

# 2. Verify Apple's CDN cached it
curl https://app-site-association.cdn-apple.com/a/v1/yourapp.com.au

# 3. Check app's associated domains
xcrun simctl get_app_container booted com.yourcompany.yourapp

# 4. Enable diagnostic logging
# Settings → Developer → Universal Links → Enable Associated Domains Development

Common iOS fixes:

  1. Team ID mismatch: Verify AASA file uses correct Team ID
  2. HTTPS issues: Ensure valid SSL certificate with no redirects
  3. User disabled: Settings → [Your App] → Universal Links must be enabled
  4. Cache issue: Delete and reinstall app to refresh AASA cache
  5. Testing incorrectly: Don’t type URLs in Safari - must click from another app

Android Debugging:

# Check App Links verification status
adb shell pm get-app-links com.yourcompany.yourapp

# View detailed verification results
adb shell dumpsys package d

# Look for your package and check domain verification status

Common Android fixes:

  1. Fingerprint mismatch: Verify SHA-256 in assetlinks.json matches keystore
  2. Package name mismatch: Ensure assetlinks.json package_name matches build.gradle applicationId
  3. Verification failed: Run adb shell pm verify-app-links --re-verify com.yourcompany.yourapp
  4. Android version: App Links require Android 6.0+
  5. Intent filter missing autoVerify: Add android:autoVerify="true"

Symptom: App opens but navigates to home screen instead of deep link target

Solution: Ensure deep link handling happens at the right time in app lifecycle

// React Native - Handle deep links after navigation is ready
import { useEffect, useState } from 'react';
import { Linking } from 'react-native';

function App() {
    const [isNavigationReady, setIsNavigationReady] = useState(false);
    const navigationRef = useRef(null);

    useEffect(() => {
        if (!isNavigationReady) {
            return;
        }

        // Handle initial deep link (app launched from link)
        Linking.getInitialURL().then(url => {
            if (url) {
                handleDeepLink(url);
            }
        });

        // Handle deep links while app is running
        const subscription = Linking.addEventListener('url', ({url}) => {
            handleDeepLink(url);
        });

        return () => subscription.remove();
    }, [isNavigationReady]);

    return (
        <NavigationContainer
            ref={navigationRef}
            onReady={() => setIsNavigationReady(true)}>
            {/* Your navigation */}
        </NavigationContainer>
    );
}

Symptoms:

  • New installs don’t navigate to deep link content
  • Attribution lost after installation

Debugging checklist:

  1. SDK initialization timing: Initialize Branch/AppsFlyer before checking for deep links
  2. App Store privacy: iOS 14.5+ requires ATT permission for some attribution methods
  3. Link expiration: Some platforms expire install attribution after 2 hours
  4. Testing methodology: Clear app data between tests, use different devices

iOS ATT handling:

import AppTrackingTransparency

func requestTrackingPermission() {
    if #available(iOS 14.5, *) {
        ATTrackingManager.requestTrackingAuthorization { status in
            switch status {
            case .authorized:
                // Tracking authorized - full attribution available
                Branch.getInstance().enableTracking(true)
            case .denied, .restricted, .notDetermined:
                // Limited tracking - some attribution may not work
                Branch.getInstance().enableTracking(false)
            @unknown default:
                break
            }
        }
    }
}

Challenge 4: Poor Attribution Data Quality

Symptoms:

  • Most installs show as “direct” or “unknown”
  • Campaign performance unclear
  • Marketing ROI unmeasurable

Solutions:

  1. Always use UTM parameters in all marketing links
  2. Implement server-side tracking for critical conversions
  3. Use shortened links from attribution platform (not bit.ly or other generic shorteners)
  4. Test attribution flow before launching campaigns
  5. Document UTM conventions and enforce across marketing team

UTM validation middleware:

function validateDeepLink(url: string): boolean {
    const urlObj = new URL(url);
    const hasUtmSource = urlObj.searchParams.has('utm_source');
    const hasUtmMedium = urlObj.searchParams.has('utm_medium');
    const hasUtmCampaign = urlObj.searchParams.has('utm_campaign');

    if (!hasUtmSource || !hasUtmMedium || !hasUtmCampaign) {
        // Log warning for internal links missing UTM parameters
        analytics.track('deep_link_missing_utm', {
            url: url,
            missing: {
                utm_source: !hasUtmSource,
                utm_medium: !hasUtmMedium,
                utm_campaign: !hasUtmCampaign
            }
        });
        return false;
    }

    return true;
}

Production Checklist

Before launching deep linking in production:

Infrastructure:

  • AASA file accessible at https://yourdomain.com.au/.well-known/apple-app-site-association
  • assetlinks.json accessible at https://yourdomain.com.au/.well-known/assetlinks.json
  • SSL certificate valid with no redirect chains
  • CDN configured to serve verification files correctly
  • Both www and non-www domains handled

iOS Configuration:

  • Associated Domains capability enabled with correct domains
  • Team ID and Bundle ID match AASA file exactly
  • Universal Link handling implemented in AppDelegate
  • Tested on physical device (not just simulator)
  • Long-press on links shows “Open in [App]”

Android Configuration:

  • SHA-256 fingerprint matches assetlinks.json
  • Package name matches build.gradle applicationId
  • android:autoVerify=“true” present in intent filters
  • Verification status confirmed via adb
  • Tested on Android 6.0+ devices

Deep Link Routing:

  • Centralized router handles all deep link types
  • Graceful fallbacks for invalid/expired links
  • Error handling for network failures
  • Loading states while fetching deep link content
  • Authentication required paths redirect to login

Attribution:

  • Attribution SDK initialized before checking for deep links
  • UTM parameters captured and stored
  • Conversion events tracked with attribution context
  • iOS ATT permission handled appropriately
  • Testing completed with actual ad campaigns

Analytics:

  • Deep link opens tracked
  • Attribution data sent to analytics platform
  • Conversion events include attribution context
  • Failed deep links logged for debugging

Documentation:

  • URL structure documented for marketing team
  • UTM parameter conventions established
  • Deep link creation process documented
  • Testing procedures documented

Conclusion

Deep linking is essential infrastructure for modern mobile apps. In September 2024, users expect seamless experiences from any entry point—email, social media, advertising, or direct sharing. Attribution tracking enables data-driven marketing decisions, critical for Australian apps competing in tight markets with limited budgets.

Start with universal links and app links for immediate value. Add deferred deep linking when you start paid acquisition campaigns. Build a robust routing system that handles edge cases gracefully. Track attribution religiously to understand what’s working.

The implementation requires coordination between mobile developers, backend engineers, and marketing teams. But once in place, deep linking becomes the connective tissue between your marketing efforts and actual user behavior—turning clicks into conversions and campaigns into measurable ROI.

For Australian apps specifically, deep linking enables:

  • Targeted regional campaigns with accurate attribution
  • Viral growth through seamless sharing
  • Retention campaigns that bring users back to specific content
  • Performance marketing that tracks every dollar spent

Get it right, and you’ve built infrastructure that compounds in value as your app grows. Get it wrong, and you’re losing users and attribution data with every campaign. The time invested in proper implementation pays dividends for the life of your app.