Skip to main content

Deep Links and Universal Links

Introduction

Deep Links allow URLs to open your app directly, improving the user experience when sharing content.

  • Format: oneplace://join-event/123/admin
  • How it works: Always opens the app (if installed)
  • Limitations:
    • ❌ Doesn't work when shared on WhatsApp and other places (not clickable)
    • ❌ Doesn't open web page if app is not installed
    • ✅ Easy to implement
    • ✅ Works offline
  • Format: https://yourdomain.com/join-event/123/admin
  • How it works: Normal HTTPS links that open the app
  • Advantages:
    • ✅ Works on WhatsApp, email, SMS, anywhere
    • ✅ If app not installed, opens in browser
    • ✅ Native and professional experience
    • ⚠️ Requires server configuration

Prerequisites

  • A domain with HTTPS configured
  • You'll need to serve configuration files via:
    • Backend: Routes that return JSON dynamically
    • Static Frontend: .well-known files hosted directly

Information you need to have on hand:

iOS:

  • Team ID: Find it in Apple Developer Portal or Xcode
  • Bundle ID: Ex: com.oneplace.app

Android:

  • Package Name: Ex: com.oneplace
  • SHA256 Fingerprint: Run in terminal:
    cd android
    ./gradlew signingReport
    Copy the SHA-256 from debug AND release

React Native App Configuration

1. Define URL Scheme

In app.json file:

{
"expo": {
"scheme": "oneplace"
}
}

2. Configure Linking

File src/app/deepLinks.ts:

import * as Linking from 'expo-linking'
import { LinkingOptions, ParamListBase } from '@react-navigation/native'

export const linking: LinkingOptions<ParamListBase> = {
enabled: 'auto',
prefixes: [
Linking.createURL('/'),
'oneplace://', // Custom scheme
'https://yourdomain.com', // Universal Link
],
}

3. Configure Route in Navigation

In your navigation file (ex: Navigation.tsx):

EventJoin: {
screen: EventJoinPage,
linking: {
path: 'join-event/:id/:permission',
parse: {
id: Number,
permission: String
},
},
options: {
headerShown: false,
}
}

4. Use parameters in screen

// EventJoinPage.tsx
export function EventJoinPage({ route }) {
const eventId = route.params?.id
const permission = route.params?.permission

// Use the parameters...
}

iOS Configuration

1. Add Associated Domains in Xcode

  1. Open the project: open ios/YourApp.xcworkspace
  2. Select the main target
  3. Signing & Capabilities tab
  4. Click + Capability
  5. Add Associated Domains
  6. In the field that appears, add:
    applinks:yourdomain.com
    Or if using subdomain:
    applinks:app.yourdomain.com

IMPORTANT: This configuration must be in ALL targets

2. Verify Entitlements file

Xcode should automatically create/update ios/YourApp/YourApp.entitlements:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:yourdomain.com</string>
</array>
</dict>
</plist>

3. URL Schemes in Info.plist

Already configured automatically by Expo, but verify ios/YourApp/Info.plist:

<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>oneplace</string>
</array>
</dict>
</array>

Android Configuration

1. Edit AndroidManifest.xml

File: android/app/src/main/AndroidManifest.xml

Add inside <activity android:name=".MainActivity">:

<!-- Deep Links with Custom Scheme -->
<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="oneplace"/>
</intent-filter>

<!-- App Links with HTTPS (auto-verify is important!) -->
<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="yourdomain.com"
android:pathPrefix="/join-event"/>
</intent-filter>

Note: android:autoVerify="true" makes Android automatically verify the assetlinks.json file on the server. Without it, the user needs to manually choose to open links in the app.


Option 1: Host .well-known files on Static Site

If you have a static site (Wix, WordPress, Netlify, etc.) or an S3/CloudFront bucket, create a .well-known folder in the static files and deploy to make them available via URL.

Required files:

1. Apple App Site Association

Path: /.well-known/apple-app-site-association

{
"applinks": {
"apps": [],
"details": [{
"appID": "TEAM_ID.com.yourapp.bundle",
"paths": ["/join-event/*"]
}]
}
}

Replace:

  • TEAM_ID: Your Apple Team ID (ex: PF8Z5765JC)
  • com.yourapp.bundle: Your EXACT Bundle ID (ex: com.oneplace.app)

Path: /.well-known/assetlinks.json

[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.yourapp",
"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",
"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:AA:BB:CC:DD:EE:FF"
]
}
}]

Replace:

  • com.yourapp: Your Android package name
  • SHA256 fingerprints: Include BOTH debug AND release (obtained with ./gradlew signingReport)

Option 2: Host via Backend

If you have a backend (Django, Node.js, etc.), you can serve the files via request.

Example: Django/Python

# views.py
from django.http import JsonResponse
from django.views.decorators.cache import cache_control

