ESP (Email Service Provider) Integration
📧 What is ESP Support?
ESP (Email Service Provider) support allows AppsFlyer to handle deep links that are wrapped by email service providers. When users click links in emails, ESP services often wrap the original URL with their own tracking domains. This can break deep linking functionality. ESP support resolves these wrapped URLs to extract the original deep link.
How ESP Works:
- Email Campaign: Your email contains a deep link to your app
- ESP Wrapping: Email provider wraps your link with their tracking domain
- User Clicks: User clicks the wrapped link from their email
- ESP Resolution: AppsFlyer resolves the wrapped URL to get the original link
- Decision: If original link is a OneLink → continue deep linking; if web URL → open in browser
🚀 Prerequisites
Before integrating ESP support, ensure you have:
- ✅ AppsFlyer React Native SDK installed (
react-native-appsflyer
) - ✅ Basic AppsFlyer integration working (SDK initialization, conversion data)
- ✅ Deep linking set up in your app (Universal Links for iOS, App Links for Android)
- ✅ ESP domain list from your email service provider(s)
📱 iOS Platform Preparation
Step 1: Configure Associated Domains
For Expo Projects:
Add associated domains to your app.json
:
{
"expo": {
"ios": {
"bundleIdentifier": "com.yourcompany.yourapp",
"associatedDomains": [
"applinks:your-onelink-domain.onelink.me"
]
}
}
}
For Native iOS Projects:
- Open your project in Xcode
- Go to Signing & Capabilities tab
- Add Associated Domains capability
- Add your OneLink domain:
applinks:your-onelink-domain.onelink.me
Step 2: Configure Bridging Header
For Expo Projects or Swift AppDelegate:
Add AppsFlyer React Native plugin to your bridging header file (e.g., your-app-name-Bridging-Header.h
):
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
#import <React/RCTBridge.h>
#import <React/RCTRootView.h>
#import <React/RCTBundleURLProvider.h>
#import <React/RCTLinkingManager.h>
// Add AppsFlyer React Native plugin
#import "RNAppsFlyer.h"
⚠️ Critical Note: Without adding RNAppsFlyer.h
to the bridging header, the AppsFlyer SDK won't be accessible from Swift code and deep linking will fail.
Step 3: Configure AppDelegate for Deep Linking
Ensure your AppDelegate.swift
includes AppsFlyer attribution handling:
import Expo
import React
import ReactAppDependencyProvider
@UIApplicationMain
public class AppDelegate: ExpoAppDelegate {
var window: UIWindow?
var reactNativeDelegate: ExpoReactNativeFactoryDelegate?
var reactNativeFactory: RCTReactNativeFactory?
public override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
//...
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
// Linking API
public override func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
AppsFlyerAttribution.shared().handleOpen(url, options: options)
return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options)
}
// Universal Links
public override func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
let selector = NSSelectorFromString("continueUserActivity:restorationHandler:")
let afAttribution = AppsFlyerAttribution.shared()
if afAttribution.responds(to: selector) {
_ = afAttribution.perform(selector, with: userActivity, with: restorationHandler)
}
let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler)
return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result
}
}
🤖 Android Platform Preparation
Step 1: Configure App.json for Expo
Add intentFilters to your app.json
:
{
"expo": {
"android": {
"package": "com.yourcompany.yourapp",
"intentFilters": [
{
"action": "VIEW",
"data": [
{
"scheme": "https",
"host": "your-onelink-domain.onelink.me"
}
],
"category": ["BROWSABLE", "DEFAULT"]
},
{
"action": "VIEW",
"data": [
{
"scheme": "your-custom-scheme"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}
Step 2: Configure AndroidManifest.xml
Critical Configuration Points:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Required permissions -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="com.google.android.gms.permission.AD_ID"/>
<!-- Queries for link handling -->
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"/>
</intent>
</queries>
<application
android:name=".MainApplication"
android:allowBackup="false"
tools:replace="android:allowBackup">
<activity
android:name=".MainActivity"
android:launchMode="singleTask"
android:exported="true">
<!-- App Launcher Intent -->
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Custom Scheme Deep Links -->
<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="your-custom-scheme"/>
</intent-filter>
<!-- HTTPS Deep Links (App Links) -->
<!-- NOTE: Remove autoVerify for testing without domain verification -->
<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="https" android:host="your-onelink-domain.onelink.me"/>
</intent-filter>
</activity>
</application>
</manifest>
⚠️ Important Android Notes:
- Remove
autoVerify="true"
unless you have domain verification set up - Add
tools:replace="android:allowBackup"
to resolve AppsFlyer SDK conflicts - Include
xmlns:tools
namespace in the manifest root - Don't include
package
attribute in manifest (use namespace in build.gradle)
⚛️ React Native Implementation
Step 1: Configure ESP Domains
Best Practice: List ESP domains first, OneLink domains below
/**
* ESP (Email Service Provider) domains configuration
* Best Practice: Add ESP domains first, then OneLink domains for reference
*/
const ESP_DOMAINS = [
// ESP Provider Domains (add your actual ESP domains here)
"reactn.esp-integrations1.com",
"my-link.onelink.me"
];
Step 2: Set Up setResolveDeepLinkURLs
Configure ESP resolution BEFORE SDK initialization:
import { AppsFlyer } from 'react-native-appsflyer';
/**
* Configure ESP domains for deep link resolution
* This MUST be called before AppsFlyer SDK initialization
*/
const configureESPDomains = () => {
console.log('🔗 Configuring ESP domains:', ESP_DOMAINS);
AppsFlyer.setResolveDeepLinkURLs(
ESP_DOMAINS,
(result) => {
console.log('✅ ESP domains configured successfully:', result);
},
(error) => {
console.error('❌ ESP domain configuration failed:', error);
}
);
};
Step 3: Deep Link Handlers
Create comprehensive ESP and deep link handling:
/**
* Main ESP deep link handler
*/
const handleDeepLink = useCallback((deepLinkData: any) => {
console.log('🔗 Deep Link Received:', deepLinkData);
// Simply stringify and display the entire deep link data
const formattedData = JSON.stringify(deepLinkData, null, 2);
console.log('📱 Deep Link Data:', formattedData);
let actualDeepLinkData = deepLinkData;
// Check if this is a deferred or direct deep link
if (actualDeepLinkData.isDeferred === true) {
console.log('[AFSDK] This is a deferred deep link');
} else {
console.log('[AFSDK] This is a direct deep link');
let originalLink = actualDeepLinkData.data?.['original_link'];
if (originalLink && typeof originalLink === 'string') {
console.log('[AFSDK] This is a resolved ESP flow');
console.log('[AFSDK] Original Link:', originalLink);
try {
// Extract the host
const url = new URL(originalLink);
const host = url.hostname;
if (host) {
console.log('[AFSDK] Host:', host);
// Check if the host part of `original_link` matches one of the ESP domains
// This means this ESP link wraps another link
if (ESP_DOMAINS.includes(host)) {
console.log('[AFSDK] The ESP domain matches');
// Check for link in both locations: clickEvent and directly in data
let espLink = actualDeepLinkData.data?.clickEvent?.['link'];
if (!espLink) {
espLink = actualDeepLinkData.data?.['link'];
}
if (espLink && typeof espLink === 'string') {
try {
const espUrl = new URL(espLink);
const espHost = espUrl.hostname;
if (espHost) {
console.log('[AFSDK] ESP Host:', espHost);
// The following `if` checks if the wrapped link should continue deep link or open the link in a browser.
// If the wrapped link ends with ".onelink.me" it is obviously a OneLink and will continue the Deep Link flow.
if (espHost.endsWith('.onelink.me')) {
console.log('[AFSDK] The ESP link is a OneLink link. Deep link continues normally');
} else {
console.log('[AFSDK] The ESP link is NOT a OneLink link. It will be opened in a browser');
console.log('[AFSDK] ESP marks to divert the link to the browser');
console.log('URL to open:', espUrl.toString());
console.log('📱 Would open in browser:', espUrl.toString());
}
} else {
console.log('[AFSDK] No host found in the ESP URL');
}
} catch (error) {
console.log('[AFSDK] Invalid ESP URL:', error);
}
} else {
console.log('[AFSDK] No link found in data');
}
} else {
console.log('[AFSDK] ESP domain does not match configured domains');
console.log('[AFSDK] Configured domains:', ESP_DOMAINS);
console.log('[AFSDK] This appears to be a regular OneLink, not an ESP-wrapped link');
}
} else {
console.log('[AFSDK] No host found in the original URL');
}
} catch (error) {
console.log('[AFSDK] Invalid original URL:', error);
}
} else {
console.log('[AFSDK] The original_link is not found');
console.log('📋 Regular Deep Link Data:', actualDeepLinkData.data);
}
}
});
Step 4: SDK Initialization with ESP
Complete SDK setup with ESP configuration:
import { useEffect } from 'react';
import { Platform } from 'react-native';
const initializeAppsFlyer = () => {
console.log('🚀 Initializing AppsFlyer with ESP support...');
// 1. Configure ESP domains FIRST
configureESPDomains();
// 2. Set up deep link listener
AppsFlyer.onDeepLink(handleEspDeepLink);
// 3. Set up conversion data listener
AppsFlyer.onInstallConversionData((res) => {
console.log('📊 Conversion Data:', res);
});
AppsFlyer.onInstallConversionFailure((error) => {
console.error('❌ Conversion Data Error:', error);
});
// 4. Initialize SDK
const devKey = Platform.OS === 'ios'
? "YOUR_IOS_DEV_KEY"
: "YOUR_ANDROID_DEV_KEY";
AppsFlyer.initSdk(
{
devKey: devKey,
appId: "YOUR_IOS_APP_ID", // iOS only
isDebug: true,
onInstallConversionDataListener: true,
onDeepLinkListener: true, // ✅ REQUIRED for ESP support
timeToWaitForATTUserAuthorization: 15,
},
() => {
console.log("✅ AppsFlyer SDK initialized successfully!");
},
(err) => {
console.error("❌ AppsFlyer SDK initialization error:", err);
}
);
};
// Initialize in useEffect
useEffect(() => {
initializeAppsFlyer();
}, []);
🔧 Troubleshooting
Common Android Issues
1. Deep links open Google Play instead of app:
- Remove
android:autoVerify="true"
from intent filters - Test with ADB for direct app opening
- Ensure app is installed and intent filters are correct
2. Email deep links redirect to Play Store (Domain Disabled):
This is a common issue where clicking deep links from emails opens the Play Store instead of your app. This happens when the domain is disabled in Android's app link settings.
Diagnosis:
# Check if your app's domain is disabled
adb shell pm get-app-links com.yourcompany.yourapp
Look for your domain in the "Selection state" → "Disabled" section.
Solution:
# Enable domain for your app (replace with your actual package name and domain)
adb shell pm set-app-links-user-selection --package com.yourcompany.yourapp --user 0 true your-onelink-domain.onelink.me
# Verify the fix
adb shell pm get-app-links com.yourcompany.yourapp
Testing:
# Test deep link after fix
adb shell am force-stop com.yourcompany.yourapp
adb shell am start -W -a android.intent.action.VIEW -d "https://your-onelink-domain.onelink.me/test"
Production Note: This is a testing solution. For production apps, consider:
- Domain verification (requires access to domain)
- User education about setting app as default handler
- Fallback handling for when app isn't the default handler
3. Manifest merger conflicts:
<!-- Add tools namespace and replace directive -->
<manifest xmlns:tools="http://schemas.android.com/tools">
<application android:allowBackup="false" tools:replace="android:allowBackup">
4. Package attribute deprecated:
- Remove
package="com.yourapp"
from AndroidManifest.xml - Use
namespace
in build.gradle instead
5. Build Cache Issues:
If experiencing persistent build failures, perform a complete clean build:
# Clean everything
rm -rf node_modules
rm -rf ios/Pods
rm -rf android/.gradle
rm -rf android/app/build
rm -rf android/build
# Reinstall dependencies
npm install
cd ios && pod install && cd ..
# Clean build
npx expo run:android / ios --clear
Common iOS Issues
1. Universal Links not working:
- Verify associated domains in app.json/Xcode
- Check AppDelegate deep link handling
- Test with iOS Simulator using xcrun
2. Deep links not triggering:
- Ensure
onDeepLinkListener: true
in SDK config - Verify ESP domains are configured before SDK init
🧪 Testing Your ESP Integration
Quick Deep Link Testing
Android Testing:
# 1. Check if app is installed
adb shell pm list packages | grep com.yourcompany.yourapp
# 2. Check app link verification status
adb shell pm get-app-links com.yourcompany.yourapp
# 3. Test deep link (cold start)
adb shell am force-stop com.yourcompany.yourapp
adb shell am start -W -a android.intent.action.VIEW -d "https://your-onelink-domain.onelink.me/test"
# 4. Test deep link (warm start)
adb shell am start -W -a android.intent.action.VIEW -d "https://your-onelink-domain.onelink.me/test"
iOS Testing:
# Test with iOS Simulator
xcrun simctl openurl booted "https://your-onelink-domain.onelink.me/test"
📖 Support Resources
Updated 1 day ago