React Native SDK

Deep linking, deferred attribution, user identity, custom events, and campaign link generation for Android and iOS — on both the new and legacy React Native architectures.

Requirements

The package is a bridge, not an implementation.

Deep link resolution, the install referrer, attribution, queues, retries, device signals and networking all live in the native SDKs, shared with every other Deeplinkly integration. Native method names match those SDKs' own entry points one-for-one, and validation is enforced natively rather than in JavaScript — so a native-only integration and this package give the same answer for the same input.

  • React Native: developed against 0.87 with React 19. Both the new and legacy architectures are supported from one import path.
  • Android: minSdk 24, compileSdk 35, JVM target 17. Wraps com.deeplinkly:deeplinkly-android:1.3.0.
  • iOS: deployment target 13.0, Swift 5. Wraps pod Deeplinkly 1.2.1. The paste control is iOS 16+ and ATT is iOS 14+, both weak-linked at runtime rather than raising the floor.
  • API key: set natively, per platform — never hard-coded in JavaScript. You can create a key in App Settings.

Native key names

PurposeAndroidiOS
API keycom.deeplinkly.sdk.api_keyDeeplinklyApiKey
Link domain allowlistcom.deeplinkly.sdk.link_domainsDeeplinklyLinkDomains
Attribution levelcom.deeplinkly.sdk.attribution_levelDeeplinklyAttributionLevel
Deferred mechanismPlay Install ReferrerPasteboard / DeeplinklyPasteButton
Pasteboard opt-outn/a — Android has no pasteboard pathDeeplinklyCheckPasteboardOnInstall

Install

One npm package, one pod install, and a rebuild.

install
npm install react-native-deeplinkly
cd ios && pod install

Rebuild afterwards

A JS reload does not pick up native code. If the module is missing at runtime the SDK says so on first use rather than at import — “the native module is not linked” — so the bundle still boots and isAvailable() is still callable.

Android autolinks and needs no manifest entries of its own: the install referrer receiver and the INTERNET permission arrive transitively from the native SDK.

Android build requirement — Kotlin 2.2.0

The native Android SDK is compiled with Kotlin 2.2.0, and a 2.0.x compiler cannot read its metadata. Gradle loads one Kotlin plugin for the whole build, so this is set by your app, not by the library:

android/build.gradle
// android/build.gradle
buildscript {
    ext {
        kotlinVersion = "2.2.0"
    }
    dependencies {
        // Version it explicitly. Left bare — as the React Native template has
        // it — the version arrives transitively from react-native-gradle-plugin
        // and ext.kotlinVersion is silently ignored.
        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
    }
}

React Native 0.87's template already sets kotlinVersion = "2.2.0" but still leaves the classpath entry unversioned, so the version arrives transitively from react-native-gradle-plugin and ext.kotlinVersion is silently ignored. The library checks the resolved version at configuration time and fails with this instruction rather than letting the compiler emit an internal error that names nothing.

Android configuration

Use HTTPS App Links for production domains and a custom scheme for dev or fallback.

AndroidManifest.xml
<activity android:name=".MainActivity" android:launchMode="singleTask">
  <!-- App Links. This is the one that matters: it lets a tap on
       https://links.yourapp.com/abc123 open the app directly. Without it
       every link detours through the browser, and in-app browsers that
       block intent:// URLs (Instagram, Facebook, TikTok) never reach your
       app at all — even when it is installed.
       autoVerify only does anything on http/https. -->
  <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="links.yourapp.com" />
  </intent-filter>

  <!-- Custom scheme. The browser fallback path uses this, so keep it —
       but it is a fallback, not a substitute for the filter above. -->
  <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" />
  </intent-filter>
</activity>

<application ...>
  <meta-data
      android:name="com.deeplinkly.sdk.api_key"
      android:value="your_api_key_here" />

  <!-- Optional, but set it if the app App Links any host besides its
       Deeplinkly link domain. Comma separated. -->
  <meta-data
      android:name="com.deeplinkly.sdk.link_domains"
      android:value="links.yourapp.com" />
</application>

Replace links.yourapp.com with your Deeplinkly link domain, and yourapp with the URI scheme you set in the dashboard.

React Native's template already sets launchMode="singleTask", which delivers a warm deep link through onNewIntent — that is what the SDK needs. Do not change it to standard, which would start a second activity instance per link instead.

Which links the SDK claims

