@adshift/react-native-plugin
v2.0.0
Published
AdShift SDK for React Native - Mobile Attribution, Event Tracking, SKAdNetwork 4.0+, Deep Linking and GDPR/TCF 2.2 Compliance
Readme
AdShift React Native Plugin
Official AdShift SDK for React Native. The plugin wraps the native iOS SDK and Android SDK behind one TypeScript API: install attribution, in-app events, ad revenue, SKAdNetwork, deep linking (direct, deferred and push) and GDPR/TCF 2.2 consent.
Table of Contents
- Requirements
- Installation
- Quick Start
- Lifecycle
- Event Tracking
- Ad Revenue
- Deep Linking
- Push Notifications
- Consent (GDPR/DMA)
- Configuration
- Errors
- Platform Setup
- Store Privacy Declarations
- Expo
- Testing
- Development Notes
- Upgrading from 1.x
- Example App
- Troubleshooting
- For Maintainers
- Support
Requirements
| | Minimum |
|---|---|
| React Native | 0.82.0 (New Architecture only) |
| iOS | 15.1, Xcode 16.1+ |
| Android | API 24 (Android 7.0), compileSdk 35+, Kotlin 2.0+ |
| Node.js | 20+ (this repository is developed on the version in .nvmrc) |
| TypeScript | 5.0+ (recommended) |
Mac Catalyst and visionOS are not supported (the native iOS SDK ships iOS-only binaries).
Native SDK versions
Each plugin release pins exact native SDK versions. Do not override them in your Podfile or Gradle files.
| Plugin | Android SDK | iOS SDK |
|---|---|---|
| 2.0.x | com.adshift:android-sdk:3.1.0 | AdshiftSDK 2.2.0 |
Installation
npm install @adshift/react-native-plugin
# or
yarn add @adshift/react-native-pluginiOS
cd ios && pod installThen add the two RCTLinkingManager handlers to AppDelegate.swift (see Platform Setup → iOS); the plugin relies on them to receive links while the app is running.
Android — nothing else to do; the module autolinks. See Platform Setup → Android for backup rules and the AD_ID permission.
Quick Start
import {
Adshift,
AdshiftEventParam,
AdshiftEventType,
AdshiftConsent,
AdshiftDeepLinkStatus,
} from '@adshift/react-native-plugin';
// 1. Register the deep link listener first. It is safe before initialize();
// results that arrive earlier are buffered and delivered to the first listener.
const subscription = Adshift.onDeepLink((deepLink) => {
if (deepLink.status === AdshiftDeepLinkStatus.Found) {
// route on deepLink.deepLinkValue or deepLink.params
}
});
// 2. Initialize once, as early as possible
Adshift.initialize({
apiKey: 'YOUR_API_KEY',
isDebug: __DEV__,
brandedDomains: ['links.yourbrand.com'],
});
// 3. Consent (every launch, before start)
Adshift.setConsentData(AdshiftConsent.forNonGDPRUser());
// 4. Start. Never rejects; check result.success.
const result = await Adshift.start();
// 5. Track. Calls made before start() resolves are queued and sent afterwards.
await Adshift.trackEvent(AdshiftEventType.Purchase, {
[AdshiftEventParam.Revenue]: 9.99,
[AdshiftEventParam.Currency]: 'USD',
});
// Later
subscription.remove();Lifecycle
Adshift.initialize(config)
Configures the SDK. Call once; the first configuration is kept and later calls are ignored. Throws AdshiftError with INVALID_ARGUMENT when apiKey is missing or an option is malformed.
See AdshiftConfig for every option.
Adshift.start(): Promise<StartResult>
Starts tracking. Resolves once the SDK has started locally and never rejects:
interface StartResult {
success: boolean;
apiKeyValidationStatus?: 'pending' | 'valid' | 'invalid';
errorCode?: AdshiftErrorCode; // when success is false
errorMessage?: string;
}- The API key is verified with the AdShift backend in the background, so
apiKeyValidationStatusreportsvalidorinvalidonce the answer is in, andpendingwhile it is not. A key the backend rejects afterstart()resolved is reported throughconfig.onErrorasINVALID_API_KEY, and events stay buffered by the native SDK. - A device excluded from tracking resolves
{ success: false, errorCode: 'OPTED_OUT' }. - On iOS
start()may take a few seconds on the first launch (SKAdNetwork configuration) and, if you enabledwaitForATTBeforeStart, waits for the ATT decision up toattTimeoutMs. - Calling
start()again while started resolves{ success: true }immediately. Calling it beforeinitialize()resolves{ success: false, errorCode: 'NOT_INITIALIZED' }. - Everything queued with
trackEvent,trackPurchaseandlogAdRevenuebetweeninitialize()and a successfulstart()is dispatched in order whenstart()succeeds. If the start failed for a reason retrying cannot fix —INVALID_API_KEY,OPTED_OUT,NOT_INITIALIZED— the queued promises reject with that code. If it failed for any other reason, they keep waiting for the nextstart(), so the data survives a start that failed while the device was offline. Queued promises stay pending until somestart()finishes, which is whystart()belongs on every launch path.
Adshift.stop(): void
Pauses delivery. Tracking calls reject with NOT_STARTED until the next successful start(). This is not an opt-out; opt-out is configured on the AdShift side.
Deep link forwarding keeps working after stop(): a link that opens the app still reaches the SDK, so attribution of that click is not lost, and the SDK decides what to do with it while delivery is paused. Configuration setters and refreshConsent() also keep working, so consent changes apply while delivery is paused. Calls queued before the interrupted start() reject with NOT_STARTED.
Adshift.isStarted(): boolean
true between a successful start() and stop(). Safe before initialize(). Reflects the JavaScript side: after a JS reload in development or an OTA update the native SDK may still be running while isStarted() returns false — call start() again.
Adshift.getAdShiftDeviceId(): Promise<string | null>
The AdShift device identifier; null before initialize().
Adshift.getInfo()
{ platform: 'ios' | 'android', version: '2.0.0', nativeSdkVersions: { android: '3.1.0', ios: '2.2.0' } }Event Tracking
Adshift.trackEvent(eventName, values?): Promise<void>
await Adshift.trackEvent(AdshiftEventType.AddToCart, {
[AdshiftEventParam.ContentId]: 'SKU123',
[AdshiftEventParam.Price]: 29.99,
in_stock: true, // custom parameters are fine alongside the standard ones
});
await Adshift.trackEvent('custom_event');values accepts strings, numbers and booleans (no nested objects). The promise resolves once the SDK has accepted the event; delivery is asynchronous and survives offline periods. Rejections use AdshiftError.
app_install and app_open are reserved — the SDK sends them itself, so passing them here rejects with INVALID_ARGUMENT instead of adding hand-made records to your install and session counts.
Adshift.trackPurchase(params): Promise<void>
await Adshift.trackPurchase({
productId: 'premium_monthly',
revenue: 9.99,
currency: 'USD',
transactionId: 'GPA.1234-5678-9012-34567',
});Predefined event types
AdshiftEventType exposes 30 standard events:
| Category | Constants |
|---|---|
| E-commerce | AddToCart, AddToWishList, AddPaymentInfo, InitiatedCheckout, Purchase, ContentView, ListView, Search |
| User lifecycle | CompleteRegistration, Login, TutorialCompletion, Subscribe, StartTrial |
| Gaming | LevelAchieved, AchievementUnlocked, SpentCredit |
| Engagement | Rate, Share, Invite, ReEngage, Update, OpenedFromPushNotification |
| Travel | TravelBooking |
| Ads | AdClick, AdView, AdRevenue |
| Location | LocationChanged, LocationCoordinates |
| Other | OrderId, CustomerSegment |
getAllEventTypes() returns the wire names as an array.
Standard parameter names
AdshiftEventParam holds the 81 parameter names reports aggregate — the same set the native SDKs expose. Use them for anything that has to be counted, revenue above all:
await Adshift.trackEvent(AdshiftEventType.Purchase, {
[AdshiftEventParam.Revenue]: 9.99,
[AdshiftEventParam.Currency]: 'USD',
[AdshiftEventParam.Quantity]: 1,
gift_wrapped: true,
});Any other key is stored with the event as a custom parameter, so revenue: 9.99 arrives intact but is not counted as revenue. Nothing is rewritten for you: in development the plugin warns once per name when a value looks like a standard parameter spelled the plain way (price, contentId, latitude) and tells you which constant to use. getAllEventParams() returns the whole set as an array.
trackPurchase() and logAdRevenue() need none of this — they fill the revenue and currency names from their own arguments.
Ad Revenue
Adshift.logAdRevenue(params): Promise<void>
import { AdshiftMediationNetwork } from '@adshift/react-native-plugin';
await Adshift.logAdRevenue({
monetizationNetwork: 'admob',
mediationNetwork: AdshiftMediationNetwork.GoogleAdMob,
currency: 'USD',
revenue: 0.0125,
adUnitId: 'ca-app-pub-1234/5678', // optional
precision: 'estimated', // optional
additionalParameters: { placement: 'home_banner' }, // optional
});mediationNetwork must be an AdshiftMediationNetwork value (AppLovinMAX, GoogleAdMob, IronSource, Chartboost, TopOn, TradPlus, AdMost, Appodeal, Fyber, Unity, Yandex, CustomMediation, DirectMonetization, Other). revenue is the value of one impression and must be positive; the native SDKs drop implausibly large per-impression amounts (above 10 in the given currency unit, regardless of currency).
Deep Linking
The contract
- Every link that opens the app and reaches the SDK — direct, deferred (first launch after install) and push — produces exactly one
onDeepLinkresult. status === 'found'means the link was delivered; it does not mean it is an attribution link. Route ondeepLinkValue,paramsor the URL and ignore links you do not care about.- Results are delivered only through
onDeepLink.handleDeepLink()andhandlePushNotification()resolve when the SDK has accepted the input.
Adshift.onDeepLink(callback): DeepLinkSubscription
useEffect(() => {
const subscription = Adshift.onDeepLink((deepLink) => {
if (deepLink.status !== AdshiftDeepLinkStatus.Found) return;
if (deepLink.deepLinkValue === 'product') {
navigate('Product', { id: deepLink.params?.product_id });
}
});
return () => subscription.remove();
}, []);- Safe before
initialize(). Up to 20 results that arrive while no listener is registered are buffered and replayed to the first listener. When the buffer is full a direct result is dropped before a deferred one, because a deferred result cannot be produced again. - Listeners are called on the JS thread; an exception in one listener does not affect the others.
remove()is idempotent.
interface AdshiftDeepLink {
deepLink?: string; // the URL
deepLinkValue?: string; // deep_link_value for routing
params?: Record<string, string>; // query parameters
isDeferred: boolean; // first launch after install
isFirstSession?: boolean; // Android only, on every result
status: AdshiftDeepLinkStatus; // Found | NotFound | Error
errorMessage?: string; // with Error; wording differs per platform
deepLinkSub1?: string; /* … */ deepLinkSub5?: string;
}Automatic forwarding (default)
With autoHandleDeepLinks: true (the default) you write no linking code:
- Android — the plugin reads the launch Intent and every new Intent (App Links, custom schemes, push taps that carry
as_push_link). Relaunches from Recents and the launcher are not re-processed. - iOS — the plugin uses React Native's
Linking(getInitialURLand theurlevent). YourAppDelegate.swiftmust forward URLs toRCTLinkingManager(see Platform Setup → iOS); without it links opened while the app is running never reach JavaScript.
start() waits (up to 3 s) for the launch URL to be handed to the SDK, so the first app_open carries the click parameters.
Manual forwarding
Set autoHandleDeepLinks: false and forward URLs yourself:
await Adshift.handleDeepLink(url); // resolves once accepted; result arrives via onDeepLinkRejects with NOT_INITIALIZED or INVALID_ARGUMENT (empty or malformed URL). If you keep an old Linking → handleDeepLink handler while automatic forwarding is on, the plugin ignores a URL it forwarded itself within the previous two seconds, so nothing is counted twice.
Deferred deep links
On the first launch after install the SDK resolves the deferred deep link (if the user clicked an AdShift link before installing) and delivers it through onDeepLink with isDeferred: true. Nothing to configure.
Push Notifications
The SDK reads an AdShift link from a push payload under the top-level key as_push_link. Register your own key paths with pushDeepLinkPaths in initialize() or Adshift.addPushNotificationDeepLinkPath(['data', 'link']) — they are searched in the order you register them.
Registering your own paths takes over from as_push_link. If your payloads use both, list it explicitly:
pushDeepLinkPaths: [['data', 'link'], ['as_push_link']],- Android — a tap on a notification whose Intent carries the payload extras is handled automatically (with
autoHandleDeepLinks). - iOS, or when you build the notification yourself — hand the payload over:
// @react-native-firebase/messaging
messaging().onNotificationOpenedApp((message) => {
Adshift.handlePushNotification(message.data ?? {});
});
const initial = await messaging().getInitialNotification();
if (initial) await Adshift.handlePushNotification(initial.data ?? {});
// notifee
notifee.onForegroundEvent(({ type, detail }) => {
if (type === EventType.PRESS) Adshift.handlePushNotification(detail.notification?.data ?? {});
});handlePushNotification resolves true when the payload carried an AdShift link (the result arrives through onDeepLink). Only taps should be forwarded, not deliveries. Call it before start() when the payload is known at launch, so the first app_open carries the push parameters.
Consent (GDPR/DMA)
Set consent on every launch, before start(), and again whenever the user changes it.
Manual consent
// User subject to GDPR; omitted flags are "not decided" and are not sent
Adshift.setConsentData(
AdshiftConsent.forGDPRUser({
hasConsentForDataUsage: true,
hasConsentForAdsPersonalization: false,
hasConsentForAdStorage: true,
})
);
// User not subject to GDPR: everything is permitted, individual flags are not sent
Adshift.setConsentData(AdshiftConsent.forNonGDPRUser());Each flag is boolean | null (ConsentFlag). AdshiftConsent.isConsentGranted(consent) applies the SDK rule (non-GDPR → granted; GDPR → data usage and ad storage must be true).
TCF 2.2 / CMP
Adshift.enableTCFDataCollection(true); // before start()
await Adshift.start();
// after the CMP dialog closes
const snapshot: ConsentSnapshot = await Adshift.refreshConsent();GPP (US state privacy)
Two independent axes. enableGPPDataCollection forwards the IAB GPP string the
CMP wrote, alongside whatever decides the GDPR axis — including a consent set
through setConsentData(). It does not gate the advertising identifier on the
device; a US opt-out limits what may be done with an event afterwards.
Adshift.enableGPPDataCollection(true); // before start()
await Adshift.start();
// after the CMP writes its GPP string
const snapshot = await Adshift.refreshConsent();
snapshot.gppPresent; // true once a GPP string is on the deviceThird-party sharing
A third axis, and the one with the opposite default: nothing said here means
sharing is allowed. So state it only once the user has actually been asked — a
allowed() set at startup as a default records a declaration the user never
made, and events already sent cannot be recalled when a real answer arrives.
// The user asked not to have their data passed on
Adshift.setThirdPartySharing(AdshiftThirdPartySharing.optedOut());
// The user was asked and did not opt out
Adshift.setThirdPartySharing(AdshiftThirdPartySharing.allowed());
// Back to having no answer on record, which is not the same as `allowed()`
Adshift.clearThirdPartySharing();Nothing is gated on the device either way; the answer travels with the events.
interface ConsentSnapshot {
source: 'manual' | 'tcf' | 'gpp' | 'none';
gdprApplies?: number;
tcStringPresent: boolean;
gppPresent?: boolean;
cmpDetected: boolean;
adUserDataEnabled?: boolean;
adPersonalizationEnabled?: boolean;
adStorageEnabled?: boolean;
appliedAtMillis: number;
adIdentifiersAllowed: boolean;
}source names what decided the GDPR axis, where manual outranks TCF. It is not a
ranking across all three axes: a GPP string and a sharing answer ride alongside
whatever source says, and neither is displaced by calling setConsentData().
cmpDetected separates the two states nothing else here can tell apart. A CMP
installed but not yet answered looks identical to no CMP at all, and the two are
fixed by opposite actions: false when you expect a CMP means it never ran or was
never wired up, while true with source: 'none' means it ran and the user has
not answered yet, which resolves itself once they do.
adIdentifiersAllowed answers whether consent lets the SDK attach the advertising identifier (GAID or OAID on Android, IDFA on iOS) to what it reports; the adshift_device_id is never affected. Both ad storage and ad user data have to be allowed, and each platform applies that to the flags its own way — under GDPR an undecided flag counts as a refusal on Android, while iOS refuses only on an explicit denial. Read this field instead of comparing the flags yourself.
Configuration
AdshiftConfig
| Option | Type | Default | Notes |
|---|---|---|---|
| apiKey | string | — | Required |
| isDebug | boolean | SDK default | Verbose native logging |
| appOpenDebounceMs | number | 10000 | Debounce for automatic app_open |
| brandedDomains | string[] | — | Custom attribution domains |
| metaAppId | string | — | Meta App Links parsing (Android) |
| pushDeepLinkPaths | string[][] | [['as_push_link']] | Extra key paths in push payloads |
| autoHandleDeepLinks | boolean | true | Automatic URL forwarding |
| disableSKAN | boolean | false | iOS |
| waitForATTBeforeStart | boolean | false | iOS; only when your app shows the ATT prompt |
| attTimeoutMs | number | 30000 | iOS; clamped to 5 000–120 000 |
| collectOaid | boolean | true | Android; OAID on devices without Google Play services |
Only options you set are passed to the native SDK; everything else keeps the SDK default.
Runtime setters
None of these raise. A value that fails validation is skipped and reported (see
Errors), and a call made before initialize() is kept and applied
afterwards, so setup code does not depend on call order.
Adshift.setCustomerUserId('user_123'); // before or after start()
Adshift.setDebugEnabled(true);
Adshift.setAppOpenDebounceMs(5000);
Adshift.setBrandedDomains(['links.yourbrand.com']); // before start()
Adshift.setMetaAppId('1234567890'); // Android; no-op on iOS
Adshift.setOaidData(oaidFromYourOwnCode); // Android; no-op on iOS
Adshift.addPushNotificationDeepLinkPath(['data', 'deep_link']);Errors
The plugin never raises into your code. Methods that return a promise reject
with an AdshiftError; methods that return nothing skip the call and report it,
because an error thrown from an event handler or a native callback would take
down the app instead of the call. Nothing the plugin does can crash your app.
Failures the plugin cannot answer with a rejected promise are reported on two
channels: the console in development, and config.onError in every build.
Adshift.initialize({
apiKey: 'your-api-key',
onError: (error) => {
// Integration mistakes, a config the plugin refused, a rejected API key.
Sentry.captureMessage(`AdShift: ${error.code} ${error.message}`);
},
});Every rejected promise and every report is an AdshiftError:
import { AdshiftErrorCode, isAdshiftError } from '@adshift/react-native-plugin';
try {
await Adshift.trackEvent('x');
} catch (error) {
if (isAdshiftError(error)) {
error.code; // AdshiftErrorCode
error.message;
error.userInfo.nativeCode; // raw native code, when available
error.userInfo.nativeDomain; // iOS NSError domain, when available
}
}| Code | Meaning |
|---|---|
| NOT_INITIALIZED | initialize() has not been called |
| NOT_STARTED | stop() was called; call start() |
| INVALID_API_KEY | The backend rejected the API key |
| OPTED_OUT | Tracking is disabled for this app on the AdShift side |
| QUEUE_FULL | More than 100 calls are waiting for start(), or the Android SDK refused the call because its own queue is full |
| INVALID_ARGUMENT | A parameter failed validation |
| UNKNOWN | Anything else; see userInfo.nativeCode |
start() never rejects; it reports these codes in StartResult.errorCode.
Platform Setup
iOS
1. Deployment target — platform :ios, '15.1' or higher in ios/Podfile (React Native 0.82 already requires this). The plugin builds with the default static libraries, use_frameworks! :linkage => :static and dynamic frameworks.
2. AppDelegate.swift — forward URLs to React Native so Linking (and the plugin) receive them. The bare React Native template does not include these methods; Expo projects need none of this (see Expo):
import React
// inside class AppDelegate
func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
return RCTLinkingManager.application(app, open: url, options: options)
}
func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
return RCTLinkingManager.application(
application,
continue: userActivity,
restorationHandler: restorationHandler
)
}3. Universal Links and custom schemes — add the Associated Domains capability (applinks:yourapp.rightlink.me or your branded domain) and, for custom schemes, CFBundleURLTypes in Info.plist.
4. App Tracking Transparency — if your app shows the ATT prompt, add NSUserTrackingUsageDescription to Info.plist and set waitForATTBeforeStart: true; start() then waits for the decision up to attTimeoutMs. If your app does not prompt, leave the option off.
5. SKAdNetwork postbacks — add to Info.plist:
<key>NSAdvertisingAttributionReportEndpoint</key>
<string>https://adshift-skadnetwork.com</string>The native SDK ships its own privacy manifest; the plugin adds no tracked APIs. For what to put in App Store Connect, see Store Privacy Declarations.
6. Pod linkage — the plugin is built in all three CocoaPods linkage modes: the React Native default, where pods are static libraries, plus use_frameworks! :linkage => :static and use_frameworks! :linkage => :dynamic. Nothing extra is needed on your side for any of them.
Android
1. Requirements — minSdkVersion 24, compileSdkVersion 35+.
2. Dependencies brought by the native SDK — okhttp 4.12, retrofit 2.9, gson 2.10, installreferrer 2.2, play-services-ads-identifier 18.0.1, androidx.lifecycle:lifecycle-process. Gradle resolves conflicts to the highest version; if you pin okhttp lower than 4.12 in your app, raise it.
3. Permissions — the SDK manifest declares INTERNET, ACCESS_NETWORK_STATE and com.google.android.gms.permission.AD_ID, plus two permissions and a set of <queries> entries used to read the OAID on devices without Google Play services. All of them merge into your app. Apps in the Families program, and any app directed to children under COPPA, must remove AD_ID:
<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" />Without that permission the advertising id is no longer read, and attribution continues on the SDK's own install identifier. The OAID permissions and queries can be removed the same way — see the Android SDK README.
4. Backup rules — the SDK manifest sets android:fullBackupContent and android:dataExtractionRules on <application> so its identifiers are never restored on a new install. The React Native template (android:allowBackup="false", no rules attribute) merges cleanly. If your app declares its own rules attributes, the build fails with a manifest merger conflict: add tools:replace for the attributes you declare and copy the SDK's exclusions into your rules files — see the Android SDK README, "Backup Rules".
5. App Links — add the intent filters for your branded domain / custom scheme to MainActivity; launchMode="singleTask" (the template default) is recommended. No JavaScript code is needed.
6. Code shrinking — the native SDK ships consumer ProGuard rules, so minifyEnabled true needs no keep rules of your own, and release builds with shrinking enabled are part of our build checks. Those rules keep the SDK's public API and its wire models and leave the rest to R8, so SDK frames in crash reports arrive obfuscated; upload your release mapping.txt. Do not add -keep class com.adshift.sdk.** { *; } — it is not needed and forces the whole native SDK into the APK unshrunk.
7. Devices without Google Play services — the SDK reads the OAID itself when collectOaid is on (the default). If your app already obtains it, pass it with Adshift.setOaidData(oaid) and set collectOaid: false.
Store Privacy Declarations
Both stores ask the publisher to declare what the app collects, and that includes what the SDK sends on your behalf. The plugin itself collects nothing: it forwards calls to the native SDK. The tables below say which declarations follow from installing the SDK and which ones only apply if your app makes a particular call. The wording of your forms is yours to decide — this is the input, not the answer.
Always sent, once the SDK starts
| What | Apple App Privacy | Google Play Data safety | | --- | --- | --- | | The SDK's install identifier, the vendor identifier on iOS, and the advertising id when consent and platform settings allow it | Identifiers → Device ID | Device or other IDs | | Installs, app opens and foreground time | Usage Data → Product Interaction | App activity → App interactions | | Device model, OS version, language, carrier, connection type | Usage Data | App info and performance | | The opening URL when the app is launched from a link | Usage Data → Other Usage Data | App activity → Other actions | | The consent state you reported, including the IAB TC string when one is present, and the ATT status on iOS | Usage Data → Other Usage Data | App info and performance |
Only if your app calls it
| Your call | Apple App Privacy | Google Play Data safety |
| --- | --- | --- |
| setCustomerUserId | Identifiers → User ID | Personal info → User IDs |
| trackPurchase | Purchases → Purchase History | Financial info → Purchase history |
| logAdRevenue | Usage Data → Advertising Data | App activity → Other actions |
| trackEvent with your own parameters | depends on what you put in them | depends on what you put in them |
Notes that decide several rows for you:
- The advertising id is optional in practice. On Android it is read only while the
AD_IDpermission is present (see Platform Setup → Android); on iOS only after the user allows tracking through ATT. Removing the permission, or never showing the ATT prompt, keeps the identifier out of your declaration — attribution continues on the SDK's own install identifier, which is still collected. - Consent gates it too. When you report a refusal through the consent API, the advertising id is withheld;
Adshift.refreshConsent()returnsadIdentifiersAllowedso you can see the current answer. - No location is collected. No location permission or API is used. The country reported by the SDK comes from the device's region setting.
- Custom event parameters are yours. Whatever you pass to
trackEventis sent as you wrote it, so anything personal in there belongs in your declaration. - Opening URLs are forwarded whole, including links that have nothing to do with a campaign, for as long as
autoHandleDeepLinksis on. If your links can carry personal data in their parameters, either keep it out of them or turn the option off and pass only the links you choose withAdshift.handleDeepLink(url). - Everything travels over HTTPS.
Whether your app's data counts as "shared" under Play's definition, and whether the identifiers are used for tracking under Apple's definition, depends on your agreement with AdShift rather than on the SDK — settle those two with us before you submit.
Expo
The plugin contains native code, so it works with development builds / prebuild (expo run:ios, expo run:android, EAS Build) and not with Expo Go.
No config plugin is needed, and neither is any native edit:
- Autolinking and codegen pick the module up.
- Expo forwards opened URLs and universal links to React Native's
Linkingitself, so skip step 2 of the iOS setup — the generatedAppDelegate.swiftis regenerated byexpo prebuildanyway. - Everything else belongs in
app.json:ios.associatedDomainsfor universal links,ios.infoPlistforNSUserTrackingUsageDescriptionandNSAdvertisingAttributionReportEndpoint,android.intentFiltersfor App Links, andschemefor a custom scheme.
An OTA update via Expo Updates reloads JavaScript — see Development Notes.
Testing
The package resolves its native module at import time, which fails in a plain Jest environment. Use the bundled mock:
// jest.setup.js
jest.mock('@adshift/react-native-plugin', () =>
require('@adshift/react-native-plugin/jest').createAdshiftMock()
);Every method is a jest.fn() with a sensible resolved default; Adshift.__emitDeepLink(deepLink) delivers a deep link to registered listeners and Adshift.__reset() clears state. The mock exports the same helpers as the package (AdshiftConsent, AdshiftDeepLink, AdshiftEventType, …).
The package ships both ES module and CommonJS builds, so the default react-native Jest preset loads it without changes to transformIgnorePatterns.
Development Notes
- JS reload (Fast Refresh, Dev Menu reload,
DevSettings.reload(), Expo Updates / CodePush) recreates the JavaScript module: the deep link buffer and the pending-call queue are empty,isStarted()isfalseuntil you callstart()again, andinitialize()accepts the configuration again. The native SDK keeps running; deep links tapped after the reload are still delivered to the new JavaScript instance. - Debug logging —
isDebug: true(orsetDebugEnabled(true)) turns on the native SDK's verbose logs (Logcat tagsAdShiftSDKandAdShiftRNon Android, the unified log on iOS). The plugin never logs URLs or event values.
Upgrading from 1.x
2.0.0 targets Android SDK 3.1.0 and iOS SDK 2.2.0 and is a breaking release.
Removed
NativeAdShiftReactNativePluginandAdShiftNativeSpecexports — the native module is internal.configToMap,consentToMap,deepLinkToMapexports — internal bridge helpers with no use outside the plugin. If you called them, drop the call: the plugin converts its own arguments.StartResult.message.
Changed
start()resolves{ success, apiKeyValidationStatus?, errorCode?, errorMessage? }after the local start and no longer waits for API key verification. It never rejects.handleDeepLink(url)returnsPromise<void>; the result arrives throughonDeepLink. RegisteronDeepLinkbeforeinitialize()to be sure nothing is missed (results are buffered anyway).- No method raises any more. Promises still reject; the rest report through
config.onErrorand skip the call. Removetry/catcharoundinitialize()and the setters, and readonErrorinstead. - Setters called before
initialize()are kept and applied afterwards instead of failing. - Deep links are forwarded automatically by default (
autoHandleDeepLinks). Remove yourLinking.addEventListener('url', …) → handleDeepLinkcode, or keep it — the plugin deduplicates its own forwards. On iOS add theRCTLinkingManagerhandlers toAppDelegate.swift. - Consent flags are tri-state (
boolean | null);AdshiftConsent.forGDPRUser()accepts partial input andforNonGDPRUser()sends no individual flags.AdshiftConsent.isConsentGrantedrequires explicittrue. refreshConsent()resolves a typedConsentSnapshot.AdshiftDeepLink.paramsisRecord<string, string>;isFirstSessionadded (Android).trackEvent/trackPurchasecalls beforestart()are queued (up to 100) instead of failing; afterstop()they reject withNOT_STARTED.- All errors are
AdshiftErrorwithcodefromAdshiftErrorCode; native codes moved touserInfo.nativeCode. getInfo()returns the real plugin version andnativeSdkVersions.- Minimum iOS is 15.1; Android
minSdk24.
Added
logAdRevenue(),AdshiftMediationNetwork,AdshiftEventType.AdRevenue.getAdShiftDeviceId(),setBrandedDomains(),setMetaAppId(),addPushNotificationDeepLinkPath(),handlePushNotification().AdshiftConfig.brandedDomains,metaAppId,pushDeepLinkPaths,autoHandleDeepLinks,onError.AdshiftErrorCode,isAdshiftError,PLUGIN_VERSION,ConsentSnapshot,ConsentFlag.- Jest mock at
@adshift/react-native-plugin/jest.
Example App
yarn
cd example/ios && bundle install && bundle exec pod install && cd ../..
yarn example ios # or: yarn example androidReplace API_KEYS in example/src/App.tsx with keys from the AdShift dashboard. The app demonstrates lifecycle, queueing before start(), events, ad revenue, consent, deep links (automatic, manual and push payloads) and error reporting.
Troubleshooting
Links do not arrive on iOS while the app is running — add the RCTLinkingManager methods to AppDelegate.swift (Platform Setup → iOS).
A link is delivered twice on iOS — you forward URLs manually from Linking while autoHandleDeepLinks is on and the second call arrives more than two seconds after the first. Remove your handler or set autoHandleDeepLinks: false.
start() takes long on iOS — waitForATTBeforeStart: true without an ATT prompt waits for attTimeoutMs. Set the option only when your app actually prompts.
Manifest merger conflict on fullBackupContent / dataExtractionRules — see Android backup rules.
TurboModuleRegistry.getEnforcing(...): 'AdShiftReactNativePlugin' could not be found — the app runs the old architecture or was not rebuilt after installing the package. Rebuild with the New Architecture enabled (default since React Native 0.76; the only mode since 0.82).
Jest fails at import — register the mock from Testing.
Events are not sent — check start() resolved with success: true, enable isDebug and read the native logs; a rejected API key shows up through config.onError as INVALID_API_KEY.
For Maintainers
Release
- Update
CHANGELOG.md(including the native SDK matrix row) and the pins (android/build.gradle,AdShiftReactNativePlugin.podspec,NATIVE_SDK_VERSIONSinsrc/Adshift.ts). yarn version <new version>regeneratessrc/version.ts; commit.- Push a tag:
v2.0.0-rc.1publishes to thenextdist-tag,v2.0.0tolatest. The workflow fails if the tag does not matchpackage.json. - Publishing uses npm trusted publishing (OIDC) from
.github/workflows/publish.yml; no npm token is stored in the repository. The package's trusted publisher on npmjs.com must point at this repository and workflow file.
Verification before GA — an RC must run through the manual device test plan in a fresh RN 0.82+ app and at least one real application on @adshift/react-native-plugin@next.
Rollback — never npm unpublish. Deprecate the faulty version (npm deprecate @adshift/[email protected] "…") and publish a fixed patch. A defect in a native SDK is fixed by a plugin patch release with a bumped pin.
Support
- Documentation: https://dev.adshift.com/docs/react-native-sdk
- Issues: https://github.com/AdShift/react_native_plugin/issues
- Email: [email protected]
License
This SDK is proprietary software. See LICENSE for details.
© 2024-2026 AdShift. All rights reserved.