@cache_control(max_age=3600)
def apple_app_site_association(request):
"""Universal Links configuration for iOS"""
data = {
"applinks": {
"apps": [],
"details": [{
"appID": "PF8Z5765JC.com.oneplace.app", # TEAM_ID.BUNDLE_ID
"paths": ["/join-event/*"]
}]
}
}
return JsonResponse(data, content_type='application/json')

@cache_control(max_age=3600)
def assetlinks(request):
"""App Links configuration for Android"""
data = [{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.oneplace",
"sha256_cert_fingerprints": [
# Debug keystore
"FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C",
# Release keystore
"16:FA:81:E6:BB:9D:21:1E:FB:85:1F:7E:D6:B8:00:2F:7F:94:20:EE:90:64:13:CE:FF:A7:DB:A8:89:58:17:6B"
]
}
}]
return JsonResponse(data, safe=False, content_type='application/json')
# urls.py
from django.urls import path
from . import views

urlpatterns = [
path('.well-known/apple-app-site-association',
views.apple_app_site_association,
name='apple-app-site-association'),
path('.well-known/assetlinks.json',
views.assetlinks,
name='assetlinks'),
]

IMPORTANT: When serving via backend, remember that if the app is not installed, the user will be redirected to the URL in the browser.

Fallback example (Django):

def join_event_fallback(request, event_id, permission):
"""Web page when app is not installed"""
return render(request, 'join_event.html', {
'event_id': event_id,
'permission': permission,
'app_store_link': 'https://apps.apple.com/...',
'play_store_link': 'https://play.google.com/...'
})

1. Rebuild the App (MANDATORY)

Whenever you change native configurations (entitlements, manifest):

# iOS
npx expo run:ios

# Android
npx expo run:android

2. Test Custom URL Scheme

iOS (Simulator):

xcrun simctl openurl booted "oneplace://join-event/123/admin"

Android (Emulator/Device):

adb shell am start -a android.intent.action.VIEW -d "oneplace://join-event/123/admin" com.yourapp

iOS (Simulator):

xcrun simctl openurl booted "https://yourdomain.com/join-event/123/admin"

Android:

adb shell am start -a android.intent.action.VIEW -d "https://yourdomain.com/join-event/123/admin"

Physical Device (iOS):

  1. Open the Notes app
  2. Create a new note
  3. Type the link: https://yourdomain.com/join-event/123/admin
  4. Tap the link

4. Validate configuration

iOS - Apple Validator:

Check the response from these sites:

IMPORTANT: If Branch.io returns correctly but Apple's CDN doesn't, it's an Apple CDN cache issue. See Common Problems and Solutions → CDN Cache section.

Android - Verify status:

adb shell pm get-app-links com.yourapp

Should show: yourdomain.com: verified


Common Problems and Solutions

1. ❌ Apple CDN Cache

What is Apple's CDN? Apple maintains a CDN (Content Delivery Network) that caches the apple-app-site-association files. When your app tries to validate Universal Links, it queries THIS CDN, not your server directly.

Why does it cause problems?

  • You update the file on your server ✅
  • But Apple's CDN still has the old version in cache ❌
  • Result: App doesn't recognize your links

How to verify:

# Your server (should be correct)
curl https://yourdomain.com/.well-known/apple-app-site-association

# Apple's CDN (may be outdated)
curl https://app-site-association.cdn-apple.com/a/v1/yourdomain.com

Solutions:

A. Wait (1h to 24h)

The cache expires naturally.

B. Change headers (prevent future caching)

response['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response['Pragma'] = 'no-cache'
response['Expires'] = '0'

C. Consult

Step by Step Apple CDN

Note: You CANNOT manually invalidate Apple's cache.

2. ❌ Incorrect Bundle ID / Package Name

Symptom: Links don't open the app, but .well-known files are correct.

Cause: The Bundle ID in the file doesn't match the app's actual Bundle ID.

Solution:

  • iOS: Check in Xcode → Target → General → Bundle Identifier
  • Android: Check in android/app/build.gradleapplicationId
  • IMPORTANT: Use the EXACT Bundle ID, including suffixes like .app
    • ❌ Wrong: com.oneplace when the real one is com.oneplace.app
    • ✅ Correct: com.oneplace.app

3. ❌ Incorrect SHA256 Fingerprints (Android)

Symptom: App Links don't work on Android.

Solution:

cd android
./gradlew signingReport

Copy the SHA-256 from debug AND release and add BOTH to assetlinks.json.

4. ❌ Server redirects

Symptom: File exists but iOS doesn't validate.

Cause: Server does redirect (HTTP → HTTPS, www → non-www, etc.)

How to verify:

curl -v https://yourdomain.com/.well-known/apple-app-site-association

Look for: < HTTP/2 301 or < HTTP/2 302

Solution: Configure the server to serve directly, without redirects.

5. ❌ Incorrect Content-Type

Cause: File served as text/plain instead of application/json.

How to verify:

curl -I https://yourdomain.com/.well-known/apple-app-site-association

Solution: Configure the server to send Content-Type: application/json


Official Documentation: