Skip to content

Repository files navigation

@linkrunner/appbrew

npm

Linkrunner attribution for Appbrew apps — install attribution, in-app event forwarding, revenue, deep links and uninstall measurement.

What this is

An adapter, not an SDK. It translates between Appbrew's analytics system and the Linkrunner React Native SDK, which does the actual work.

Appbrew app fires "add_to_cart"
        │
        ▼
AnalyticsProvider → LinkrunnerTrackerV2      ← this package (pure TypeScript)
        │
        ▼
   rn-linkrunner                             ← the SDK (native Kotlin / Swift)
        │
        ▼
   api.linkrunner.io
Package Role
@linkrunner/appbrew This package. LinkrunnerTrackerV2 extends AnalyticsTrackerV2.
rn-linkrunner Linkrunner React Native SDK. Native module.

How it works

init() is the first thing that runs, on every app open — not once per install. It registers the install, resolves attribution, and must complete before anything else: every other SDK call no-ops until it has.

app open
  ├─ setConsent()                     Google Ads consent, if configured
  └─ init(token)                      registers the install, both platforms
     ├─ setCustomerUserId(deviceId)   guests get a stable id immediately
     ├─ getAttributionData()          deferred deep link, if any
     ├─ setPushToken()                uninstall tracking
     └─ setAdditionalData()           CleverTap id, if the module is present

You do not call init() yourself. The tracker runs it inside initTracker, which Appbrew invokes on launch via AnalyticsProvider.trackersInit().

One init() covers both platforms. Appbrew emits two separate install events, app_install_android and app_install_ios, but both are droppedinit() has already recorded the install natively, and forwarding them would double-count. They also fire off a JS-side has-booted flag exactly once per install, so they cannot be used to drive init() in the first place.

Requirements

  • An Appbrew app (@gauntlet/* packages)
  • rn-linkrunner >= 3.0.0
  • iOS 15.0+, Android minSdk 24
  • A Linkrunner project token

Getting the token

Copy the project token from your project's settings page in the Linkrunner dashboard:

https://dashboard.linkrunner.io/dashboard/settings/project-details?p_id=<PROJECT_ID>

Replace <PROJECT_ID> with your project's id — or open dashboard.linkrunner.io and navigate to Settings → Project Details.

Optional, only if you use SDK signing: secretKey and keyId, from the same dashboard.

You get the token from Linkrunner but enter it in the Appbrew dashboard — see step 5.

Where everything goes

What Where Who does it
Project token Appbrew dashboard → integration settings → linkrunner Appbrew team, per store
Tracker registration src/app/App.tsx — one line App developer, once
Backup rules android/app/src/main/AndroidManifest.xml App developer, once
Pods cd ios && pod install App developer, once
Deep link domain Associated Domains (iOS) + intent filter (Android) App developer, per store domain
Domain verification files Linkrunner dashboard → Project Settings → Domain Verification Merchant / Linkrunner
Event → Meta mapping Linkrunner dashboard → Meta Ads → Event Mapping Merchant / Linkrunner
Uninstall tracking keys Linkrunner dashboard → Settings → Uninstall Tracking Merchant / Linkrunner

The token never goes in code, .env, or a config file that ships. It is set per store in the Appbrew dashboard and delivered at runtime.


Getting started

1. Install

pnpm add @linkrunner/appbrew rn-linkrunner
cd ios && pod install

Adding this integration requires a new binary build (requiresNativeBuild: true).

2. Android setup

One change. In android/app/src/main/AndroidManifest.xml:

<application
  android:dataExtractionRules="@xml/linkrunner_backup_rules"      <!-- API 31+ -->
  android:fullBackupContent="@xml/linkrunner_backup_descriptor">  <!-- API 23-30 -->

This excludes the SDK's SharedPreferences from Android auto-backup. Without it the install ID is restored on reinstall, so a genuine reinstall reads as an existing install and reinstall attribution breaks silently. Both rule files ship inside rn-linkrunner as library resources — nothing to author.

If the app already has backup configuration, merge the exclusion into it instead:

<exclude domain="sharedpref" path="io.linkrunner.sdk_prefs"/>

Nothing else is needed. INTERNET, ACCESS_NETWORK_STATE and com.google.android.gms.permission.AD_ID are declared by the SDK and merged automatically by Gradle, and Play Install Referrer, GAID and App Set ID arrive as transitive dependencies.

Reference: Android backup configuration

3. iOS setup

pod install only.

NSUserTrackingUsageDescription is normally already present in an Appbrew app. If not:

<key>NSUserTrackingUsageDescription</key>
<string>This identifier will be used to deliver personalized ads and improve your app experience.</string>

You do not need to handle ATT — Appbrew presents the prompt itself in @gauntlet/brewery and awaits it before initialising trackers, so IDFA is already resolved by the time this package runs.

4. Register the tracker

In src/app/App.tsx:

import { AnalyticsProvider } from '@gauntlet/analytics'
import { LinkrunnerTrackerV2 } from '@linkrunner/appbrew'

AnalyticsProvider.getInstance().addTracker(new LinkrunnerTrackerV2())

No token in code — see below.

5. Configure in the Appbrew dashboard

This is where the token goes. The Appbrew team enters it per store; it arrives in the app config at config.integrations.linkrunner, and the tracker reads it on launch. The appbrew.settings manifest in this package's package.json generates that form automatically — no schema to write.

The resulting config the app receives:

{
  "integrations": {
    "linkrunner": {
      "token": "your-project-token",
      "debug": false,
      "trackScreenViews": false,
      "deeplinkRouting": true,
      "uninstallTracking": true
    }
  }
}

Only token is required; everything else has a default.

Get the token from https://dashboard.linkrunner.io/dashboard/settings/project-details?p_id=<PROJECT_ID> — see Getting the token. If secretKey / keyId are used for SDK signing, they go in the same form.

Key Type Required Secret Default Purpose
token text yes yes Linkrunner project token. Without it the tracker stays disabled.
secretKey text no yes Signing secret, paired with keyId. SDK signing
keyId text no no Key id, paired with secretKey.
debug boolean no no false Verbose SDK logging.
disableIdfa boolean no no false Opt out of IDFA even when ATT was granted.
enablePIIHashing boolean no no false Hash email/phone on-device before they leave.
trackScreenViews boolean no no false Forward screen_view / page_view. Off — highest-volume events by a wide margin.
deeplinkRouting boolean no no true Route resolved deferred deep links into the Appbrew router.
uninstallTracking boolean no no true Register the push token for uninstall measurement.
enableRefunds boolean no no false Forward refund to removePayment. See Refunds.
consentIsEEA text no no unset Google Ads consent: granted, denied or unknown. See Consent.
consentAdUserData text no no unset Google Ads consent for ad user data.
consentAdPersonalization text no no unset Google Ads consent for ad personalization.
enableTCFConsentCollection boolean no no false Let the SDK read TCF consent from the device CMP (Android).
clevertapIntegration boolean no no true Send the CleverTap ID to Linkrunner. No-ops without clevertap-react-native.
analyticsIdentifiers boolean no no true Attach Firebase Analytics ids to signup() / setUserData().
eventsMapper text (JSON) no no {} Rename events before sending.
paramsMapper text (JSON) no no {} Rename params before sending.
eventsWhitelist text (JSON array) no no all Restrict which events are forwarded.
paramsWhitelist text (JSON array) no no all Restrict which params are forwarded.

configKey: linkrunner · requiresNativeBuild: true · token is the only required setting.

type, required and secret come straight from the appbrew.settings manifest in this package's package.json — that is what generates the merchant-facing form, so the booleans render as toggles rather than free-text fields.

The four JSON-valued settings are declared as text; paste a JSON object or array. These are advanced tuning rather than merchant settings, and can be hidden from the form if preferred.

The three consent settings are also text because the form has no enum control. true / false are accepted as aliases for granted / denied; anything else is treated as not configured.

Local development

The demo store config has no integrations.linkrunner key, so pass the token directly — constructor options override remote config:

new LinkrunnerTrackerV2({ token: Config.getConstants().LINKRUNNER_TOKEN, debug: true })

Never do this in production. Note also that the sample app registers trackers inside if (!__DEV__) — move the registration outside that guard to test in a debug build.


Event mapping

Event names are forwarded verbatim. add_to_cart reaches Linkrunner as add_to_cart. Linkrunner has no enforced event enum, and mapping a name onto a Meta standard event happens in the dashboard regardless — so renaming in code would only put every new Appbrew event behind a package release plus a native rebuild.

Handled specially

Appbrew event Linkrunner call
purchase capturePayment()
refund removePayment() — off by default
signup identity resolution → signup() or setUserData()
login identity resolution → signup() or setUserData()
logout setCustomerUserId(<device id>) — no event sent
app_install_android / app_install_ios dropped — init() already records the install

Forwarded as custom events

view_item · view_item_list · view_cart · add_to_cart · remove_from_cart · add_to_wishlist · remove_from_wishlist · begin_checkout · add_shipping_info · apply_coupon · remove_coupon · search · notify_back_in_stock · push_notification_subscribed · push_notification_unsubscribed · screen_view* · page_view*

* off unless trackScreenViews is enabled.

Not mapped

add_payment_info · select_item · select_promotion · view_block · view_promotion

Declared in AnalyticsEvent but with zero call sites anywhere in @gauntlet/* — they never fire. Exported as NEVER_FIRED_EVENTS.

Payload transform

Events carrying items[] get Meta Catalog Sales fields merged in alongside the raw Appbrew keys.

Appbrew gives:

{ "value": 195, "currency": "USD",
  "items": [{ "item_id": "7486390763543", "item_variant": "41996942409751",
              "price": 195, "quantity": 1, "item_size": "L" }] }

Linkrunner receives:

{ "content_ids": ["41996942409751"],
  "item_group_ids": ["7486390763543"],
  "contents": [{ "id": "41996942409751", "quantity": 1, "item_price": 195 }],
  "content_type": "product", "value": 195, "currency": "USD", "num_items": 1,
  "items": [{ "...": "raw Appbrew keys preserved, incl. item_size" }] }

content_ids uses the variant id because that is what Shopify-fed Meta catalogues key on; item_group_ids carries the product id. item_price must match the catalogue price or Meta's match rate degrades silently.

Dashboard mapping is required

Sending an event is not enough to reach Meta. Map each name to its standard commerce event in Linkrunner dashboard → Meta Ads → Event Mapping:

Appbrew event Meta standard event
add_to_cart AddToCart
view_item ViewContent
payment type DEFAULT Purchase

Without it, events are captured by Linkrunner but never sync — silently.


Identity

app open        → init() → setCustomerUserId(<device instance id>)
user identified → signup()       (first time this user on this install)
                → setUserData()  (every time after)
logout          → setCustomerUserId(<device instance id>)

init() runs on every app open, not once per install.

The signup/setUserData decision keys on (install, customer id), not on which event arrived:

Scenario Fires
Signup, then login signup(), then setUserData()
Reinstall, login only signup() — storage is wiped with the install
Second user, shared device signup() — different id
Same user, later launches setUserData()

Hard-mapping login → setUserData() would break the middle two cases, which is why the flag stores the customer id rather than a boolean.

Guest checkout works. setCustomerUserId runs at init, so every device carries a stable id before any login, and capturePayment creates the identity itself.

User identifiers

signup() and setUserData() carry id, name, email, phone and user_created_at from Appbrew's customer record, plus:

Field Source
is_first_time_user true after Appbrew's signup event. Omitted otherwise, never sent as false.
ga_app_instance_id, ga_session_id @react-native-firebase/analytics, when installed. Lets you join Linkrunner attribution to your GA4 / BigQuery export. Set analyticsIdentifiers: false to skip.

To push fields Appbrew does not hold (a Mixpanel distinct id, say), call setUserData yourself. id defaults to the current customer, or the device id for guests:

import { setUserData } from '@linkrunner/appbrew'

await setUserData({ mixpanel_distinct_id: distinctId })

Reference: Setting user data

CleverTap

When clevertap-react-native is installed, the CleverTap ID is read after init and sent with setAdditionalData({ clevertapId }), which is what the CleverTap integration needs. Set clevertapIntegration: false to skip, or pass ids yourself:

import { setAdditionalData } from '@linkrunner/appbrew'

await setAdditionalData({ clevertapId })

Attribution data

Read the campaign that drove this install and the deferred deep link, for referral codes, campaign-specific onboarding, or anything else the feature docs describe with getAttributionData():

import { getAttributionData } from '@linkrunner/appbrew'

const attribution = await getAttributionData()
// { deeplink?: string, campaignData?: { id, name, type, adNetwork, ... } }
  • Resolves once the tracker has initialised, so it is safe to call from a screen that mounts before Appbrew runs trackersInit(). Gives up with undefined after 30 seconds if the tracker was never registered.
  • undefined when no token is configured or the SDK has no attribution for this install.
  • One native call per launch. The deferred deep link router shares the same result.

The same method exists on the tracker instance (tracker.getAttributionData()). Types AttributionData and CampaignData are re-exported.

Reference: Getting attribution data


Consent

Google Ads attribution (ICM / ODM) needs the user's consent state, and the SDK wants it before init(). Two ways to provide it:

Static, from the dashboard. Set consentIsEEA, consentAdUserData and consentAdPersonalization in the integration settings. The tracker forwards them with setConsent() before init() on every launch. Only configured flags are sent; an unset flag is left out rather than sent as unknown.

Dynamic, from a CMP. Call setConsent whenever the user's choice changes. It is SDK-level state, so it works before the tracker exists:

import { setConsent } from '@linkrunner/appbrew'

setConsent({
  isEEA: 'granted',
  hasConsentForDataUsage: 'granted',
  hasConsentForAdsPersonalization: 'denied',
})

Consent is stored between launches. Call setConsent again when the user withdraws it, or the previous value keeps being sent.

enableTCFConsentCollection lets the SDK read IAB TCF consent from the device CMP on Android instead.

Reference: Google Integrated Conversion Measurement


Revenue

purchase goes to capturePayment, not trackEvent:

capturePayment({
  paymentId: "#1001",          // transaction_id — the dedup key
  userId:    "9538196275417",  // Shopify customer id, or device id for guests
  amount:    195,
  type:      "DEFAULT",        // constant — the other half of the dedup key
  status:    "PAYMENT_COMPLETED",
  currency:  "USD",
  eventData: { /* Meta fields + order_id */ },
})

transaction_id is passed verbatim as paymentId. Linkrunner dedupes idempotently on (type, payment_id) — the only real protection, since Appbrew dedupes purchases in an in-memory Set that does not survive a process restart. Killing the app on the thank-you screen re-emits the event.

Do not also call capturePayment from a Shopify webhook for the same order unless both sides send an identical payment_id, or one payment produces two records.

Reference: Revenue tracking

Refunds

Off by default. Enable with enableRefunds only after verifying the id mapping against a real store.

removePayment({ userId }) with no paymentId deletes every payment for that user. The two ids also come from different namespaces — purchases carry cart.order.name, refunds carry orderData.id — so the call usually no-ops rather than matching.


Deep linking

Both direct and deferred deep linking are supported.

Two distinct flows, handled deliberately differently.

Trigger SDK call Routed into the app?
Direct User taps a link with the app installed handleDeeplink(url) No — Appbrew already routes it
Deferred First open after installing from a link getAttributionData() Yes, into an empty slot only

Why asymmetric: a user-initiated link is authoritative and Appbrew's own router is already handling it, so writing to the router again would double-navigate. A deferred link is speculative — it may only fill a slot nobody else claimed. Losing a deferred destination is far cheaper than hijacking an intentional tap.

Deferred routing applies once per install: getAttributionData() returns the same URL on every cold start, so replaying it would hijack every launch.

Set deeplinkRouting: false to report links for attribution without touching the router.

Links that arrive through another channel (a push payload, an in-app banner) can be reported by hand. The result says whether Linkrunner recognised the link:

import { handleDeeplink } from '@linkrunner/appbrew'

const result = await handleDeeplink(url)
// { deeplink?: string, isLinkrunner: boolean, processing?: boolean }

Native configuration

Store-specific, and not supplied by this package. Follow the deep linking setup guide — summary below.

Host the verification files

In the dashboard under Project Settings → Domain Verification, paste both JSON objects. Linkrunner then serves them at:

  • https://<your-domain>/.well-known/apple-app-site-association
  • https://<your-domain>/.well-known/assetlinks.json

Androidassetlinks.json:

[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.yourstore.app",
    "sha256_cert_fingerprints": ["AA:BB:CC:..."]
  }
}]

Get the fingerprint — debug and release keystores differ, so list both, or use the Play Console fingerprint (Setup → App integrity) if you use Play App Signing:

keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android

iOSapple-app-site-association:

{ "applinks": { "apps": [], "details": [
  { "appID": "TEAMID.com.yourstore.app", "paths": ["/*"] }
]}}

App configuration

Android — in AndroidManifest.xml, inside <activity>:

<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="link.yourstore.com" />
</intent-filter>

iOS — Xcode → Signing & Capabilities → Associated Domains:

applinks:link.yourstore.com

A custom URI scheme (yourstore://) works as a fallback and needs no domain verification.

Testing

# Android
adb shell am start -a android.intent.action.VIEW \
  -d "https://link.yourstore.com/products/abc" com.yourstore.app

# iOS simulator
xcrun simctl openurl booted "https://link.yourstore.com/products/abc"

Typing a Universal Link into Safari never opens the app — tap it from another app, e.g. Notes.

With debug: true the SDK logs the resolved destination:

handleDeeplink success: Deeplink processed
handleDeeplink response > { deeplink: 'https://...', processing: true }

When links open the browser instead of the app

Almost always domain verification, not app code.

Android — check the verification state (verified is good; 1024 / legacy_failure means a fingerprint or hosted-file problem):

adb shell pm get-app-links com.yourstore.app

# force a re-check
adb shell pm set-app-links --package com.yourstore.app 0 all
adb shell pm verify-app-links --re-verify com.yourstore.app

iOS — devices fetch the AASA from Apple's CDN, not your domain, so a fresh file can still be stale:

curl -v https://app-site-association.cdn-apple.com/a/v1/link.yourstore.com

To bypass the CDN while testing, set the entitlement to applinks:link.yourstore.com?mode=developer, enable Settings → Developer → Associated Domains Development, then delete and reinstall the app. App Store builds ignore developer mode — verify with the normal entitlement before release.

Full checklist: debugging domain verification

Reference


Uninstall tracking

The device push token is registered on init — APNs on iOS, FCM on Android — and re-sent whenever Android rotates it. @react-native-firebase/messaging is an optional peer; without it this quietly does nothing.

Configure Settings → Uninstall Tracking in the dashboard (Firebase Project ID for Android; APNs p8 key, Key ID, Bundle ID and Team ID for iOS), or the token is accepted but no uninstall is ever reported.

Linkrunner detects uninstalls with a silent push — ignore those in your FCM handler:

messaging().onMessage(async (msg) => {
  if (msg.data?.['lr-uninstall-tracking']) return
  // ...
})

Set uninstallTracking: false to disable.


Verifying an integration

  1. Build with debug: true. The SDK logs on init:
    Linkrunner initialised successfully
    
  2. Check dashboard → Events Settings — the install and events should appear.
  3. Exercise the funnel: view a product → add to cart → checkout.
  4. Confirm a purchase produces one payment, and that relaunching does not duplicate it.
  5. Deep links:
    adb shell am start -a android.intent.action.VIEW -d "<url>" <package>
    xcrun simctl openurl booted "<url>"

Reference: Integration testing

Troubleshooting

Symptom Cause
no token in config.integrations.linkrunner — tracker disabled Token not set in the dashboard for this store
No events at all Registration is inside if (!__DEV__), or the whitelist excludes them
Events in Linkrunner but not Meta Event not mapped under Meta Ads → Event Mapping
Reinstalls read as existing installs Backup rules missing from AndroidManifest.xml
Deep link opens the app but does not navigate Domain verification incomplete
Duplicate payments capturePayment called from both app and webhook with different payment_id
getAttributionData() resolves undefined No token, or the device installed directly from the store. Check with debug: true
tracker not initialised warning A public call waited 30s and Appbrew never ran trackersInit(). Registration is probably inside if (!__DEV__)

API

import {
  LinkrunnerTrackerV2,     // the tracker — register with AnalyticsProvider
  LinkrunnerTracker,       // alias

  // Module-level calls. Delegate to the most recently constructed tracker.
  getAttributionData,      // deferred deep link + campaign for this install
  setConsent,              // Google Ads consent; works before init
  setUserData,             // extra user fields; id defaults to the current user
  setAdditionalData,       // integration ids, e.g. { clevertapId }
  handleDeeplink,          // report a url; returns { isLinkrunner, ... }
  getActiveTracker,

  toEcommercePayload,      // Appbrew items[] → Meta Catalog Sales fields
  buildEventData,
  buildPurchaseEventData,
  NEVER_FIRED_EVENTS,      // the 5 declared-but-dead Appbrew events
  HANDLED_SEPARATELY,      // events not forwarded via trackEvent
  trackerStorage,
} from '@linkrunner/appbrew'

import type {
  LinkrunnerIntegrationConfig,
  LinkrunnerTrackerOptions,
  EcommercePayload,
  // Re-exported from rn-linkrunner
  AttributionData, CampaignData, DeeplinkData,
  LinkrunnerConsent, ConsentStatus, IntegrationData, UserData,
} from '@linkrunner/appbrew'

The same five calls exist as methods on the tracker instance.

Links

Support: support@linkrunner.io

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages