Deep links, install attribution and in-app events for React Native and Expo.
v1.0.0 · Expo SDK 54+ · iOS / Android · MIT
Step-by-step setup · Your dashboard values · API reference · Support
In the Appy dashboard, open Apps & SDK → New app. Register your own app, then copy Publishable key and Link domain from SDK setup. The hosted SDK features require an Enterprise plan.
Replace YOUR_PUBLISHABLE_KEY and your-app.appy.to below with those two values. Keep the SDK/package names as written. Help finding your values.
Install from npm:
npx expo install react-native-appyUse a development build; Expo Go cannot load the native code.
At module scope in your app's entry file:
import Appy from 'react-native-appy';
Appy.configure({
publishableKey: 'YOUR_PUBLISHABLE_KEY',
linkDomains: ['your-app.appy.to'],
});Finish the required setup: add the Expo config plugin or follow bare React Native setup, then listen for deep links.
Create a link for this dashboard app, install the matching app build and tap the link. Confirm that your deep-link handler receives it before testing fresh installs or adding event tracking. Follow the complete walkthrough.
The sections below cover platform settings, callbacks, events, consent, troubleshooting and the API. Start here when you need a specific integration detail.
- Expo SDK 54 or later (tested with SDK 58 and React Native 0.88). Bare React Native apps need the
expopackage, added withnpx install-expo-modules@latest. - The iOS and Android minimum versions required by your Expo / React Native release. The host framework may require newer OS versions than the underlying Appy native SDK.
- A development build. Expo Go cannot load the native code.
- An app registered with Appy and its publishable key (
appy_pk_…). The SDK endpoints are part of the Appy Enterprise plan; keys on other plans receive403 plan_required, which the first launch reports as{ status: 'failed', error: 'invalidResponse' }.
npx expo install react-native-appyThe package resolves the native SDKs from their public releases. To develop the plugin alongside local native SDK changes, use Source setup.
After installation, use the sections below to connect the SDK to your app. Swift Package Manager resolves the public iOS package automatically. If your app uses CocoaPods instead, add pod 'AppySDK', :git => 'https://github.com/appy-to/appy-ios-sdk.git', :tag => '1.0.0' inside the app target in ios/Podfile.
Open Apps & SDK → New app in the Appy dashboard. Enter your own app name, choose its link subdomain, and select the platforms you ship:
- iOS: your Apple Team ID and the Bundle ID from Xcode.
- Android: your
applicationIdand the SHA-256 fingerprints of the signing certificates used for your builds, including Play App Signing for Play Store builds.
Select Register app. Open SDK setup on the app's page and copy the Publishable key and Link domain. One dashboard app can contain both platforms. Where to find every value.
In the examples, replace YOUR_PUBLISHABLE_KEY and your-app.appy.to with those copied values. Keep the Appy class names and package/import names. You do not need a secret API key or a terminal registration request.
In app.json, set your URL scheme and add the plugin with your link domains:
{
"expo": {
"scheme": "yourapp",
"plugins": [
["react-native-appy", { "linkDomains": ["your-app.appy.to"] }]
]
}
}Then build again with npx expo prebuild or EAS Build. The plugin:
- adds
applinks:your-app.appy.toto the iOS Associated Domains entitlement; - adds an
autoVerifyApp Link intent filter forhttps://your-app.appy.toto the main Android activity; - connects the iOS
AppySDKdependency to its public GitHub release tag; - excludes the SDK's state from Android Auto Backup, so a reinstall still gets its first launch. If your app already sets backup rules, prebuild prints a warning with the line to add.
| Option | Default | Meaning |
|---|---|---|
linkDomains |
required | Hosts of your Appy links. |
ios.developerMode |
false |
Also add applinks:<domain>?mode=developer, so development builds fetch the association file directly from Appy. |
android.excludeFromBackup |
true |
Add the backup rules described above. |
The scheme gives in-app browsers that ignore Universal Links (Instagram, Facebook, many mail apps) a way back into your app: the Appy redirect page opens yourapp://…?appy_click_id=….
Without prebuild, make the same changes yourself:
-
In Xcode, add the Associated Domains capability with
applinks:your-app.appy.to, and your URL scheme under Info > URL Types. -
Add this line inside your app target in
ios/Podfile, then runpod installinios/:pod 'AppySDK', :git => 'https://github.com/appy-to/appy-ios-sdk.git', :tag => '1.0.0'
-
Add the App Link and custom-scheme intent filters to your main activity in
android/app/src/main/AndroidManifest.xml, as in the Android guide, and the backup rules from Keep the first launch a first launch.
Links reach the SDK without further native code: the package listens to the app delegate on iOS, including scene events that ExpoAppDelegate forwards, and to the React activity on Android. Your AppDelegate must subclass ExpoAppDelegate, which install-expo-modules sets up.
Call configure once, as early as possible, at module scope of your app's entry file (app/_layout.tsx with Expo Router, App.tsx or index.js otherwise):
import Appy from 'react-native-appy';
Appy.configure({
publishableKey: 'YOUR_PUBLISHABLE_KEY',
linkDomains: ['your-app.appy.to'],
logLevel: __DEV__ ? 'debug' : 'error',
});| Option | Default | Meaning |
|---|---|---|
publishableKey |
required | Your appy_pk_… key. Never ship a secret key (appy_sk_…). |
linkDomains |
[] |
Hosts of your Appy links. The first one is used by buildLink. |
emailLinkDomains |
[] |
Click-tracking domains of your email tool that open the app. The native SDKs resolve their links to the destination, as described in the iOS and Android guides. The config plugin does not add them: put them in ios.associatedDomains and android.intentFilters in your app config. |
logLevel |
'error' |
'none', 'error', 'warning', 'info' or 'debug'. |
openDeferredDeepLinks |
false |
Let the SDK open a deferred link's in-app URL itself. See Deferred deep links. |
deferredDeepLinkTimeoutMs |
5000 |
How long the first launch waits for Appy's answer before reporting 'timeout'. |
launchLinkWaitMs |
500 |
How long a launch waits for a link, so a cold start from a link sends one request. |
requestTimeoutMs |
10000 |
Timeout per HTTP attempt. |
apiBaseUrl |
https://api.appy.to |
Change only for staging. |
android.collectInstallReferrer |
true |
Read the store's install referrer on the first Android launch. |
android.metaAppId |
none | Your Facebook App ID, to read the Meta Install Referrer. See Install attribution. |
Links that open the app before configure runs, such as the link of a cold start, are kept by the native SDK and resolved right after it. A second configure call in the same process, for example after a JavaScript reload, is ignored.
import { useEffect } from 'react';
import { router } from 'expo-router';
import Appy from 'react-native-appy';
useEffect(() => {
const subscription = Appy.addDeepLinkListener((result) => {
if (result.status === 'found') {
const item = result.link.parameters.item;
router.push(item ? `/items/${item}` : `/campaigns/${result.link.slug}`);
}
});
return () => subscription.remove();
}, []);The listener receives an AppyDeepLinkResult:
| Result | Meaning |
|---|---|
{ status: 'found', link } |
An Appy link opened the app, or on the first launch Appy found the link tapped before the install (link.isDeferred is true). |
{ status: 'notFound' } |
First launch only. Appy answered, and no Appy link led to this install. |
{ status: 'failed', error } |
First launch only. error is 'timeout', 'network' or 'invalidResponse' (for example an invalid key). |
When it is called:
- on the first launch after install, exactly once, with one of the three results;
- every time an Appy link opens the app, cold or warm, with
'found'; - on no other launch.
A result that arrives while no listener is registered is kept by the native SDK and delivered once, to the next listener; when several arrive, the most recent link wins. Register the listener in a component that lives as long as the app, such as the root layout.
An AppyDeepLink has:
| Property | Meaning |
|---|---|
url |
The Appy link, for example https://your-app.appy.to/spring?item=42 |
deeplinkUrl |
The in-app destination set on the link, for example yourapp://items?item=42 |
slug |
The link's slug, for example spring |
parameters |
The link's query parameters, including utm_*, without the reserved appy_ ones |
isDeferred |
true when the link was tapped before the app was installed |
clickedAt |
When the link was tapped (Date), if known |
When Appy cannot be reached, a link that opened the app still arrives as 'found', read from the URL itself: slug is the first path segment and deeplinkUrl and clickedAt are null. Make your routing work from slug and parameters too.
Appy.getLatestDeepLink() resolves to the most recent link, including a deferred link that arrived after the timeout.
React Native's Linking also receives every link that opens the app, so Expo Router and React Navigation would try to open /spring for https://your-app.appy.to/spring. Let the Appy listener route those links and tell the router to ignore them. With Expo Router, add app/+native-intent.tsx:
import { isAppyLink } from 'react-native-appy';
export function redirectSystemPath({ path }: { path: string; initial: boolean }) {
return isAppyLink(path, ['your-app.appy.to']) ? null : path;
}isAppyLink(url, linkDomains?) returns true for URLs on your link domains and for any URL with appy_click_id. Without the second argument it uses the link and email link domains passed to configure; with it, list your email link domains there too. With React Navigation, apply the same check in linking.getInitialURL and linking.subscribe.
Links from push notifications, in-app messages or QR scanners reach your code as a URL. Pass it to Appy.handleUrl; an Appy link then arrives on the listener and counts as an app open:
if (!(await Appy.handleUrl(url))) {
router.push(url);
}When someone taps an Appy link without your app installed, Appy remembers the tap and sends them to the store. On the first launch after install the package asks Appy for that link, and the listener receives 'found' with isDeferred === true, or 'notFound' for an organic install. On Android the store's install referrer carries the link; on iOS Appy matches the install to the tap. The answer comes within deferredDeepLinkTimeoutMs, counted from configure; until Appy has answered a first launch, later launches ask again.
With openDeferredDeepLinks: true, the SDK also opens the deferred link's deeplinkUrl (for example yourapp://items?item=42) after calling the listener. Your router receives it through Linking like any other link to your scheme, so route deferred links either in the listener or through your router, not both. The SDK opens only URLs your app registered, so it never sends the user to a browser.
To test, delete the app, tap an Appy link on the same device, install your development build and open it. Every install gets a new random install ID, so there is nothing to reset.
Appy.addAttributionListener((attribution) => {
analytics.setUserProperty('acquisition', attribution.isOrganic ? 'organic' : attribution.campaign ?? attribution.linkSlug);
});
const stored = await Appy.getAttribution();The listener is called once, when Appy answers the first launch, also when that answer came after the deep link timeout. getAttribution() resolves to the stored value on every later launch, or null until Appy has answered.
| Property | Meaning |
|---|---|
isOrganic |
true when no Appy link or ad network led to the install |
linkSlug, linkTitle |
The link that brought the install |
source, medium, campaign |
The utm_source, utm_medium and utm_campaign of the tapped link |
clickedAt |
When the link was tapped |
network |
Set when an ad network attributed the install instead of an Appy link: name (google_ads, meta), campaign, ad group, ad, clickId and touch |
- Apple Ads: on iOS 14.3 and later the iOS SDK sends the AdServices attribution token on the first launch. Nothing to configure; the result appears on the install in the dashboard and in install postbacks.
- Google Ads: a
gclidin the Play install referrer marks the install as Google Ads. Nothing to configure. - Meta: set
android: { metaAppId: '<Facebook App ID>' }inconfigureand add the Install Referrer decryption key to your app in the Appy dashboard.
Attribution on the device is for analytics and personalization. Before you grant a referral reward, look the install up from your backend and check attribution.verified; see Install attribution.
Appy.track('signup');
Appy.track('add_to_cart', { properties: { sku: 'A1', qty: 2, gift: false } });
Appy.track('purchase', {
properties: { sku: 'A1' },
revenue: 19.98,
currency: 'USD',
deduplicationId: purchase.transactionId,
});- Names are 1 to 64 characters. Property values are strings, finite numbers or booleans; other values are dropped. At most 50 properties, strings up to 512 characters.
revenueneeds a three-lettercurrency. Negative revenue (refunds) is allowed.deduplicationIdis the store's transaction ID: the App Store transaction ID or the Google Play order ID. Appy counts a purchase once, even when it is tracked again after a restore or also reported by your backend with the same ID.
Events are queued on disk and sent in batches: 2 seconds after the last track, when 20 are waiting, when the app goes to the background, or when you call Appy.flush().
Appy.setUserId('u_123');
Appy.setUserId(null);Call it after sign-in, and with null on sign-out. Use an opaque internal ID, not an email address.
Appy.setTrackingEnabled(false) is stored across launches. While tracking is off, no launch open, deferred deep link lookup or event is sent, and links that open the app still resolve without being stored. If you ask for consent first, turn tracking off before configure until the user answers:
if (!consent.hasAnswered) {
Appy.setTrackingEnabled(false);
}
Appy.configure(configuration);
consent.onAnswer((granted) => Appy.setTrackingEnabled(granted));When tracking turns on, the first-launch lookup starts and the deep link and attribution listeners follow. The SDKs read no advertising ID, show no App Tracking Transparency prompt and ship a privacy manifest on iOS. Data handling is described in the iOS and Android guides.
const url = await Appy.buildLink('spring', { ref: referralCode });This resolves to https://your-app.appy.to/spring?ref=… on the first link domain, or null before configure, without a link domain, or for an invalid slug. The slug must belong to a link in your account.
Set logLevel: 'debug'. On Android run adb logcat -s Appy; on iOS filter the Xcode console or Console.app on subsystem:to.appy.sdk.
xcrun simctl openurl booted "https://your-app.appy.to/spring?item=42"
adb shell 'am start -W -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "https://your-app.appy.to/spring?item=42"'| Symptom | Check |
|---|---|
Cannot find native module 'Appy' |
You are in Expo Go or an old build. Build a development build after installing the package. |
| Links open the browser | The AASA file or assetlinks.json for your domain, the Team ID or SHA-256 fingerprint of the build you run, and the entitlement and intent filter the plugin added. |
| The router opens an unmatched route | Add +native-intent.tsx as shown above. |
| The listener is never called | configure runs at startup, the domain is in linkDomains, and a listener is registered. |
{ status: 'failed', error: 'invalidResponse' } |
HTTP 401 or 403 in the log: the publishable key, or a plan without SDK access. |
| Function | |
|---|---|
configure(configuration) |
Starts the SDK. Call once, early. |
addDeepLinkListener(listener) |
Receives every AppyDeepLinkResult. Returns a subscription with remove(). |
addAttributionListener(listener) |
Receives the install's AppyAttribution, once per install. |
getAttribution() |
The stored install attribution, or null. |
getLatestDeepLink() |
The most recent link, or null. |
handleUrl(url) |
Resolves an Appy link received another way; false for other URLs. |
isAppyLink(url, linkDomains?) |
Whether a URL is an Appy link, for routers. |
track(name, { properties, revenue, currency, deduplicationId }) |
Queues an event. |
setUserId(userId) |
Sets or clears the user ID. |
setTrackingEnabled(enabled), isTrackingEnabled() |
Stored opt-out, on by default. |
buildLink(slug, parameters?) |
Builds a share link. |
flush() |
Sends queued events now. |
nativeSdkVersion() |
The version of the native SDK in the app. |
pluginVersion |
This package's version. |
Every function is also a named export, and the default export Appy groups them.
react-native-appy |
Appy iOS SDK | Appy Android SDK | Expo SDK |
|---|---|---|---|
| 1.0.0 | 1.0.x | 1.0.x | 54 or later |
MIT. See LICENSE.
The Appy source in this repository uses the MIT license. Third-party libraries and platform SDKs keep their own licenses. The Android implementation depends on com.android.installreferrer:installreferrer:2.2; its published POM identifies the Android Software Development Kit License. Obtain it from Google's Maven repository and retain applicable notices; the Appy MIT license does not relicense this dependency.