The rule is the same on both platforms. A link that came through the redirect carries a click_id, and the SDK acts on that whatever the scheme. The ambiguous case is the App Link / Universal Link bypass, where the OS routes https://links.yourapp.com/<code> straight to the app and the first path segment is the only thing there is to resolve on. So:

  • Custom-scheme URLs without a click_id are ignored. Your own routes (yourapp://settings/notifications) are yours; the SDK will not resolve them.
  • http(s) URLs are resolved by code. If the link-domains list is set, only those hosts are; without it every https link the app handles is, which is fine for an app whose only App Link filter is its link domain.

Set link domains if you App Link anything else

A marketing site, say — otherwise https://www.yourapp.com/pricing is resolved as the link code pricing.

Verifying App Links

Android checks https://<your-link-domain>/.well-known/assetlinks.json on install. Deeplinkly serves that file for you, but only once the dashboard has both your package name and your SHA-256 signing certificate fingerprint — with either missing the endpoint returns 404 and verification silently fails.

verify
# The SHA-256 fingerprint the dashboard needs.
keytool -list -v -keystore <your-keystore> -alias <your-alias> | grep SHA256

# Confirm the file is live and verification passed.
curl https://links.yourapp.com/.well-known/assetlinks.json
adb shell pm get-app-links <your.package.name>

If you use Play App Signing, take the fingerprint from Play Console → Test and release → Setup → App signing, not from your upload keystore — Google re-signs the APK, so the upload fingerprint will not match what ships. pm get-app-links should report verified for your domain; none or legacy_failure means the fingerprint does not match or the file is not reachable.

iOS configuration

Universal Links need Associated Domains and a hosted apple-app-site-association file on your link domain.

Info.plist
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>yourapp</string>
    </array>
  </dict>
</array>

<key>DeeplinklyApiKey</key>
<string>your_api_key_here</string>

<!-- Your Deeplinkly link domains. Subdomains count. Two jobs: the hosts a
     deferred link may be read from, and the hosts whose first path segment
     may be read as a link code. Set it if the app Universal Links any host
     besides its link domain. -->
<key>DeeplinklyLinkDomains</key>
<array>
  <string>yourbrand.deeplinkly.com</string>
</array>

Then add an Associated Domains capability with applinks:yourbrand.deeplinkly.com for each link domain.

Forward links from your AppDelegate

Required on iOS, with no equivalent on Android.

Without this, no deep link reaches the SDK

A React Native native module never receives app-delegate callbacks, so the SDK cannot register itself for the UIApplicationDelegate and UIScene link callbacks the way a native integration does. React Native's own template also ships an AppDelegate with no linking support at all, so there is nothing to piggyback on.

This is the opposite of the Flutter plugin, which registers for those callbacks itself and delivers links twice if you hand-wire them.

ios/<YourApp>/AppDelegate.swift
import react_native_deeplinkly

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
  func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
  ) -> Bool {
    // ... existing React Native setup ...

    // Cold launch. A Universal Link that *starts* the app is in launchOptions,
    // not in continue(userActivity:) — missing this loses exactly the case
    // deferred deep linking exists for.
    RNDeeplinklyLinking.handleLaunchOptions(launchOptions)
    return true
  }

  // Universal Links while running.
  func application(
    _ application: UIApplication,
    continue userActivity: NSUserActivity,
    restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
  ) -> Bool {
    RNDeeplinklyLinking.handleUserActivity(userActivity)
    return RCTLinkingManager.application(
      application, continue: userActivity, restorationHandler: restorationHandler)
  }

  // Custom-scheme links.
  func application(
    _ app: UIApplication,
    open url: URL,
    options: [UIApplication.OpenURLOptionsKey: Any] = [:]
  ) -> Bool {
    RNDeeplinklyLinking.handleURL(url)
    return RCTLinkingManager.application(app, open: url, options: options)
  }
}

Calling RCTLinkingManager as well keeps JS Linking working — the Deeplinkly call is non-exclusive.

Forward eagerly; do not order these carefully

handleLink buffers until the SDK initialises, buffers again until a JS listener attaches, and suppresses a duplicate for the same link — because the resolve is idempotent and attribution is written once.

If your app uses a SceneDelegate

When the host adopts UISceneDelegate, the UIApplicationDelegate callbacks above never fire. Use these three instead:

SceneDelegate.swift
func scene(
  _ scene: UIScene,
  willConnectTo session: UISceneSession,
  options connectionOptions: UIScene.ConnectionOptions
) {
  // Cold launch on the scene path.
  RNDeeplinklyLinking.handleSceneConnectionOptions(connectionOptions)
}

func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
  RNDeeplinklyLinking.handleUserActivity(userActivity)
}

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
  RNDeeplinklyLinking.handleOpenURLContexts(URLContexts)
}

From an Objective-C AppDelegate

Import the generated Swift header instead:

AppDelegate.mm
#if __has_include(<react_native_deeplinkly/react_native_deeplinkly-Swift.h>)
#import <react_native_deeplinkly/react_native_deeplinkly-Swift.h>
#else
#import "react_native_deeplinkly-Swift.h"
#endif

[RNDeeplinklyLinking handleURL:url];

The two forms cover framework and static-library linkage respectively.

Deferred deep linking on iOS

iOS has no install-referrer API, so the link survives an App Store install via the pasteboard.

The Deeplinkly interstitial copies the link when the visitor taps through to the store, and the SDK reads it back on first launch. There are two ways to read it back — pick one.

Option A — <DeeplinklyPasteButton> (recommended, no banner)

A system paste button rendered in your component tree. Because the user taps it themselves, iOS treats the tap as the grant and shows no “Pasted from…” banner at all.

first-run screen
import { DeeplinklyPasteButton } from 'react-native-deeplinkly';

<DeeplinklyPasteButton
  onPasted={(handled) => setShowPasteButton(!handled)}
  fallback={null}
/>;

Put it on a first-run screen next to something like “Tapped a link to get here? Restore where you left off.” The recovered link arrives on your normal listener exactly like any other; onPasted only tells you whether the pasted content was one of your links, so you can hide the button or explain that it was not.

Requires iOS 16+. Renders fallback on Android and older iOS, so it is safe to place unconditionally — isPasteButtonSupported is exported for hosts that want to change surrounding copy rather than just the button. The control has an intrinsic size React Native's layout does not read, so it defaults to 140×40; override with style. Other props are displayMode (iconOnly | labelOnly | iconAndLabel), cornerStyle (small | medium | large | capsule), backgroundColor and foregroundColor.

Leave the colors unset unless you have a reason

Styling a paste button to look like something else is what gets it rejected as misleading.

Option B — automatic read (on by default, shows the banner)

On by default; you do not need to enable it. To turn it off:

Info.plist
<key>DeeplinklyCheckPasteboardOnInstall</key>
<false/>

Do that in Info.plist, not from JavaScript — the read happens during native module construction, before your JS runs, so setCheckPasteboardOnInstall(false) arrives too late to prevent the first one. Turning it on from JS at runtime reads immediately rather than waiting for a next launch the pasteboard may not survive to.

To explain the prompt before it appears, turn the automatic read off in Info.plist and drive it yourself:

priming
if (await Deeplinkly.willShowPasteboardBanner()) {
  await showMyPrimingDialog(); // "we can restore where you left off"
  await Deeplinkly.checkPasteboardNow();
}

willShowPasteboardBanner reads no content and shows no banner itself.

Either way

  • The visitor must tap through the interstitial — there is no auto-redirect on iOS, because Safari will not allow a clipboard write without a user gesture.
  • The automatic read happens once per install, guarded by a persisted flag, and probes the pasteboard's types first with hasURLs, which is banner-free. On iOS 16+ a second banner-free probe, detectPatterns(.probableWebURL), catches links that arrived as plain text.
  • The banner is not limited to your own links. Any URL on the clipboard triggers the read; the SDK then discards anything whose host is not in DeeplinklyLinkDomains — but the banner has already shown. A user with no URL copied sees nothing.
  • The automatic read clears your own link from the pasteboard once the resolve is durably queued; the paste button leaves the pasteboard alone, since the user pasted deliberately.
  • The resolved click is stamped attribution_source = "clipboard", not install_referrer — that API does not exist on iOS.
  • If the first launch is offline the pending resolve is persisted and retried next launch, so an offline install is not lost.
  • Both paths are skipped entirely when tracking is disabled via setTrackingEnabled(false).

Identity and install attribution

Deeplinkly’s stable install id and your own user id, alongside campaign context.

identity.ts
import Deeplinkly from 'react-native-deeplinkly';

const attribution = await Deeplinkly.getInstallAttribution();
const deeplinklyId = await Deeplinkly.getDeeplinklyId();

// custom_user_id, for enrichment and backend user linking.
Deeplinkly.setUserId('user_123');
Deeplinkly.setUserId(null); // clear on logout

getDeeplinklyId is the stable per-install id — the same value the API sees as deeplinkly_device_id / X-Deeplinkly-User-Id. setUserId sets custom_user_id for enrichment and backend user linking.

User data

The fields a conversion is matched on once it reaches Meta’s Conversions API or Google’s enhanced conversions.

user-data.ts
import Deeplinkly from 'react-native-deeplinkly';

// Returns false if any field was malformed, in which case nothing was stored.
const ok = await Deeplinkly.setUserData({
  userId: 'user_123',
  email: 'ada@example.com',
  phoneNumber: '+441234567890',
  firstName: 'Ada',
  lastName: 'Lovelace',
  city: 'London',
  country: 'GB',
});

Every field is optional and each call merges, so you can supply an email at sign-up and an address at checkout. A malformed field rejects the whole call — nothing is stored — so a rejected call never leaves you guessing which of the values took.

Validation is native, so a native-only integration gets the same answer this one does. These fields survive a reduced downgrade, because attribution levels gate what the SDK observes about a device and an email someone typed into your app is not an observation. At none nothing is sent.

  • dateOfBirth: YYYY-MM-DD.
  • gender: 'm' or 'f' — the only two values Meta's ge accepts. Anything else is refused rather than coerced.
  • country: ISO-3166-1 alpha-2, for example 'US'.
  • Every field has a maximum length, enforced before anything is stored.

Your own identifiers

customData carries identifiers Deeplinkly does not name — typically product-analytics ids such as a Mixpanel distinct id or a CleverTap id. Attach a new identifier anytime; no app release required.

custom-data.ts
await Deeplinkly.setUserData({
  userId: 'user_123',
  customData: {
    mixpanel_distinct_id: 'd-8837',
    clevertap_id: 'ct-4412',
  },
});

Up to 10 entries, keys up to 64 characters and values up to 256, enforced natively. Anything larger rejects the whole call, as one bad typed field does.

Erasing it

clear.ts
Deeplinkly.clearUserData(); // erases everything setUserData and setUserId recorded
Deeplinkly.setUserId(null); // clears only the id

This is not merely “stop sending”: the next enrichment carries each previously-set field as an empty value, which the service reads as null this column rather than not reported. The erasure is re-sent until it is delivered, so calling it on a device that is offline still takes effect once it is not.

Custom events

Validation is enforced in the native layer, so the same rules apply to every host — not just React Native callers.

events.ts
import Deeplinkly, { DeeplinklyEvent } from 'react-native-deeplinkly';

const ok = await Deeplinkly.logEvent(DeeplinklyEvent.purchase, {
  order_id: 'ord_42',
  amount: 49.99,
  currency: 'INR',
});

DeeplinklyEvent holds the well-known names the backend reports on without extra configuration (purchase, add_to_cart, signup, and so on); any string is accepted for your own funnels.

Validation

  • Event name: non-empty after trimming, at most 64 characters.
  • At most 25 custom parameters. The SDK's own _dl_* keys do not count toward this, and passing a key with that prefix is rejected.
  • Parameter keys: max 64 characters.
  • Parameter string values: max 256 characters.
  • Array and object values are stored as compact JSON, and the 256 limit applies to that encoded form.
  • null values are rejected, not dropped. So is any type other than string, number, boolean, array or object.

Numbers and booleans keep their JSON types end to end — 49.99 is stored as a number, not "49.99". A rejected event resolves false and sends nothing.

Purchases

A typed wrapper over logEvent, so revenue is spelled the same way by every caller.

purchase.ts
await Deeplinkly.logPurchase({
  value: 49.99,
  currency: 'USD',
  orderId: 'ord_42',
  quantity: 1,
  productId: 'sku_9',
});

Not a separate pipeline: it sends the event named purchase with value and currency set, so everything true of logEvent — the retry queue, the parameter limits, the device block — is true of this too.

value and currency are what Meta's Conversions API and Google's enhanced conversions both key off, so this one call feeds both without you having to match their spelling.

Rejected, sending nothing, if the value is negative or not finite (a refund is a different event, not a negative purchase), the currency is not three letters, the quantity is negative, or parameters contains any of value, currency, order_id, quantity or product_id — pass those as fields instead.

Pass orderId

It is what Google deduplicates conversions on, and how you reconcile a forwarded conversion against your own records.

Privacy

One off switch, one reset, and a middle ground for consent flows.

consent.ts
await Deeplinkly.setTrackingEnabled(false);      // the consent-flow off switch
await Deeplinkly.setAttributionLevel('reduced'); // a middle ground
await Deeplinkly.resetPrivacyData();             // forget this device

setTrackingEnabled(false) sends no enrichment, no events and no error reports, skips the iOS pasteboard read, and deletes pending reporting retries. Deep links still resolve and are still delivered — the link a user tapped keeps working — but functional requests omit the stable Deeplinkly ID and custom user ID. It persists across launches.

resetPrivacyData() removes the stable Deeplinkly ID, custom user ID, attribution, cached device profile, session and event state, pasteboard state and pending queues. Tracking stays disabled afterwards; call setTrackingEnabled(true) only once the user opts back in.

Hashing identifiers on the device

Off by default. With it on, the email, phone number and names given to setUserData are SHA-256 hashed on the device, so the plaintext never leaves it.

pii-hashing.ts
await Deeplinkly.setPIIHashingEnabled(true); // SHA-256 on device before sending
await Deeplinkly.isPIIHashingEnabled();     // off unless you turned it on

Only those four are hashed. Gender, country and date of birth are not: their value ranges are small enough that a digest is reversed by enumerating them, so hashing them would be protection in appearance only.

It costs attribution quality

A digest is computed once, under one normalisation, and advertising destinations disagree about phone formatting — so a conversion forwarded to a destination whose rules differ will not match. Turn it on when a compliance requirement says plaintext must not leave the device, not by default.

Attribution levels

For consent flows that need a middle ground between “track” and “don’t”.

LevelWhat is sent
fullEverything. The default.
reducedDrops every high-entropy hardware signal: screen geometry, model, CPU, local IP, WebView user agent, and the advertising ID / Android ID / IDFA / IDFV. Keeps the coarse context campaign reporting reads — locale, timezone, OS and app version.
minimalOnly the install id, app build, and the link being reported on. Nothing describing the device.
noneNo enrichment at all. Links still resolve and still deliver.

Each level is a strict subset of the one above. Deep link delivery works at every level, including none — this restricts reporting, not functionality. Resolving a link never sends anything describing the device, at any level. setTrackingEnabled(false) wins over the level: while disabled, getAttributionLevel() reports none whatever was set.

To start restricted before any JavaScript runs — enrichment can be sent during native module construction, before a JS call could arrive — set it natively:

Info.plist
<key>DeeplinklyAttributionLevel</key>
<string>reduced</string>
AndroidManifest.xml
<meta-data
    android:name="com.deeplinkly.sdk.attribution_level"
    android:value="reduced" />

The package's docs/SIGNALS.md is the field-by-field reference: every signal the SDK can send, its level, and which platforms report it. It is generated from the same catalogue the SDKs compile against, so it cannot drift from what is actually sent.

Behaviour when the API key is missing

Every method answers with its documented failure value rather than throwing.

isAvailable() returns false and the rest degrade in place — logEvent resolves false, getAttributionLevel resolves none, generateLink resolves { success: false, errorCode: 'SDK_DISABLED' }. getDeeplinklyId and resetPrivacyData keep working, since they are local operations that need no key.

Each method carries its own correctly-typed default rather than sharing one SDK_DISABLED envelope: a typed native module cannot resolve a map where it declared a boolean, so an envelope would break the contract for every method that does not return an object. isAvailable() is what tells you the key is missing — assert it once on a debug build, since a missing key is a configuration bug rather than a runtime condition.

dev assert
if (__DEV__ && !(await Deeplinkly.isAvailable())) {
  console.warn('Deeplinkly: no API key in AndroidManifest.xml / Info.plist');
}
verbose native logging
Deeplinkly.setDebugMode(__DEV__);

API reference (TypeScript)

MethodReturnsDescription
addListener(handler)SubscriptionSubscribes to resolved deep links, and signals readiness to native — links buffered before the first listener are flushed on subscribe.
isAvailable()Promise<boolean>False when the API key is missing from AndroidManifest.xml / Info.plist. This is how you tell a misconfigured build from a call that simply failed.
getDeeplinklyId()Promise<string>Stable per-install id — the same value the API sees as deeplinkly_device_id. Works even with no API key. Empty string on failure.
setUserId(id | null)voidSets custom_user_id for enrichment and backend user linking. Fire-and-forget; pass null to clear on logout.
setUserData(data)Promise<boolean>Merges the conversion-matching fields, plus your own customData ids. All-or-nothing: one malformed field stores none of them.
clearUserData()voidErases everything setUserData and setUserId recorded, here and on Deeplinkly's servers. Re-sent until delivered.
getInstallAttribution()Promise<Record<string, string>>Install and campaign attribution for this device. Empty object on failure.
generateLink(content, options)Promise<DeeplinklyResult>Builds a campaign link. Resolves on failure rather than rejecting — inspect success, url, errorCode, errorMessage.
logEvent(name, params?)Promise<boolean>Validated custom event. False when the native layer rejects it; nothing is sent on rejection.
logPurchase(purchase)Promise<boolean>Typed wrapper over logEvent. Sends the `purchase` event under the one spelling Meta and Google are both built from.
setTrackingEnabled(bool)Promise<boolean>The consent-flow off switch. Persists across launches. Wins over setAttributionLevel.
resetPrivacyData()Promise<boolean>Deletes locally stored privacy data. Leaves tracking disabled afterwards.
setAttributionLevel(level)Promise<boolean>Restricts what enrichment is sent. False on an unknown level.
getAttributionLevel()Promise<AttributionLevel>The level currently in force. Reports none while tracking is disabled, whatever was set.
setPIIHashingEnabled(bool)Promise<boolean>SHA-256 the email, phone and names on the device before they are sent. Off by default.
isPIIHashingEnabled()Promise<boolean>Whether on-device hashing is on.
setCheckPasteboardOnInstall(on, checkNow?)Promise<boolean>iOS only; false on Android. To disable the automatic read, use Info.plist instead — this call arrives too late.
willShowPasteboardBanner()Promise<boolean>Whether a read would surface the system banner. Reads no content and shows no banner itself. Always false on Android.
checkPasteboardNow()Promise<boolean>Starts the pasteboard read on demand — it reports that the read began, not what it found. The link arrives on your listener.
setDebugMode(bool)voidVerbose native logging for integration debugging.

Testing

Generate a link with generateLink, then open it. A URL with no click_id and no Deeplinkly short code is skipped by design, so yourapp://open?screen=home will never deliver anything — use a generated link.

open a link
# iOS Simulator
xcrun simctl openurl booted "https://<your-link-domain>/<code>"

# Android device or emulator
adb shell "am start -W -a android.intent.action.VIEW \
  -d 'https://<your-link-domain>/<code>' -p com.yourapp"

Verify cold start (process killed) and warm start (app backgrounded) separately. Two constraints worth planning around:

  • Android: the Install Referrer is unavailable on sideloaded builds — test deferred flows through Play internal testing.
  • iOS: deferred deep linking cannot be tested on the Simulator (no App Store). The automatic read is once per install and clears the pasteboard, so reinstall to retest — relaunching will not repeat it.

Troubleshooting

“The native module is not linked”

The bundle resolved neither the TurboModule nor NativeModules entry. Rebuild the app after installing — a JS reload will not pick up native code — and run pod install first on iOS.

No link ever reaches the listener on iOS

Almost always the missing AppDelegate forwarding: a React Native module cannot receive those callbacks itself. If the app adopts UISceneDelegate, the UIApplicationDelegate callbacks never fire — wire the three scene methods instead.

Android build fails on Kotlin metadata

Set kotlinVersion = "2.2.0" in android/build.gradle and version the kotlin-gradle-plugin classpath entry explicitly. Left bare, the version arrives transitively and ext.kotlinVersion is ignored.

Cold-start links are lost

Subscribe with addListener at app root rather than on a screen. Subscribing is what flushes the native queue, and a listener that unsubscribes mid-delivery may see the link redelivered later.

Attribution always empty

Read after splash; on first open the native store may not be ready. Check the attribution level — none sends no enrichment, so it looks empty server-side even when the link resolved correctly.

Deferred does nothing on iOS

Confirm the link host is listed in DeeplinklyLinkDomains; an unlisted custom domain is ignored by design. Then confirm you are testing on a real device with a genuinely fresh install.

logEvent returns false

Check name length, param count, key length, and string value length. Keys starting with _dl_ are reserved and rejected, and null values are rejected rather than dropped.

generateLink error is undefined

Read result.errorCode / result.errorMessage. The wire shape is snake_case but the JS surface is camelCase, so result.error_code is always undefined.

Repository & more docs

The extended README, the full markdown guide, and the signal-by-signal reference live in the open-source package: README.md, docs/REACT_NATIVE_SDK.md, docs/SIGNALS.md. The example/ app is a React Native 0.87 host wired to the library, with a button for each API and a running log of received links.

Back to documentation home