# ChatGPT Ads
Source: https://docs.linkrunner.io/ad-networks/chatgpt-ads
Run ChatGPT Ads (OpenAI) with Linkrunner attribution, oppref click IDs, and OpenAI Conversions API postbacks
## Overview
Use ChatGPT Ads with Linkrunner when you want to send ChatGPT ad traffic through one Linkrunner campaign link, attribute installs and in-app events, and send server-side conversions back to OpenAI through the OpenAI Conversions API.
The recommended flow is:
1. Create or prepare your ChatGPT Ads account in [ChatGPT Ads Manager](https://ads.openai.com).
2. Create a Pixel and a Conversions API key in OpenAI Ads Manager under **Tools → Conversions**.
3. Add the Pixel ID and Conversions API key in Linkrunner and validate them.
4. Create a Linkrunner campaign link for each ChatGPT ad you want to measure.
5. Use that link as the ad's destination URL, and set the landing page query parameters.
6. Test the full click, install, first open, and event flow before scaling spend.
Linkrunner captures OpenAI's `oppref` click ID when a user clicks your ChatGPT ad. ChatGPT appends this value to the destination URL, so keep the Linkrunner campaign link intact and do not remove query parameters.
ChatGPT app attribution is **click-through only**. There is no view-through attribution for app events, so an install with no `oppref` cannot be matched back to a ChatGPT ad.
## What you need
* A Linkrunner project with the Linkrunner SDK installed in your app
* Android and iOS store URLs configured in Linkrunner
* Deep linking configured if the ad should open a specific in-app screen
* Access to a ChatGPT Ads account
* A **Pixel ID**
* A **Conversions API key**
Use the **Conversions API key**, not the Ads management API key. They are different credentials and only the Conversions API key authorizes event ingestion.
You do not need a partnership with OpenAI to send Conversions API events. Any advertiser can create a Pixel ID and Conversions API key in Ads Manager and paste them into Linkrunner.
## How ChatGPT Ads Manager is organized
Everything in [ChatGPT Ads Manager](https://ads.openai.com) hangs off four levels:
| Level | What it holds |
| -------------- | --------------------------------------------------------------------------------------------- |
| **Ad account** | Billing, users, API keys, and your Pixels |
| **Campaign** | Objective, budget, schedule, and the conversion event a conversions campaign optimizes toward |
| **Ad group** | Targeting and bidding |
| **Ad** | Title, description, images, and the **destination URL** |
Two places in the left nav matter for this integration:
* **Tools → Conversions** holds your **Pixel ID** and your **Conversions API key**. Both go into Linkrunner.
* **Settings** holds the **Ads API key**, which manages campaigns. Linkrunner does not use it.
These are two different credentials. Linkrunner needs the **Conversions API key** from **Tools → Conversions**. The Ads API key from **Settings** will not authorize conversion events.
The destination URL lives on the **ad** (and can be defaulted from the ad group), and landing page query parameters can be set at campaign, ad group, or ad level. That is the whole surface Linkrunner touches.
## 1. Create a Pixel and Conversions API key
Go to [ChatGPT Ads Manager](https://ads.openai.com) and sign in to the advertiser account that will run the campaign. If this is your first ChatGPT campaign, complete the business, billing, and advertiser setup first.
Open **Tools → Conversions**. It has three tabs: **Data Source**, **Conversion Events**, and **Event Stream**.
On **Data Source**, the string under each data source name is its **Pixel ID**. Copy it.
Then click the key icon in the top right to open **Conversion keys**, and click **Create new key**. Copy the key when it is shown, because it is only displayed once.
Keep both values ready:
* **Pixel ID** - identifies the data source that should receive conversions.
* **Conversions API key** - authorizes Linkrunner to send events.
App events (`app_installed`, `app_opened`) are Conversions API only. OpenAI does not currently support native mobile SDK setup or mobile data sources, so use a Pixel ID from an existing **web** data source.
On the **Conversion Events** tab, confirm you have a conversion event whose base event is the one Linkrunner will send, usually `app_installed`. This is the event a conversions campaign optimizes toward, so it has to exist here before you create the campaign.
## 2. Add ChatGPT Ads credentials in Linkrunner
Open your Linkrunner project and go to **Dashboard > Integrations > ChatGPT Ads**.
1. Open **Pixel Management**.
2. Click **Add ChatGPT Ads Pixel**.
3. Paste the Pixel ID and Conversions API key.
4. Click **Validate** to check them, then **Save Configuration**.
Validation sends a synthetic `app_opened` event with OpenAI's `validate_only` flag, so it confirms the credentials without recording a conversion in your Ads Manager reporting.
Use the Pixel ID and Conversions API key from the same ChatGPT advertiser account. A mismatched Pixel or key prevents OpenAI from accepting or matching events.
## 3. Map events for ChatGPT Ads
Open the **Event Mapping** tab inside the ChatGPT Ads integration page. This tab becomes available after the Pixel ID and Conversions API key are saved.
Linkrunner creates the default mappings for you when you connect. Click **Add Mapping** for any additional app event you want ChatGPT to optimize or report on.
These defaults are created automatically on connect:
| Linkrunner event | ChatGPT Ads event |
| ---------------------- | ------------------------ |
| Install or first open | `app_installed` |
| App open | `app_opened` |
| Registration or signup | `registration_completed` |
| Purchase | `order_created` |
Other standard events you can map:
| Linkrunner event | ChatGPT Ads event |
| --------------------------- | ----------------------- |
| Lead form or qualified lead | `lead_created` |
| Appointment booked | `appointment_scheduled` |
| Checkout started | `checkout_started` |
| Add to cart | `items_added` |
| Product or content view | `contents_viewed` |
| Screen or page view | `page_viewed` |
| Subscription started | `subscription_created` |
| Free trial started | `trial_started` |
| Anything else | `custom` |
### Custom events
Pick **Custom Event (custom)** when your app event has no standard equivalent, then type the event name ChatGPT should record it as.
OpenAI matches a custom conversion on its name, so type the **same name you already use in ChatGPT Ads Manager**. If your web pixel fires `add_to_wishlist` and you type something else here, OpenAI treats them as two different events: they will not deduplicate, and they will not roll up against the conversion event on the **Conversion Events** tab. Names are lowercase and up to 64 characters.
Conversion-optimized (oCPC) campaigns optimize toward exactly one **standard** event. A custom event cannot be used as an optimization goal, which is why Linkrunner maps installs to `app_installed` rather than to a custom event.
Start with the default install and signup behavior plus a purchase mapping. Add the rest after your first campaign is sending clean traffic and Ads Manager shows successful event delivery.
For purchase or revenue events, enable **Send Revenue** and choose the currency. Linkrunner converts the amount into the integer minor unit OpenAI expects (for example, USD 42.00 is sent as `4200`), including zero-decimal currencies such as JPY.
## 4. Create a Linkrunner campaign link
Before you create the ChatGPT ad, create the Linkrunner campaign link that ChatGPT will send users to.
1. Go to **All Campaigns** in Linkrunner.
2. Click **Create Link**.
3. Enter a campaign name. The **Display ID** fills in automatically and becomes the unique code at the end of your campaign link.
4. Turn on **Enable Deferred deeplinking** if the ad should open a specific in-app screen after install.
5. Open **Advanced Options**.
6. Under **Ad Channel**, select **ChatGPT Ads**.
7. Click **Create Campaign**, then copy the campaign link.
Selecting **ChatGPT Ads** is what tells Linkrunner to send this campaign's conversions to the OpenAI Conversions API. A campaign left on **None** still records clicks, but sends no postbacks.
Create a new Linkrunner campaign for every ChatGPT destination link you want measured independently: different creatives, landing destinations, audiences, or experiments.
Do not paste the App Store or Play Store URL directly into ChatGPT Ads if you want Linkrunner attribution, deferred deep linking, or OpenAI CAPI postbacks. ChatGPT must send the user to the Linkrunner campaign link first.
## 5. Create the ChatGPT campaign
After the Linkrunner campaign link is created, go to [ChatGPT Ads Manager](https://ads.openai.com) and create the campaign that will use it.
The **New campaign** wizard has three steps: **Create Campaign**, **Create Ad Group & Ads**, and **Review**.
### Step 1: Create Campaign
Name the campaign, then set:
* **Objective** to **Conversions**, so OpenAI optimizes delivery toward your app event.
* **Conversion event** to the event you want to buy, usually **App Installed**. This list comes from the **Conversion Events** tab you checked earlier.
* **Locations**, **Eligible platforms**, **Budget**, and the schedule.
A conversions campaign optimizes toward exactly **one standard** event, and the objective cannot be changed after the campaign is created. Pick the event you actually want to buy, usually `app_installed`. A `custom` event cannot be an optimization goal.
Start with a deliberately low daily budget so you can verify the full click, install, and event flow before any meaningful spend.
### Step 2: Create Ad Group & Ads
Name the ad group and set the bid strategy, then fill the two fields Linkrunner depends on.
**Default Ad Destination URL** takes your **Linkrunner campaign link**, and every new ad in the ad group inherits it:
```
https://app.product.com/AbCdEf
```
Nothing else goes in that field. ChatGPT appends `oppref` to it on every click.
Do not replace the Linkrunner campaign link with a store URL or your website URL. ChatGPT must send the click to Linkrunner first so Linkrunner can capture `oppref`, redirect the user, attribute the install, and send the CAPI postbacks.
**Landing page query parameters** takes the tracking macros:
```
campaign_id={campaign_id}&ad_group_id={ad_group_id}&ad_id={ad_id}
```
This field exists at campaign, ad group, and ad level, and the most specific level wins. Setting it once on the campaign covers everything beneath it. The supported macros are `{campaign_id}`, `{ad_group_id}`, `{ad_id}`, `{ad_account_id}`, and `{oppref}`.
Do not add `oppref` as a parameter name. ChatGPT reserves it and rejects the field with "Reserved query parameters are not supported: olref, oppref." It appends `oppref` to the landing URL itself on every click, so all you have to do is make sure nothing in your setup strips query parameters.
ChatGPT substitutes each macro at click time, so a real click arrives at your Linkrunner link as:
```
https://app.product.com/AbCdEf?campaign_id=cmp_123&ad_group_id=grp_456&ad_id=ad_789&oppref=gAAAA...
```
Linkrunner records these macro values against every click, so a single Linkrunner campaign link gives you a per campaign, ad group, and ad breakdown. You only need separate links when you want the campaigns themselves kept apart in Linkrunner.
If an ad points at a link with no Linkrunner campaign on it, Linkrunner creates one from the `{campaign_id}` macro (named `ChatGPT Ads - `) so the installs still land on a campaign instead of showing as organic.
### Step 3: Create the ad
Add the headline, description, and images. **Destination URL** is pre-filled from the ad group's default, so leave it as the Linkrunner campaign link.
### Step 4: Review and publish
1. Confirm the ad account owns the same Pixel configured in Linkrunner.
2. Confirm the destination URL is the Linkrunner campaign link.
3. Confirm the landing page query parameters are set.
4. Confirm the budget is intentionally low for the first test.
5. Unpause the campaign when everything is correct.
## 6. Open a specific screen with deep linking
By default a ChatGPT ad sends a new user to your app's home screen. Deep linking sends them to a specific screen instead, such as the exact product in the ad, and it keeps working when the user did not have your app yet: they tap the ad, install from the store, and land on that screen the first time they open the app. That second case is called **deferred** deep linking, and it is the one that matters for ads.
You do not need a different link for this. The same Linkrunner campaign link carries it.
Your app must handle deep links before this works. Follow [Deep Linking Setup](/features/deep-linking-setup) first, then come back here.
In the **Create Campaign** form, switch on **Enable Deferred deeplinking?**, then enter the screen you want users to land on.
Enter the destination your app already knows how to open, for example:
```text theme={null}
https://app.product.com/product
```
Keep values that change per ad out of this field. A value set here always wins over the same value on the clicked link, so putting `variant_id` here would force every ad to the same product. Step 3 is where per-ad values go.
Nothing changes in ChatGPT Ads Manager. Paste the **Linkrunner campaign link** into **Default Ad Destination URL** on the ad group, exactly as you would for a campaign without deep linking.
Do not paste the deep link destination (`https://app.product.com/product`) here. That value stays in Linkrunner, on the campaign. ChatGPT only ever sees the campaign link, and Linkrunner resolves the destination after the install.
Put your own values in **Landing page query parameters**, next to the tracking macros, and set them at the level you want them to vary. Setting them on an individual ad lets each creative open a different screen:
```text theme={null}
campaign_id={campaign_id}&ad_group_id={ad_group_id}&ad_id={ad_id}&variant_id=987
```
Linkrunner merges these onto the destination from Step 1, so this ad resolves to:
```text theme={null}
https://app.product.com/product?variant_id=987
```
Use this field rather than editing the destination URL. The macros already live here, the values stay in one place, and the campaign, ad group, and ad levels give you exactly the granularity you need.
After the user installs and opens your app, call `getAttributionData()`. The `deeplink` field holds the resolved URL:
```json theme={null}
{
"deeplink": "https://app.product.com/product?variant_id=987&campaign_id=cmp_123&ad_group_id=grp_456&ad_id=ad_789&oppref=gAAAA..."
}
```
Read the values your app defined, in this example `variant_id`, and route the user to that screen.
The tracking values ride along. `oppref`, `campaign_id`, `ad_group_id`, and `ad_id` are merged into the deep link with everything else, so your app must read the parameters it knows and ignore the rest. Do not fail or show an error screen on an unrecognized parameter.
### Test it before you spend
Deferred deep linking only takes the deferred path on a genuinely fresh install, so test in this order:
1. Delete your app from the test device.
2. Click your Linkrunner campaign link on that device.
3. Install the app from the store.
4. Open the app and log what `getAttributionData()` returns.
5. Confirm `deeplink` holds the destination you configured, with your parameters on it.
Reinstalling over an existing install, or opening the link on a device that already has the app, tests direct deep linking rather than the deferred path. Delete the app first or the result will not tell you what you think it does.
## How attribution and CAPI work
When a ChatGPT user clicks your ad, OpenAI appends `oppref` to the destination URL. It is an opaque token, and Linkrunner captures it on the campaign link click and carries it through install attribution unchanged.
For attributed installs and mapped in-app events, Linkrunner sends an OpenAI Conversions API event to `https://bzr.openai.com/v1/events?pid=` containing:
* The OpenAI click ID (`oppref`)
* The mapped event name and its data type
* Event time in milliseconds
* A deterministic deduplication ID, so a retry never double-counts
* Hashed user identifiers when available (email, phone, external ID, name)
* Google Advertising ID, IP address, user agent, and coarse location when available
* Amount in integer minor units plus currency, for revenue events
Every request is tagged with `integration_source: linkrunner`.
Conversions typically appear in the Ads Manager **Conversions** metric 24 to 48 hours after the event is sent.
## Frequently Asked Questions
ChatGPT campaigns do not appear automatically on the Linkrunner dashboard. They show up after users click the Linkrunner campaign links you set as destination URLs in your ChatGPT ads.
Confirm each ad uses a Linkrunner campaign link, the ad is active and receiving traffic, and your app is live with the Linkrunner SDK installed.
`oppref` is OpenAI's click ID. ChatGPT appends it to the destination URL when a user clicks an ad, and Linkrunner captures it from the campaign link click.
It is the only key OpenAI matches app conversions on. Keep the Linkrunner campaign link intact and do not strip URL parameters, or Linkrunner cannot match the install or event back to the ad click.
OpenAI's Conversions API accepts `android_advertising_id` (GAID) but does not support IDFA. On iOS, matching relies on `oppref` plus any hashed user identifiers your app provides, so iOS attribution depends on the click ID being preserved.
OpenAI rejects any event whose timestamp is more than 7 days old, and a single rejected event fails the entire batch it was sent in. Linkrunner drops events older than 7 days before sending and logs them as skipped, so one stale event can never block your other conversions.
Not for app events. ChatGPT app attribution is click-through only. A 1-day view-through window exists for web events, but it is reporting-only and does not apply to installs or in-app events.
OpenAI opened self-serve Ads Manager to India, 31 European markets, and 11 MENA markets on 31 August 2026, in addition to the earlier US, CA, AU, NZ, UK, JP, and KR markets. Check Ads Manager for the current list for your account.
Yes. Map your purchase event to `order_created` and enable **Send Revenue** with the correct currency. Linkrunner converts the amount to the integer minor unit OpenAI requires.
Use the **Validate** action on the Pixel Management tab to confirm credentials, then use Linkrunner's test-event action for a mapped event. Verify installs and events through the Linkrunner SDK testing flow first, then confirm delivery in the Ads Manager Conversions tab after 24 to 48 hours.
## Related setup
Configure Android App Links and iOS Universal Links.
Read deferred deep link data after first app open.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Google Ads
Source: https://docs.linkrunner.io/ad-networks/google-ads
Connect your app with Google Ads for enhanced campaign tracking and optimization
## Overview
Google Ads integration with Linkrunner enables you to track conversions and optimize your app campaigns directly within the Google Ads platform. This integration allows you to import events from your app into Google Ads as conversion actions, providing better insights into your campaign performance and user behavior.
## Setup Process
Setting up Google Ads integration involves four main steps: connecting your Google Ads account, creating a link ID for your mobile app, mapping events in Linkrunner, and importing events for conversion tracking.
Running iOS App Campaigns? Google measures iOS installs via SKAdNetwork. After completing this guide, follow the
[Google Ads SKAN Setup](/features/google-skan-setup) guide.
## 1. Connect Your Google Ads Account
Start in the Linkrunner dashboard.
1. Go to [Integrations](https://dashboard.linkrunner.io/dashboard/integrations) and click **Configure** on the **Google Ads** card
2. On the **Account Integration** tab, click **Add Google Account**
3. Sign in with a Google account that has access to your Google Ads account and approve the requested permissions
Once connected, your account appears in the **Google Ad Accounts** table with **Auth Status** showing **Connected**.
## 2. Create Link ID for Android/iOS App
To connect your mobile app with Google Ads, you'll need to create a link ID that enables data sharing between Linkrunner and Google Ads.
### Step 1: Access Third-party App Analytics
1. Go to your Google Ads dashboard
2. Navigate to **Tools** (side panel) → **Data Manager** → **Third-party app Analytics**
3. Or visit directly: [https://ads.google.com/aw/productlinks/thirdpartyappanalytics](https://ads.google.com/aw/productlinks/thirdpartyappanalytics) and click on **Create Link ID**
### Step 2: Add App Analytics Provider
1. In the Third-party app analytics section, click on the **"+ Create link ID"**
2. Select **"Other provider"** from the App analytics provider dropdown menu
3. Enter the following provider ID: `9936233049`
4. Select the platform (iOS or Android) and search for your app
5. Click on **"Create link ID"**
Google Ads then shows the generated link ID. Copy it.
Link IDs are per platform. Repeat Step 2 for your other platform if you ship both Android and iOS.
### Step 3: Configure in Linkrunner Dashboard
Now paste that link ID into Linkrunner.
1. Go to [Integrations → Google Ads](https://dashboard.linkrunner.io/dashboard/integrations/google-ads) in your Linkrunner dashboard
2. On the **Account Integration** tab, open the three-dot menu on your Google Ads account row
3. Choose **Setup Link IDs**
4. Paste the link ID into **Android Link ID** or **iOS Link ID**, matching the platform you selected in Google Ads
5. Click **Save Link IDs**
You only need one link ID to save, but add both if you ship on Android and iOS.
## 3. Set Up Event Mapping
Map your app events to Google Ads events so Linkrunner can send postback conversions.
**Install** and **Sign up** are included by default as `first_open` and `sign_up`. You do not need to add mappings
for these events.
1. Go to [Google Ads Event Mapping](https://dashboard.linkrunner.io/dashboard/integrations/google-ads?tab=event_mapping)
2. Click **Add Mapping**
3. Select your event under **Your Events**
4. Select the matching **Google Ads Event**
5. Turn on **Send Revenue** only for revenue events, then choose the **Currency**
6. Click **Save**
7. Click the paper plane **Send event to ad network** icon to fire a test event
After Linkrunner sends an event, it can take up to **6 hours** to appear in Google Ads.
If an event is not available as a conversion action in Google Ads, fire a test event from Linkrunner, then import it
from **Goals** → **Conversions** → **Summary** after Google finishes processing it.
## 4. Importing Events for Conversion Actions Setup
After creating the link ID and mapping events in Linkrunner, you can import app events into Google Ads to set up conversion tracking.
### Step 1: Access Conversions Dashboard
1. On Google Ads Dashboard, navigate to **Goals** \[sidebar] → **Conversions** → **Summary**
2. Or visit directly: [https://ads.google.com/aw/conversions](https://ads.google.com/aw/conversions)
### Step 2: Create Conversion Action
1. Click on **"+ Create conversion action"**
2. On the "Start tracking conversions" popup:
* Select **"App"**
* Then select **"Third-party app analytics"**
* Press **Continue**
### Step 3: Import Events
1. Select all the events you want to import from the available list
2. To get started, we recommend selecting the `first_open` event
3. Configure additional settings as needed for each conversion action
4. Save your conversion actions
## 5. Create Your First Campaign
With steps 1 to 4 complete, launch an App campaign in Google Ads. App campaigns need no tracking links. Attribution flows through the link ID, and campaigns appear in Linkrunner automatically as installs come in.
### Step 1: Start a New Campaign
1. Open [Google Ads](https://ads.google.com) and go to **Campaigns**
2. Click the **+** button and choose **New campaign**
3. Under **What's your campaign objective?**, select **App promotion** and click **Continue**
### Step 2: Choose Subtype and Select Your App
1. Under **Select a campaign subtype**, choose the subtype that matches your goal. **App installs** is the right choice for most first campaigns.
2. Select your app's platform (**Android** or **iOS**)
3. Under **Look up your app**, search by app name, package name, or store URL and select your app
4. Enter a campaign name and click **Continue**
Pick a different subtype when your goal is not new installs:
* **App engagement**: re-engage users who already have your app with in-app actions. Google requires a minimum of
50K installs.
* **App pre-registration** (Android only): drive Play Store pre-registrations before your app launches.
### Step 3: Set Locations and Languages
1. Under **Locations**, select the countries to run in
2. Under **Languages**, select the languages your customers speak
3. Click **Next**
### Step 4: Add Ad Assets
App campaigns generate ads automatically from the assets you provide. Google mixes and matches them across Search, Play, YouTube, Display, and Discover.
1. Add up to 5 **Headlines** and 5 **Descriptions**
2. Upload images and videos if you have them
3. Click **Next**
### Step 5: Set Bidding and Budget
1. Under **What do you want to focus on?**, pick what Google should optimize for. You can choose either option; for your first campaign, choose **Install volume**.
2. Under **What kind of users do you want to target?**, pick either option. For this guide, we select **All users**.
3. Set your average daily **Budget**
4. Click **Next**, review the campaign, and click **Publish campaign**
Bid on **In-app actions** only after your imported conversion actions have enough volume. Under **How do you want to
track install volume?**, Google lists the download conversion actions imported from Linkrunner.
### Step 6: Verify the Campaign in Linkrunner
Campaigns and spend sync automatically from your connected Google Ads account. Attribution rows appear after the campaign receives its first install.
1. Open the [**Campaigns** section](https://dashboard.linkrunner.io/dashboard) in your Linkrunner dashboard
2. Click **All Channels** and select **Google** to filter your campaigns
3. Open the campaign to view installs, events, and revenue attributed to it
Campaigns appear after the first attributed install, not immediately at launch. New campaigns can also take a few
hours to exit Google's review and start serving.
To open a specific screen after install, click **Edit** on the campaign row and set a **Deferred Deep Link**. Your app receives the URL from `getAttributionData()`. See [Deferred Deep Linking](/features/deferred-deep-linking).
## Important Notes
After generating the link ID, it might take up to **12 hours** for the events to show up in Google Ads. This is a
normal processing delay from Google's side.
Start with basic events like `first_open` to verify the integration is working before adding more complex conversion
events.
Running App Campaigns for an iOS app? Set up [Google Integrated Conversion
Measurement](/features/google-icm) so Google can attribute installs from users who declined App Tracking
Transparency.
## FAQ and Discrepancies
For answers to common questions and an explanation of why numbers in Google Ads may not exactly match Linkrunner, see [Google Ads FAQ and Discrepancies](/ad-networks/google-ads-discrepancies).
## Troubleshooting
### Campaign Not Showing in Linkrunner
* Wait for the first install. Campaigns appear only after at least one install is attributed.
* Confirm the link ID is saved for the right platform under [Integrations → Google Ads](https://dashboard.linkrunner.io/dashboard/integrations/google-ads?p_id=89)
* Confirm the campaign is active and serving impressions in Google Ads
* Confirm your released app build includes the Linkrunner SDK at 100% rollout
### Events Not Showing Up
* Ensure the link ID was copied correctly
* Verify the event is mapped in [Google Ads Event Mapping](https://dashboard.linkrunner.io/dashboard/integrations/google-ads?tab=event_mapping)
* Wait the full 12 hours for Google's processing
* Verify that your app is sending events to Linkrunner
### Re-enabling Previously Deleted Events
If you previously added an event as a conversion action and then deleted it, the event won't appear in the "Create conversion action" flow when you try to add it again. Instead of creating a new conversion action, you need to re-enable the existing (deleted) one.
#### Step 1: Navigate to All Conversion Actions
1. Go to **Goals** → **Conversions** → **Summary** in the sidebar
2. Click on **"View all conversion actions"** link
#### Step 2: Set Filter to Show All Conversions
1. Click on the **Status** filter
2. Select **"All"** from the dropdown to show both enabled and removed conversion actions
#### Step 3: Re-enable the Deleted Event
1. Find your previously deleted event in the list
2. Click on the status indicator (red "Removed" icon) next to the event
3. Select **"Enabled"** from the dropdown menu
Your event will now be active again and will start tracking conversions.
### Link ID Creation Issues
* Make sure you've selected the correct platform (iOS/Android)
* Verify your app is published and searchable in the respective app store
* Double-check the provider ID: `9936233049`
***
For any questions or assistance with Google Ads integration, please contact us at [support@linkrunner.io](mailto:support@linkrunner.io).
# Google Ads FAQ and Discrepancies
Source: https://docs.linkrunner.io/ad-networks/google-ads-discrepancies
Troubleshoot common issues and understand expected discrepancies between Google Ads and Linkrunner
Google Ads and Linkrunner will rarely show identical numbers. Most of the gap is expected — this page explains what's normal, what's fixable, and how to compare the two dashboards correctly.
For setup instructions, see the [Google Ads integration guide](/ad-networks/google-ads).
Google publishes its own explanation of the unavoidable differences: [Discrepancies in App Conversion Attribution](https://developers.google.com/app-conversion-tracking/api/discrepancies).
## Google Ads FAQ
Walk through this checklist.
Open [Integrations → Google Ads](https://dashboard.linkrunner.io/dashboard/integrations/google-ads), open the three-dot menu on the Google Ads row, choose **Setup Link IDs**, and verify the link ID is saved for the correct platform (Android or iOS).
On the Google Ads [Conversions summary](https://ads.google.com/aw/conversions) page, confirm the `first_open` conversion event is imported. If it was previously deleted, follow [Re-enabling Previously Deleted Events](/ad-networks/google-ads#re-enabling-previously-deleted-events). Full setup: [Importing Events for Conversion Actions Setup](/ad-networks/google-ads#4-importing-events-for-conversion-actions-setup).
If you run from an MCC (manager account) with sub-accounts, either:
* The MCC imports `first_open` on behalf of all linked sub-accounts, or
* The MCC shares the Link ID with the sub-account, and the sub-account imports `first_open` itself.
After creating or updating a Link ID, allow up to **12 hours** for Google to start attributing events.
You most likely haven't imported `first_open` as a conversion action. Follow [Importing Events for Conversion Actions Setup](/ad-networks/google-ads#4-importing-events-for-conversion-actions-setup).
Trigger the event in your app (on iOS, allow tracking so the IDFA is available) and confirm it appears in your Linkrunner dashboard.
In the Linkrunner Google Ads integration tab, confirm the event is mapped and postbacks are enabled.
From Google Ads, go to **Tools → Conversions** and import the event. See [Importing Events for Conversion Actions Setup](/ad-networks/google-ads#4-importing-events-for-conversion-actions-setup).
Conversions can take up to **6 hours** to appear in Google Ads after the event is performed.
It depends on the campaign type.
* **User acquisition campaigns:** Not mandatory.
* **Re-engagement campaigns:** Recommended. Import `session_start` to measure engagements.
Linkrunner works with a single Link ID per platform. Generate one Link ID in Google Ads and share it across the relevant accounts. See [Google's documentation on sharing Link IDs](https://support.google.com/google-ads/answer/3030657).
Both are supported. Work with your Google Ads representative to confirm the account structure, especially if multiple teams or agencies manage the same ad accounts.
Google is a Self-Reporting Network (SRN), so Google Ads reports its own attributed installs and events back to Linkrunner through its API.
* **Android:** Requires GAID collection. The Linkrunner SDK captures GAID automatically when the user has not opted out.
* **iOS:** Measurement happens via **SKAdNetwork (SKAN)**. Google Ads sends SKAN postbacks to Linkrunner, and installs with `did_win=TRUE` are counted. Deterministic SDK attribution only works when the user grants ATT and the IDFA is available. See [SKAdNetwork integration](/features/skadnetwork-integration) and [iOS Campaign Data Limitations](/features/ios-campaign-data-limitations).
* **iOS without ATT consent:** [Integrated Conversion Measurement](/sdk/ios#google-integrated-conversion-measurement-optional) can attribute installs even when ATT is denied and no IDFA is available. It complements SKAN rather than replacing it, and needs setup in your iOS app. Google keeps it inactive for users in the EEA, the UK, and Switzerland.
See the [Google Ads integration guide](/ad-networks/google-ads) for full setup on each platform.
## What causes discrepancies
The differences fall into three groups.
Google Ads dates conversions to the **ad click**. Linkrunner dates them to the **first app open** (or the event itself). A Jan 5 conversion in Linkrunner can show on Jan 1 in Google Ads if the click happened then.
As an SRN, Google attributes every install that follows any Google Ads engagement within its window. Linkrunner uses **last-click attribution**, deduplicates across networks, and counts one install per user.
On iOS, Linkrunner counts only SKAN postbacks with `did_win=TRUE`. Google Ads treats all SKAN events the same, so its dashboard typically reports more iOS installs. [Integrated Conversion Measurement](/sdk/ios#google-integrated-conversion-measurement-optional) narrows this gap for users who declined ATT.
### Row-by-row comparison
| Area | Google Ads | Linkrunner |
| ------------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| Install record time | Click time | First app open (Android) or SKAN postback time (iOS) |
| In-app event record time | Click time | Dashboard views: install time. Raw data: actual event time |
| Attribution window | 30 days for installs, 90 days for in-app events | Uses Google's API defaults; configurable per project |
| Time zones | Account local time | Project time zone |
| View-through conversions | Shown in **All conversions** column, not **Conversions** column by default | Click-through and view-through surfaced separately when Google provides them |
| Assisted installs | Every install following any engagement within the window is credited | Last-click attribution; earlier engagements are assists |
| Reinstalls | Reported as `session_start` conversions | Surfaced as re-attributions in the remarketing view |
| Count setting | "Every" conversion counted | One install per user; multiple occurrences of the same in-app event |
| Processing delay | Up to 24 hours | Near real time |
| Deduplication | Reports all claims, including overlap with other networks | Credits a single source per user |
| SKAN installs (iOS) | Counts postbacks with `did_win=TRUE` and `did_win=NULL` together | Counts only `did_win=TRUE` |
**Web-to-App is a Linkrunner-specific case.** When a Google Ads click originates from a web page that carries `gad_campaignid` in its referrer URL, Linkrunner attributes the resulting install to the **original SEARCH campaign from the referrer** rather than the App Campaign returned by Google's API. This keeps the real source intact across the web → install handoff. Google Ads will still report the install under the App Campaign, which produces a predictable per-campaign discrepancy.
## Troubleshooting checklist
If the gap is much bigger than expected:
* Event is **imported** in the correct Google Ads account.
* Event is **mapped and enabled** in the Linkrunner → Google Ads integration.
* The correct **Link ID** is saved in Linkrunner and shared with the right MCC sub-account.
* iOS: SKAdNetwork is configured and you're running App Campaigns. See [SKAdNetwork integration](/features/skadnetwork-integration).
* Android: cross-check the re-install rate in the Google Play Developer Console to rule out measurement-model differences.
***
For any questions or assistance, please contact us at [support@linkrunner.io](mailto:support@linkrunner.io).
# LinkedIn Ads
Source: https://docs.linkrunner.io/ad-networks/linkedin-ads
Run LinkedIn ads for your app with in-app event optimization and deferred deep linking
## Overview
LinkedIn Ads integration with Linkrunner enables you to run powerful app growth campaigns while optimizing on real in-app events and providing seamless deferred deep linking. This integration allows you to track installs, sign-ups, purchases, and custom events across both Android and iOS platforms.
**📱 Works for both Android & iOS Apps**
### What You'll Get
* **Optimization on real in-app events** — Track and optimize on Install, Sign Up, Purchase, and custom events
* **Deferred deep linking** — Drop users into the right screen after first open, even if they need to install the app first
* **Consistent tracking** — Unified tracking across Android & iOS via the Linkrunner SDK
* **Cross-platform consistency** — Track installs across iOS and Android with one unified flow
## Setup Process
Setting up LinkedIn Ads integration involves three main steps: creating conversions in LinkedIn Campaign Manager, configuring ad accounts in Linkrunner, and setting up your first campaign.
## 1. Creating Conversions
This process creates conversion events in LinkedIn that will be used to track and optimize your campaigns.
### Step 1: Access LinkedIn Campaign Manager
1. Go to [LinkedIn Campaign Manager](https://www.linkedin.com/campaignmanager)
2. Select your ad account
### Step 2: Create Conversion
1. Select **Conversion Tracking** under **Measurement**
2. Click **Create conversion**
3. Select **Conversions API**
### Step 3: Configure Conversion Details
1. Name your conversion
2. Select the appropriate category
3. Click **Next step**
### Step 4: Select Data Source
1. Select **Direct API** as the data source
2. Click **Next step**
### Step 5: Review and Create
1. Review your conversion settings
2. Click **Create**
### Step 6: Create Additional Conversions
1. Create different conversions as per your tracking needs (e.g., Install, Sign Up, Purchase)
## 2. Configuring Ad Accounts in Linkrunner
Connect your LinkedIn ad accounts with Linkrunner to enable seamless data sharing and conversion tracking.
### Step 1: Access LinkedIn Integration on Linkrunner
1. Go to [LinkedIn Integration tab on Linkrunner](https://dashboard.linkrunner.io/dashboard/integrations/linkedin-ads)
### Step 2: Add LinkedIn Account
1. Click **Add LinkedIn Account** to add your LinkedIn Ad accounts
### Step 3: Generate Access Token in LinkedIn
1. Go to [LinkedIn Campaign Manager](https://www.linkedin.com/campaignmanager)
2. Select your ad account
3. Select **Signals manager** under **Data**
4. Select **Direct API**
5. Click **Generate access token**
6. Copy the token
### Step 4: Configure CAPI on Linkrunner
1. Go back to Linkrunner and click on the three dots of the LinkedIn ad account you added
2. Click on **Configure CAPI**
3. Paste the token
4. Click **Save Configuration**
### Step 5: Repeat for Additional Accounts
1. Do the same process for all the ad accounts you want to integrate
**At this point your LinkedIn integration setup is completed!** Your LinkedIn Ads account is now connected to Linkrunner and ready to track conversions.
## 3. Setting Up Your First LinkedIn Campaign
After your accounts are configured, you can create your first LinkedIn ad campaign optimized for app conversions.
### Step 1: Create Campaign Link in Linkrunner
1. Go to [Linkrunner Dashboard](https://dashboard.linkrunner.io/)
2. Create a new campaign for each creative you plan to run
3. Select **LinkedIn** as the ad channel and select an ad account
4. Copy the **Shareable campaign link** of the campaign
### Step 2: Access LinkedIn Campaign Manager
1. Go to [LinkedIn Campaign Manager](https://www.linkedin.com/campaignmanager)
2. Select the same ad account
### Step 3: Create Campaign
1. From the **Overview** tab, click on **Create**
2. Select **Campaign**
3. Add your campaign name
4. Select the objective
5. Click **Next**
### Step 4: Configure Ad Set
1. Select **Classic** for the ad set type
2. Click **Next**
3. Name your ad set
4. Paste the campaign link in **source URL**
5. Select the suitable conversion events for this campaign
6. Click **Next**
### Step 5: Create Ad
1. Click on **Create ad**
2. Select **Start from a blank ad**
3. Give an introductory text
4. In **External URL**, paste the campaign link copied from Linkrunner
5. Click on **Save ads**
You can easily duplicate campaigns once created for every ad creative, making it faster to create multiple campaigns with different tracking links.
## Deferred Deep Linking
One of the powerful features of running LinkedIn ads with Linkrunner is deferred deep linking support:
* If your campaign includes deep link parameters, Linkrunner ensures that **after the install and first open**, users land on the correct in-app content
* This means a user who clicked an ad for a specific product but hadn't installed your app yet will still be taken directly to that product screen inside the app after installation
* Works seamlessly across both iOS and Android platforms
## Why Run Web-to-App Ads?
Running **web-to-app ads** with LinkedIn gives you more flexibility and control over your campaigns, while still unlocking:
* **In-app event optimization** — LinkedIn can optimize on installs or post-install events like purchases
* **Deferred deep linking** — Users land on the right content inside your app after they install from the ad
* **Cross-platform consistency** — Track installs across iOS and Android with one unified flow
* **Better attribution** — More accurate tracking of user acquisition and in-app behavior
Start by optimizing on simple events like app installs or sign-ups before moving to more complex conversion events
like purchases. This helps LinkedIn's algorithm gather initial data.
For any questions or assistance with LinkedIn Ads integration, please contact us at [support@linkrunner.io](mailto:support@linkrunner.io).
# Meta Ads
Source: https://docs.linkrunner.io/ad-networks/meta-ads
Configure your Meta app integration settings to enable proper attribution tracking for your mobile app campaigns
## Overview
Connect Meta Ads to send installs, registrations, purchases, and custom app events from Linkrunner to Meta. Use this guide to connect your Meta app and ad account, configure Conversions API, and map the events Meta should receive.
### What You'll Get
* **Automatic attribution tracking** — Track installs and conversions from your Meta ad campaigns
* **View-through attribution** — Attribute installs from users who viewed but didn't click your ads ([setup required](#6-enable-meta-view-through-attribution-android))
* **Event mapping** — Map your in-app events to Meta events for postback conversion tracking
* **Revenue tracking** — Share revenue data with Meta to optimize for value
* **Default events included** — App Install events and Sign up events are captured automatically
## Setup Process
Setting up Meta Ads integration involves configuring your Meta app credentials, mapping events, and setting up conversion tracking in Meta Events Manager.
Running iOS App Promotion campaigns? Meta measures iOS installs via SKAdNetwork. After completing this guide, follow
the [Meta SKAN Setup](/features/meta-skan-setup) guide.
## 1. Configure Meta App Integration
This process connects your app and Meta ad account with Linkrunner.
### Step 1: Get Your Meta App ID
1. Go to [Meta for Developers](https://developers.facebook.com/)
2. Navigate to your App Dashboard
3. Go to **Settings** → **Basic**
4. Copy your **App ID**
### Step 2: Get Your Referrer Decryption Key
1. In the same App Dashboard under **Settings** → **Basic**
2. Scroll down to the **Android** section
3. Copy the **Install Referrer Decryption Key**
The Install Referrer Decryption Key is required for Android attribution tracking. Make sure you have this configured
in your Meta app settings.
### Step 3: Configure in Linkrunner Dashboard
1. Go to [Meta App Integration](https://dashboard.linkrunner.io/dashboard/integrations/meta-ads?tab=integration)
2. Enter your **App ID** in the designated field
3. Enter your **Referrer Decryption Key**
4. Click **Save**
### Step 4: Connect Meta Ad Account
1. Go to [Ad Account Integration](https://dashboard.linkrunner.io/dashboard/integrations/meta-ads?tab=ad_account)
2. Click **Add Meta Account**
3. Log in with Meta and connect your ad account
### Step 5: Configure CAPI
1. Click **Configure CAPI** for the connected ad account
2. Enter the **Dataset ID** from Meta Events Manager
3. Enter the **CAPI Access Token** from Meta Events Manager → **Settings** → **Conversions API**
4. Click **Save**
## 2. Set Up Event Mapping
Map your app events to Meta events so Linkrunner can send postback conversions.
**MobileAppInstall** and **CompleteRegistration** are sent to Meta by default. You do not need mappings for these
events.
1. Go to [Meta Ads Event Mapping](https://dashboard.linkrunner.io/dashboard/integrations/meta-ads?tab=event_mapping)
2. Click **Add Mapping**
3. Select your event under **Your Events**
4. Select the matching **Meta Ads Event**
5. Turn on **Send Revenue** only for revenue events, then choose the **Currency**
6. Click **Save**
7. Click the paper plane **Send event to ad network** icon to fire a test event
After Linkrunner fires an event, it can take up to 1 hour to appear in Meta Events Manager.
If you cannot see **MobileAppInstall** or **CompleteRegistration** in Meta Events Manager, click the paper plane
**Send event to ad network** icon for that event.
## 3. Configure Events in Meta Events Manager
Use Meta Events Manager to verify that Meta receives events from Linkrunner.
1. Go to [Meta Ads Dashboard](https://business.facebook.com/)
2. Open **Events Manager** → **Datasets**
3. Select your app dataset, or click **Create Dataset**
4. Go to **Settings** → **Conversions API** and confirm the dataset can receive Conversions API events
5. Open the **Events** tab to view events from Linkrunner
6. Create custom conversion events for the events you want to optimize campaigns on
If events do not appear in Meta Events Manager after 1 hour, verify your App ID and Referrer Decryption Key are
correct.
## 4. Create Your First Campaign
With the integration configured, launch an App Promotion campaign in Meta Ads Manager. No tracking links are needed. Linkrunner attributes installs through the Meta install referrer (Android) and SKAdNetwork (iOS), and campaigns appear in Linkrunner automatically as installs come in.
Your app must be registered in [Meta for Developers](https://developers.facebook.com/) with its store listing URL,
and linked to the ad account you run campaigns from. Running website campaigns that send users to your app through a
landing page? Follow [Meta Web to App](/ad-networks/meta-web-to-app) instead.
### Step 1: Start a New Campaign
1. Open [Meta Ads Manager](https://business.facebook.com/adsmanager) and select the ad account you connected to Linkrunner
2. Click **+ Create**
3. Keep the buying type as **Auction**, select the **App promotion** objective, and click **Continue**
4. Enter a campaign name and click **Next**
Meta enables **Advantage+** by default, which automates audience and placements. You can switch to a manual setup
during configuration. Linkrunner attributes both the same way.
### Step 2: Configure the Ad Set
1. Under **App promotion**, select your **Mobile app store** (**Google Play Store** or **App Store**) and search for your app under **App name**
2. Set your **Budget & schedule**. Keep the budget low for the first campaign.
3. Define your **Audience** (locations, age, interests)
4. Leave **Advantage+ placements** on unless you have a reason not to
5. Click **Next**
If your app is not listed under **App name**, it is not registered with Meta or not linked to this ad account.
### Step 3: Create the Ad
1. Choose the Facebook Page and Instagram account for the ad's **Identity**
2. Upload your creative and write the primary text and headline
3. In **Destination**, keep **Main destination** as **App**
4. To open a specific screen after install, add your link in **Deferred deep link**. See [Deferred Deep Linking](#5-deferred-deep-linking) below.
5. Click **Publish**
### Step 4: Verify the Campaign in Linkrunner
Campaigns and spend sync automatically from your connected ad account. Attribution rows appear after the campaign receives its first install.
1. Open the [**Campaigns** section](https://dashboard.linkrunner.io/dashboard) in your Linkrunner dashboard
2. Click **All Channels** and select **Meta** to filter your campaigns
3. Open the campaign to view installs, events, and revenue attributed to it, broken down by ad set and ad creative
Campaign, ad set, and ad creative rows appear after they receive at least one install, not immediately at launch.
New campaigns can also take a few hours to exit Meta's review and start delivering.
## 5. Deferred Deep Linking
Use deferred deep links when a Meta install should open a specific screen after first app open. Linkrunner returns the selected URL in the SDK `getAttributionData()` response.
Campaign, ad set, and ad creative rows appear in Linkrunner after they receive at least one install.
### Meta App Promotion Deep Links
For Meta App Promotion campaigns, add the destination in Meta Ads Manager at the ad level. Linkrunner reads the ad creative `app_link` from Meta and returns it from `getAttributionData()`.
1. Open the campaign in [Meta Ads Manager](https://business.facebook.com/adsmanager)
2. Edit the **Ad** inside your App Promotion ad set
3. In **Destination**, choose **App**
4. Add your Linkrunner or app-link URL in **Deferred deep link**
5. Leave **Custom store listing** empty unless you want Meta to send users to a specific store listing
6. Publish the ad
The fresh Meta sync runs every 30 minutes for the last 2 days of active clicks and spend. When an active ad has an `app_link`, Linkrunner stores it on the matching ad creative. If you update or remove the app link in Meta, Linkrunner updates the auto-synced value on the next fresh sync that sees that ad.
Meta app links can use a Linkrunner domain, another app-link domain, an MMP link, or a custom scheme. If Linkrunner imports the link from Meta, the domain does not need to be added in Linkrunner.
Linkrunner syncs **Deferred deep link**. It does not currently sync Meta's **Custom store listing** value. That field controls the store page Meta opens when the app is not installed and is separate from the deep link returned by `getAttributionData()`.
Do not use a Play Store or App Store URL as the app link. Meta exposes store destinations separately, and Linkrunner only returns the app link.
### Priority Order
Linkrunner uses manual ad entity values before auto-synced Meta values:
1. **Manual Ad Creative Level**: Used first
2. **Manual Ad Set Level**: Used when the ad creative has no manual deep link
3. **Meta App Promotion App Link**: Used when Meta returns an ad creative `app_link`
4. **Campaign Level**: Used as the fallback
### Campaign-Level Deep Links
Use this when every ad in the campaign should open the same destination.
1. Navigate to the **Campaigns** section in your [Linkrunner Dashboard](https://dashboard.linkrunner.io)
2. Find your Meta campaign in the list
3. Click the **Edit** button on the campaign row
4. In the campaign settings, locate the **Deferred Deep Link** field
5. Enter your deep link URL, such as `https://myapp.com/product/12345`
6. Click **Save** to apply the changes
### Ad Set-Level Deep Links
Use this when an ad set should open a different destination from the campaign default. Manual ad set links use your Linkrunner project domain and override auto-synced Meta app links.
1. Navigate to the **Campaigns** section in your [Linkrunner Dashboard](https://dashboard.linkrunner.io)
2. Find your Meta campaign in the list
3. Click on the **ad set name** to open the ad set details
4. In the ad set configuration, locate the **Deferred Deep Link** field
5. Enter your deep link URL, such as `https://myapp.com/category/electronics`
6. Click **Update** to apply the changes
If no ad set deep link is set, Linkrunner uses the campaign deep link.
### Ad Creative-Level Deep Links
Use this when one ad should open a specific product, offer, or screen. Manual ad creative links use your Linkrunner project domain and override auto-synced Meta app links.
1. Navigate to the **Campaigns** section in your [Linkrunner Dashboard](https://dashboard.linkrunner.io)
2. Find your Meta campaign in the list
3. Click on the **ad creative name** (or ad name) to open the ad creative details
4. In the ad creative configuration, locate the **Deferred Deep Link** field
5. Enter your deep link URL, such as `https://myapp.com/product/12345?promo=summer`
6. Click **Update** to apply the changes
If no ad creative deep link is set, Linkrunner checks the ad set, then the campaign.
## 6. Enable Meta View-Through Attribution (Android)
By default, Linkrunner attributes installs from Meta ads using click-based attribution. To also attribute installs from users who **viewed** your ad but didn't click on it (view-through conversions), you need to enable Meta Install Referrer support.
Meta Install Referrer allows Linkrunner to receive ad campaign metadata directly from the Facebook or Instagram app installed on the user's device. This enables view-through conversion tracking, giving you a more complete picture of your Meta campaign performance.
### How It Works
1. A user views your Meta ad but doesn't click on it
2. The user later installs your app organically
3. The Linkrunner SDK retrieves campaign metadata stored by the Facebook/Instagram app on the device
4. Linkrunner attributes the install back to the original Meta ad view
### Setup
To enable this, you need to make your Facebook App ID available to the Linkrunner SDK in your Android app's `AndroidManifest.xml`.
Add the following to your `AndroidManifest.xml`:
```xml theme={null}
```
And add your App ID to `strings.xml`:
```xml theme={null}
YOUR_FACEBOOK_APP_ID
```
If you already have the Facebook SDK integrated, the Linkrunner SDK will automatically read the Facebook App ID from the existing `meta-data` tag. No additional configuration is needed.
### Requirements
* **Android SDK** version 3.5.2 or higher
* **Flutter SDK** version 3.6.2 or higher
* **React Native SDK** version 2.6.2 or higher
* Users must have **Facebook app** version 428.x.x+ or **Instagram app** version 296.x.x+ installed on their device
View-through attribution requires the Facebook or Instagram app to be installed on the user's device. If neither app is present, only click-based attribution will be available.
For more details, see the [Meta Install Referrer](/features/meta-install-referrer) documentation.
Test the complete preview, install, and first attribution response with [Meta Android attribution testing](/testing/meta-android-attribution-testing).
## FAQ and Discrepancies
For answers to common questions and an explanation of why numbers in Meta Ads Manager may not exactly match Linkrunner, see [Meta Ads FAQ and Discrepancies](/ad-networks/meta-ads-discrepancies).
## Troubleshooting
### Events Not Showing Up in Meta
* Verify your Referrer Decryption Key is correct
* Check that your dataset is configured to receive Conversions API events
* Wait up to 1 hour for events to appear in Meta Events Manager
* Test your event mappings using the paper plane **Send event to ad network** icon in Linkrunner
### Campaigns Not Appearing in Dashboard
Campaigns will only appear in the Linkrunner dashboard after the first install comes through that campaign, not immediately when the campaign is launched.
**Before troubleshooting, verify:**
* **App is live with Linkrunner SDK** — Ensure your app is deployed with the Linkrunner SDK integrated and has 100% rollout to users
* **Meta decryption key is configured** — Verify the [Install Referrer Decryption Key](#step-2-get-your-referrer-decryption-key) is correctly added in your Linkrunner dashboard settings
* **Campaigns are running** — Confirm your Meta ad campaigns are active and serving impressions
* **First install received** — Campaigns appear only after the first user installs your app via that campaign
Campaigns do not appear immediately after launch. You must wait for at least one install to come through the
campaign before it will show up in your dashboard.
### Revenue Data Not Tracking
* Confirm "Send Revenue" is enabled in your event mapping
* Verify the correct currency is selected
* Ensure your app is sending revenue values with events
***
For any questions or assistance with Meta Ads integration, please contact us at [support@linkrunner.io](mailto:support@linkrunner.io).
# Meta Ads FAQ and Discrepancies
Source: https://docs.linkrunner.io/ad-networks/meta-ads-discrepancies
Troubleshoot common issues and understand expected discrepancies between Meta Ads Manager and Linkrunner
Meta Ads Manager and Linkrunner will rarely show identical numbers. Most of the gap is expected. This page explains what's normal, what's fixable, and how to compare the two dashboards correctly.
For setup instructions, see the [Meta Ads integration guide](/ad-networks/meta-ads). For Web-to-App campaigns, see [Meta Web to App](/ad-networks/meta-web-to-app).
Meta publishes its own explanation of how it attributes and reports conversions: [About Meta attribution](https://www.facebook.com/business/help/458681590974355).
## Meta Ads FAQ
Walk through this checklist.
Open [Integrations → Meta Ads](https://dashboard.linkrunner.io/dashboard/integrations/meta-ads?tab=integration) and confirm the **App ID** and **Install Referrer Decryption Key** are saved.
On the [Ad Account tab](https://dashboard.linkrunner.io/dashboard/integrations/meta-ads?tab=ad_account), confirm the ad account is connected and CAPI is configured with the **Dataset ID** and **CAPI Access Token** from Meta Events Manager.
On the [Event Mapping tab](https://dashboard.linkrunner.io/dashboard/integrations/meta-ads?tab=event_mapping), confirm the event is mapped, then click the paper plane **Send event to ad network** icon to fire a test event.
Events can take up to **1 hour** to appear in Meta Events Manager after Linkrunner sends them.
Linkrunner sends `MobileAppInstall` to Meta only for installs it attributes to a Meta campaign. Organic installs and installs attributed to other networks are never sent, so they can't appear in Meta's numbers.
Two more common causes:
* **The 7-day event window.** Meta accepts conversion events up to 7 days old. An install processed or attributed more than 7 days after it happened is not sent to Meta at all.
* **Missing CAPI credentials.** Postbacks require the Dataset ID and access token on the ad account connected to that campaign. Without them, Linkrunner tracks the install but sends nothing to Meta.
This is the most common direction of discrepancy, and most of it is by design:
* **Meta self-attributes.** Meta claims every install with a Meta ad engagement inside its window, even when the user's last touch was another network. Linkrunner uses last-touch attribution across all networks and credits one source per install.
* **View-through conversions.** Meta counts installs after an ad view (1-day view window) by default. Linkrunner measures view-through only on Android, and only when [Meta Install Referrer](/features/meta-install-referrer) is set up. Meta also shares view-through data only for App promotion campaigns with broad targeting; see the [Meta View-Through Attribution FAQ](/ad-networks/meta-view-through-attribution). iOS view-through installs land as organic in Linkrunner.
* **Cross-device attribution.** Meta can credit an ad click on one device for an install on another. Linkrunner matches signals on the same device only.
* **Modeled results on iOS.** Meta's iOS reporting (Aggregated Event Measurement and SKAdNetwork) includes modeled and aggregated conversions. Linkrunner's SDK attribution on iOS is deterministic, and users who deny App Tracking Transparency often can't be matched, so those installs show as organic.
* **Window differences.** Linkrunner matches clicks up to 30 days before the install. Meta's default click window is 7 days, so a click 8 to 30 days before first open counts in Linkrunner but not in Meta.
* **Ads Manager attribution settings.** The install count in Ads Manager depends on the ad set's attribution setting and the columns you compare. Use Meta's [Compare Attribution Settings](https://www.facebook.com/business/help/654970342692714) view before treating the gap as real.
* **Events older than 7 days.** Installs that Linkrunner attributes late are kept in Linkrunner but dropped by Meta (see the previous question).
No. Linkrunner attributes Meta installs without it.
If you do integrate the Facebook SDK alongside Linkrunner, disable its automatic event logging (`AutoLogAppEventsEnabled = false`). Otherwise Meta receives the same in-app events twice, once from the Facebook SDK and once from Linkrunner CAPI, and its event and revenue counts inflate. Meta deduplicates install events automatically, but not in-app events sent from two sources.
* **Android:** Deterministic. Linkrunner decrypts the Meta campaign metadata in the Google Play install referrer using your Referrer Decryption Key. With [Meta Install Referrer](/features/meta-install-referrer) enabled, it also reads campaign metadata from the Facebook or Instagram app on the device, which adds view-through measurement.
* **iOS:** There is no install referrer. Linkrunner attributes deterministically by matching the install to a Meta click (deep-link click, or IDFA when the user grants App Tracking Transparency). Meta itself measures iOS through Aggregated Event Measurement and SKAdNetwork, which are aggregated, modeled, and delayed by 24+ hours.
For iOS Meta App campaigns where Linkrunner has no SDK-attributed installs, the dashboard shows the install counts Meta reports through its Marketing API, so those figures follow Meta's SKAN reporting and timing. See [iOS Campaign Data Limitations](/features/ios-campaign-data-limitations).
Web-to-App attribution comes from the click, not from Meta's API. When a click arrives with the `campaign_id`, `adset_id`, and `ad_id` macros on your Linkrunner destination URL, Linkrunner auto-creates a campaign named `Meta W2A - ` and attributes the install to it by click matching.
Linkrunner sends the website `Download` conversion to Meta only when the install matches a click. If no click match is found (more common on iOS, where there is no install referrer), the install is still tracked in Linkrunner but no conversion is sent to Meta for it.
Meta reports the same conversions under your Sales campaign in Ads Manager. See [Meta Web to App](/ad-networks/meta-web-to-app) for setup.
## What causes discrepancies
The differences fall into three groups.
Meta dates conversions to the **ad click or view**. Linkrunner dates installs to the **first app open**. A Jan 5 install in Linkrunner can show on Jan 1 in Meta if the ad interaction happened then.
Meta is a self-attributing network: it claims every install with a Meta engagement in its window, including view-through and cross-device. Linkrunner uses **last-touch attribution**, deduplicates across networks, and counts one install per user.
Meta measures iOS through Aggregated Event Measurement and SKAdNetwork: aggregated, partly modeled, and delayed 24+ hours. Linkrunner's iOS attribution is deterministic and depends on ATT consent or a deep-link click.
### Row-by-row comparison
| Area | Meta Ads Manager | Linkrunner |
| ------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Install record time | Date of the ad click or view | First app open |
| In-app event record time | Date of the ad interaction | Dashboard views: install time. Raw data: actual event time |
| Attribution windows | 7-day click, 1-day view by default (set per ad set) | Android: install referrer read at first open. iOS and Web-to-App: clicks matched up to 30 days |
| View-through conversions | Counted by default | Android only, requires Meta Install Referrer; not measured on iOS |
| Attribution scope | Meta engagements only; claims installs regardless of other networks | Last-touch across all networks; one source per install |
| Cross-device | Credits installs on a different device from the ad interaction | Same-device signals only |
| Reinstalls | Install events deduplicated within a 90-day window | One install per user; re-attribution possible only 90+ days after the original install |
| Time zones | Ad account time zone | Project time zone (synced Meta spend and clicks stay on Meta's calendar day) |
| Events sent to Meta | Only mapped events, and only when under 7 days old | All SDK events appear in Linkrunner |
| iOS installs | AEM and SKAN, includes modeled results, delayed 24+ hours | SDK-attributed installs; Meta-reported counts shown for iOS App campaigns without SDK attribution |
| Processing delay | CAPI events appear within \~1 hour; SKAN adds 24+ hours | Near real time |
## Troubleshooting checklist
If the gap is much bigger than expected:
* **App ID** and **Install Referrer Decryption Key** are saved in the [integration tab](https://dashboard.linkrunner.io/dashboard/integrations/meta-ads?tab=integration).
* **CAPI Dataset ID and access token** are configured for the connected ad account.
* Events are **mapped** and a test event (paper plane icon) reaches Meta Events Manager.
* If the Facebook SDK is also integrated, its **automatic event logging is disabled**.
* Android: [Meta Install Referrer](/features/meta-install-referrer) is configured if you expect view-through installs.
* iOS: SKAdNetwork is configured and you compare against Meta's SKAN reporting. See [SKAdNetwork integration](/features/skadnetwork-integration).
* Web-to-App: the ad's destination URL includes the `campaign_id={{campaign.id}}`, `adset_id={{adset.id}}`, and `ad_id={{ad.id}}` macros.
* You compare the same date range in the same time zone, remembering Meta reports on the ad-interaction date.
***
For any questions or assistance, please contact us at [support@linkrunner.io](mailto:support@linkrunner.io).
# Meta View-Through Attribution FAQ
Source: https://docs.linkrunner.io/ad-networks/meta-view-through-attribution
When Linkrunner receives view-through install data from Meta, and why Meta Ads Manager can show view-through conversions that Linkrunner does not
A view-through install happens when a user sees your ad, does not click it, and installs the app later. Meta attributes these installs in its own dashboard, but only shares view-through data with measurement partners like Linkrunner under specific campaign conditions. This page explains those conditions.
For setup instructions, see the [Meta Install Referrer guide](/features/meta-install-referrer). For general number differences between Meta and Linkrunner, see the [Meta Ads FAQ and Discrepancies](/ad-networks/meta-ads-discrepancies).
Meta documents this mechanism in [Meta Install Referrer](https://developers.facebook.com/docs/app-ads/meta-install-referrer/). It is the only channel through which Meta shares view-through install data, and it applies to campaigns "where the objective is app installs" (the **App promotion** objective in Ads Manager).
## When Meta shares view-through data
All of the following must be true for an install to arrive with view-through attribution:
Only campaigns created with the **App promotion** objective generate view-through install referrer data. Sales, Leads, Traffic and other objectives do not, even when their ads open the app store and drive installs.
Meta requires broad targeting: Advantage+ app campaigns, or manual App promotion campaigns with default age and gender (18-65+, all genders). If detailed targeting or custom audiences are applied, the Advantage versions are required. Narrow geo, age or gender targeting disqualifies the campaign from view-through data.
Your app needs the [Meta Install Referrer setup](/features/meta-install-referrer): a supported SDK version, the Facebook App ID in `AndroidManifest.xml`, the install referrer decryption key added in Linkrunner, and a recent Facebook or Instagram app on the user's device.
When these conditions are met, the Facebook or Instagram app stores an attribution record on the device for ad impressions as well as clicks. The Linkrunner SDK reads it on first launch and the install is attributed as view-through.
## Meta View-Through FAQ
Meta Ads Manager and the install referrer are two separate systems.
Ads Manager numbers are computed inside Meta. Meta logs every ad impression, receives your app's events through the Meta SDK or Conversions API, and joins the two within the ad set's attribution setting (for example 7-day click, 1-day view). This works for any campaign objective.
The install referrer is the only data Meta shares with measurement partners on the device. For campaigns that are not App promotion with broad targeting, Meta writes click records only. So Ads Manager can credit view-through conversions to a Sales campaign while no view-through evidence ever reaches the device, which is why Linkrunner (or any MMP) cannot report them.
Not necessarily. A Sales objective campaign can use the app as its conversion location and optimize for in-app purchases. Its ads open the app store and installs happen, but the objective is still Sales, so Meta does not generate view-through install referrer data for it.
Check the **Objective** column in Ads Manager. Campaign and ad set names are free text, so a campaign named "App Promotion" can still be a Sales campaign underneath.
No. Clicks that open the app store carry the install referrer for any objective, and Linkrunner attributes them normally. Only view-through data is restricted to App promotion campaigns with broad targeting.
The ad set's attribution setting (for example 7-day click, 1-day view) controls how Meta counts conversions in its own reporting. It does not enable view-through install referrer data on its own. A Sales ad set with a 1-day view window still produces no view-through data for Linkrunner.
No. The Meta Install Referrer is Android only. On iOS, view-through installs are only reflected through [SKAdNetwork](/features/skadnetwork-integration) postbacks.
Move install-driving budget to campaigns created with the **App promotion** objective, ideally Advantage+ app campaigns.
Use default age and gender. If you need detailed targeting or custom audiences, use their Advantage versions.
Follow the [Meta Install Referrer guide](/features/meta-install-referrer) to add the Facebook App ID and decryption key.
## References
* [Meta Install Referrer](https://developers.facebook.com/docs/app-ads/meta-install-referrer/) (Meta for Developers)
* [Install Referrer](https://developers.facebook.com/docs/app-ads/install-referrer/) (Meta for Developers, Play Store referrer)
* [Branch: Meta Content Provider Install Referrer Support](https://help.branch.io/docs/new-metafacebook-content-provider-install-referrer-support) (documents the broad targeting requirement)
***
For any questions or assistance, please contact us at [support@linkrunner.io](mailto:support@linkrunner.io).
# Meta Web to App
Source: https://docs.linkrunner.io/ad-networks/meta-web-to-app
Run Meta web-to-app ads for your app with advanced conversion tracking and optimization
## Overview
Meta Web to App integration with Linkrunner lets you run app growth campaigns through Facebook and Instagram using a standard Sales (Website) objective, while Linkrunner handles attribution, spend sync, and CAPI event delivery. Linkrunner automatically creates one campaign per Meta Campaign on first click, so there is no manual campaign setup required on the Linkrunner side.
**Works for both Android and iOS apps.**
### What You'll Get
* **Web-to-app conversion tracking.** Track installs, sign-ups, purchases, and custom events from Meta ads.
* **Conversions API (CAPI) integration.** Enhanced tracking accuracy and resilience against browser limitations.
* **Deferred deep linking.** Drop users into the right screen after first open, even if they need to install the app first.
* **Cross-platform consistency.** Track installs across iOS and Android with one unified flow.
* **Automatic campaign creation.** Linkrunner mirrors your Meta Campaign hierarchy on first click, no manual setup needed.
### How It Works
A user taps your Meta ad, lands on your Linkrunner subdomain, and is routed to the App Store. Linkrunner tracks the click, matches it to the install, and attributes the campaign.
## Setup
Connect your Meta advertising account with Linkrunner to enable spend sync and CAPI event delivery. This is the same OAuth connection used for Meta App Ads.
1. Go to [Linkrunner Settings → Meta Integration](https://dashboard.linkrunner.io/settings?s=meta-integration\&meta_tab=meta-ad-account)
2. Click **"Add account"**
3. Log in with your Facebook account credentials and grant the requested permissions
Linkrunner needs access to your Meta ad account to pull spend data from the Marketing API and to send CAPI conversion events back to Meta. Without this connection, cost and ROAS figures will not appear in your Linkrunner dashboard.
Linkrunner uses your custom subdomain as the destination URL in your Meta ads. If you have not set one up yet, go to [Settings → Domains](https://dashboard.linkrunner.io/settings?s=domains) and add a subdomain.
For full instructions on configuring the CNAME record, see the [Subdomain Setup guide](/features/subdomain-setup).
Once your subdomain is active (CNAME verified and SSL active), you are ready for the next step. You can also find your ready-to-paste destination URL under [Integrations → Meta Ads → Web to App](https://dashboard.linkrunner.io/dashboard/integrations/meta-ads?tab=web_to_app).
In Meta Ads Manager, paste your Linkrunner destination URL directly as the ad's **Website URL** (in the ad destination section, not the Tracking field).
Use this template, replacing `` with your actual subdomain:
```
https:///?utm_source=facebook&utm_medium=paid_social&campaign_id={{campaign.id}}&adset_id={{adset.id}}&ad_id={{ad.id}}&placement={{placement}}&site_source={{site_source_name}}
```
Meta substitutes the `{{...}}` macros with real campaign, ad set, ad, and placement IDs at delivery time. Linkrunner reads those values on each click to:
1. **Auto-create a Linkrunner campaign** mirroring your Meta Campaign on first click. No manual campaign creation is needed.
2. **Sync Meta spend** into your Linkrunner dashboard. The `campaign_id` macro is what the spend sync uses to pull cost data from Meta's Marketing API.
3. **Break out reports by ad set, ad, and placement** using `adset_id`, `ad_id`, `placement`, and `site_source`.
A ready-to-paste version of this URL (with your subdomain pre-filled) is available under [Integrations → Meta Ads → Web to App](https://dashboard.linkrunner.io/dashboard/integrations/meta-ads?tab=web_to_app). Pick the destination domain there and copy the URL directly.
Paste this URL as the **Website URL** in the ad destination section, not into the Tracking field. Without the `campaign_id` macro in the destination URL, Meta spend will not sync into Linkrunner reports and campaign-level breakdowns will be missing.
**How to configure the ad in Meta Ads Manager:**
1. Go to [Meta Ads Manager](https://business.facebook.com/adsmanager) and create or edit a campaign
2. Set the campaign objective to **Sales**
3. In the ad set, set **Conversion location** to **Website**, **Performance goal** to **Maximise number of conversions**, and select your **Dataset** (Meta pixel)
4. Select an ad set. Under **Placements**, open **Devices and operating systems** and select **Mobile** only. **Desktop** should not be selected.
5. In the ad, paste the destination URL above as the **Website URL**
6. Add your creative assets and publish
Even though the conversion location is set to Website, the Linkrunner link automatically redirects mobile users to your app. Meta treats it as a website click and Linkrunner handles the redirect to the appropriate app store or directly into the app.
## Set Up Conversions API for Event Sending
Configuring CAPI is optional but strongly recommended. It enables server-side event delivery to Meta, which improves attribution accuracy and campaign optimization.
### Generate an Access Token
Complete the access token setup in Facebook Events Manager:
1. Navigate to [Facebook Events Manager](https://business.facebook.com/events_manager2/list)
2. Follow the [CAPI token generation steps](/ad-networks/meta-ads#2-generate-access-token) as shown in the Meta Ads setup guide
3. Paste the token into your Linkrunner [Meta Integration settings](https://dashboard.linkrunner.io/settings?s=meta-integration)
Keep your access token secure and never share it publicly. This token provides access to your Meta advertising data.
## Why Run Meta Web to App Ads?
Running Meta web-to-app ads gives you more flexibility and control over your campaigns:
* **Better attribution.** CAPI provides server-side tracking that is more reliable than browser-based tracking.
* **Cross-platform reach.** Run campaigns across Facebook, Instagram, Messenger, and Audience Network.
* **Advanced optimization.** Meta's algorithm can optimize based on your actual in-app conversion events.
* **Deferred deep linking.** Users land on the right content inside your app after installation.
* **iOS 14.5+ compatibility.** Enhanced tracking that works within iOS privacy constraints.
## Important Notes
After connecting your Meta account and running the first click, it may take a few hours for conversion data to start flowing from Linkrunner to Meta. This is normal processing time.
Automatic Meta app-link sync is for Meta App Promotion campaigns. For Meta Web to App, keep using the Linkrunner destination URL as the ad **Website URL**.
Start with simple conversion events like app installs or sign-ups before optimizing for more complex events like purchases. This gives Meta's algorithm time to learn and gather sufficient data.
## Troubleshooting
### Conversions Not Tracking
* Verify your Meta account is properly connected in Linkrunner Settings → Meta Integration
* Ensure CAPI is configured correctly in Facebook Events Manager
* Check that your access token is valid and has not expired
* Confirm your app is sending events to the Linkrunner SDK
### Campaign Not Appearing in Linkrunner Dashboard
Linkrunner auto-creates a campaign on the first click that carries a valid `campaign_id` in the destination URL. If no campaign appears after the first click:
* Verify the destination URL includes `campaign_id={{campaign.id}}` with the correct macro syntax
* Check that the URL is pasted as the **Website URL** (not the Tracking field)
* Make sure the Linkrunner subdomain is active (CNAME verified and SSL active)
### Spend Not Syncing
* Confirm the `campaign_id` macro is present in the destination URL
* Verify the Meta ad account connected in Linkrunner matches the account running the campaign
* Allow up to a few hours for the first spend data to appear after campaign launch
***
For any questions or assistance with Meta Web to App integration, please contact us at [support@linkrunner.io](mailto:support@linkrunner.io).
# Reddit Ads
Source: https://docs.linkrunner.io/ad-networks/reddit-ads
Run Reddit Ads with Linkrunner attribution, Reddit click IDs, Conversions API postbacks, and deferred deep linking
## Overview
Use Reddit Ads with Linkrunner when you want to send Reddit traffic through one Linkrunner campaign link, attribute installs and in-app events, and send server-side conversions back to Reddit through the Reddit Conversions API.
The recommended flow is:
1. Create or prepare your Reddit Ads account.
2. Create a Reddit Pixel and Conversion Access Token in Reddit Events Manager.
3. Add the Reddit Pixel ID and Conversion Access Token in Linkrunner.
4. Create a Linkrunner campaign link for each Reddit ad or destination you want to measure.
5. After the Linkrunner campaign link is created, use it as the destination URL in Reddit Ads Manager.
6. Test the full click, install, first open, and event flow before scaling spend.
Linkrunner captures Reddit's `rdt_cid` click ID when a user clicks your Reddit ad. Reddit appends this value to the destination URL, so keep the Linkrunner campaign link intact and do not remove query parameters.
## What you need
* A Linkrunner project with the Linkrunner SDK installed in your app
* Android and iOS store URLs configured in Linkrunner
* Deep linking configured if the ad should open a specific in-app screen
* Access to a Reddit Ads account
* A Reddit Pixel ID
* A Reddit Conversion Access Token for Conversions API
Linkrunner's Reddit CAPI setup does not require a Reddit Advertiser ID or Ad Account ID. Use the Reddit account only to create the Pixel, generate the Conversion Access Token, and run the ad campaign.
## 1. Prepare your Reddit Ads account
Go to [Reddit Ads](https://ads.reddit.com) and sign in to the advertiser account that will run the campaign. If this is your first Reddit campaign, complete the business, billing, and advertiser setup required by Reddit.
In Reddit Ads Manager, keep these values ready:
* **Pixel ID** - this identifies the Reddit Pixel that should receive conversions.
* **Conversion Access Token** - this authorizes Linkrunner to send Reddit Conversions API events.
You can create or manage these from Reddit Events Manager.
## 2. Add Reddit CAPI credentials in Linkrunner
Open your Linkrunner project and go to **Dashboard > Integrations > Reddit Ads**.
Complete the Linkrunner setup in this order:
1. Open **Pixel Management**.
2. Add the Reddit Pixel ID and Conversion Access Token.
3. Open **Event Mapping** after the credentials are saved.
Paste the Reddit Pixel ID and Conversion Access Token from Reddit Events Manager, then click **Save Configuration**.
After the credentials are saved, Linkrunner shows the Reddit Pixel row on **Pixel Management** with a configured CAPI status.
After saving, Linkrunner uses these credentials to send Reddit Conversions API events for attributed installs and mapped in-app events.
Use the Pixel ID and Conversion Access Token from the same Reddit advertiser account. A mismatched Pixel or token can prevent Reddit from accepting or matching events.
## 3. Map events for Reddit
Open the **Event Mapping** tab inside the Reddit Ads integration page. This tab becomes available after the Reddit Pixel ID and Conversion Access Token are saved.
Click **Add Mapping** for each app event you want Reddit to optimize or report on.
Create or review your mappings:
1. Review the default install mapping. Linkrunner sends installs and first opens to Reddit as a custom event named `Install`.
2. In **Your Events**, choose the Linkrunner event captured by your SDK, such as `registration`, `purchase`, `search`, or `add_to_cart`.
3. In **Reddit Ads Event**, choose the matching Reddit event key, such as `SIGN_UP`, `PURCHASE`, `SEARCH`, or `ADD_TO_CART`.
4. For purchase or revenue events, enable **Send Revenue** and choose the currency that should be sent to Reddit.
5. Save the mapping.
6. Use Reddit Events Manager's test flow or Linkrunner's test-event action to confirm the mapped event reaches Reddit before increasing spend.
Linkrunner sends install and first-open defaults as a Reddit custom event named `Install`. Signup and registration defaults use Reddit's `SIGN_UP` event. For purchase and other post-install events, create explicit mappings so Linkrunner knows which Reddit event to send.
Recommended starting mappings:
| Linkrunner event | Reddit event key |
| --------------------------- | ---------------------------- |
| Install or first open | Custom event named `Install` |
| Registration or signup | `SIGN_UP` |
| Purchase | `PURCHASE` |
| Search | `SEARCH` |
| Add to cart | `ADD_TO_CART` |
| Lead form or qualified lead | `LEAD` |
| Custom app event | Custom Reddit event name |
Start with the default install/signup behavior and a purchase mapping. Add search, add-to-cart, lead, or custom mappings after your first campaign is sending clean traffic and Reddit Events Manager shows successful event delivery.
## 4. Create a Linkrunner campaign link
Before you create the Reddit ad, create the Linkrunner campaign link that Reddit will send users to.
1. Go to **App Campaigns** in Linkrunner.
2. Click **Create Link**.
3. Enter a clear campaign name and destination setup.
4. Add the app route or deferred deep link if the ad should open a specific in-app screen after install.
5. Open **Advanced Options**.
6. Select **Reddit Ads** as the ad channel when the option is available.
7. Create the campaign and copy the shareable Linkrunner campaign link.
Use **Advanced Options** to set the ad channel and any campaign metadata you need for reporting.
Create a new Linkrunner campaign for every new Reddit destination link that should be valid and measured independently. For example, use different Linkrunner campaign links for different creatives, landing destinations, audiences, or experiments.
Create different links for different ad creatives to track creative-level metrics. You can easily duplicate campaigns once created for every ad creative, making it faster to create multiple campaigns with different tracking links.
Do not paste the App Store or Play Store URL directly into Reddit Ads if you want Linkrunner attribution, deferred deep linking, or Reddit CAPI postbacks. Reddit should send the user to the Linkrunner campaign link first.
## 5. Create the Reddit campaign
After the Linkrunner campaign link is created, go to [Reddit Ads Manager](https://ads.reddit.com) and create the campaign that will use that link.
### Step 1: Open Ads Manager
Go to [Reddit Ads Manager](https://ads.reddit.com) and select the ad account that owns the Pixel you configured in Linkrunner.
Click **Create Campaign**.
### Step 2: Use Advanced Create
If Reddit asks how you want to create the campaign, choose **Advanced Create**. This gives you access to objective, audience, bidding, conversion goal, and destination URL controls.
### Step 3: Configure campaign details
Enter a clear campaign name. Choose the objective based on how you want Reddit to optimize delivery:
* **Conversions** - recommended when your Reddit Pixel and Linkrunner CAPI events are ready, and you want Reddit to optimize toward mapped events like Sign Up, Lead, or Purchase.
* **Traffic** - useful for an early click-through test before event optimization.
For most Linkrunner CAPI setups, select **Conversions**.
Set the campaign budget, schedule, and bidding strategy. Keep the budget low while testing the first campaign.
### Step 4: Configure the ad group
In the ad group, configure the audience and delivery settings:
1. Add an ad group name.
2. Choose audience suggestions such as keywords, communities, custom audiences, and locations.
3. Choose placements. **Auto placements** is a good default for the first test.
4. Set the ad group budget and schedule.
5. In **Bidding & optimization**, select the conversion goal that matches the Reddit event you mapped in Linkrunner.
6. Choose the conversion optimization window, such as 7-day click and 1-day view, unless your team has a stricter attribution policy.
The conversion goal should match the event you want Reddit to optimize for. For example, if Linkrunner maps registration to Reddit **Sign Up**, choose that Sign Up conversion goal in Reddit when it is available.
### Step 5: Create the ad
On the ad setup screen, add the creative that Reddit users will see:
1. Enter the ad name.
2. Add the headline, media, body copy, and call to action.
3. In the destination URL field, paste the **Linkrunner campaign link** you created earlier.
4. Keep all URL parameters intact.
5. Preview the ad.
Do not replace the Linkrunner campaign link with the App Store URL, Play Store URL, or your website URL. Reddit must send the click to Linkrunner first so Linkrunner can capture `rdt_cid`, redirect the user, attribute the install, and send Reddit CAPI postbacks.
### Step 6: Review and publish
Review the campaign, ad group, and ad before publishing.
1. Confirm the ad account owns the same Reddit Pixel configured in Linkrunner.
2. Confirm the Pixel and conversion goal match the Pixel configured in Linkrunner.
3. Confirm the destination URL is the Linkrunner campaign link.
4. Confirm the budget is intentionally low for the first test.
5. Publish the campaign when everything is correct.
If you create multiple Reddit ads in the same ad group, create a separate Linkrunner campaign link for each ad when you want ad-level reporting in Linkrunner.
## How attribution and CAPI work
When a Reddit user clicks your ad, Reddit appends `rdt_cid` to the destination URL. Linkrunner captures that click ID on the campaign link click and carries it through install attribution.
For attributed installs and mapped in-app events, Linkrunner sends a Reddit Conversions API event that includes:
* The Reddit click ID (`rdt_cid`)
* The mapped Reddit event name
* Event time
* Deduplication ID
* Hashed user identifiers when available
* Value, currency, item count, and other event metadata when available
This lets Reddit match the server-side conversion back to the ad click while Linkrunner continues to show attribution and campaign performance in your dashboard.
## Frequently Asked Questions
Unlike Meta campaigns, Reddit campaigns do not appear automatically on the Linkrunner dashboard. They show up after users click the Linkrunner campaign links you set as destination URLs in your Reddit ads.
To fix this, confirm each Reddit ad uses a Linkrunner campaign link, the ad is active and receiving traffic, and your app is live with the Linkrunner SDK installed.
Confirm the Pixel ID and Conversion Access Token are from the same Reddit advertiser account. Then verify that the Reddit ad sends users to the Linkrunner campaign link and that the click URL preserves `rdt_cid`.
Reddit Events Manager may show Linkrunner events under **CAPI Direct**. Pixel source events appear only when you also install Reddit's browser Pixel on your website; Linkrunner's server-side postbacks do not require that browser Pixel source.
No. Reddit uses a web-to-app flow where the Linkrunner campaign link acts as the tracking mechanism. Campaigns appear on the dashboard when users click through these links.
`rdt_cid` is Reddit's click ID. Reddit appends it to the destination URL when a user clicks an ad, and Linkrunner captures it from the campaign link click for attribution and Reddit CAPI postbacks.
Keep the Linkrunner campaign link intact and do not remove URL parameters. Without `rdt_cid`, Linkrunner cannot match the install or event back to the Reddit ad click.
You can, but it is not recommended when you want creative-level reporting. Create separate Linkrunner campaign links for each Reddit ad, creative, destination, or experiment you want to split in reporting. Update each Reddit ad destination URL with the correct Linkrunner campaign link.
You can quickly duplicate campaigns in Linkrunner to create multiple links for different ad creatives.
Check the Linkrunner campaign's store URLs, platform routing, and deferred deep link configuration. If you changed the campaign after publishing the Reddit ad, click the Reddit ad again to test the current redirect behavior.
Yes. Linkrunner can send mapped purchase and revenue events to Reddit when your app sends revenue events through the Linkrunner SDK and the event is mapped to Reddit's `PURCHASE` event.
For purchase or revenue events, enable **Send Revenue** in the Reddit event mapping and choose the currency that should be sent to Reddit.
Use Linkrunner's SDK testing flow to verify installs and events first. Then use [Reddit's event verification workflow for test events](https://ads-api.reddit.com/docs/v3/capi-verify-events) to confirm events are reaching Reddit Events Manager.
## Related setup
Configure Android App Links and iOS Universal Links.
Read deferred deep link data after first app open.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Snapchat Ads
Source: https://docs.linkrunner.io/ad-networks/snapchat
Run Snapchat ads for your app with in-app event optimization and deferred deep linking
## Overview
Snapchat Ads integration with Linkrunner enables you to run powerful app growth campaigns while optimizing on real in-app events and providing seamless deferred deep linking. This integration allows you to track installs, sign-ups, purchases, and custom events across both Android and iOS platforms.
**📱 Works for both Android & iOS Apps**
### What You'll Get
* **Optimization on real in-app events** — Track and optimize on Install, Sign Up, Purchase, and custom events
* **Deferred deep linking** — Drop users into the right screen after first open, even if they need to install the app first
* **Consistent tracking** — Unified tracking across Android & iOS via the Linkrunner SDK
* **Cross-platform consistency** — Track installs across iOS and Android with one unified flow
## Setup Process
Setting up Snapchat Ads integration involves configuring a pixel in Snapchat Ads Manager and connecting it to Linkrunner.
## 1. Configuring Pixel in Snapchat Ads Manager
This process takes approximately 5 minutes and connects your app tracking with Snapchat's advertising platform.
### Step 1: Connect Snapchat Account on Linkrunner
1. Go to [Snapchat Integration tab on Linkrunner](https://dashboard.linkrunner.io/dashboard/integrations/snapchat-ads)
### Step 2: Add Snapchat Account
1. Click **Add Snapchat Account** to add your Snapchat Ad accounts
### Step 3: Navigate to Snapchat Ads Manager
1. Go to [Snapchat Ads Manager](https://ads.snapchat.com/)
2. Click the menu from the top left corner and select **Pixels**
### Step 4: Create Pixel
1. Click **Create Pixel**
### Step 5: Name Your Pixel
1. Add a name for your Pixel
2. Click **Create my Snap Pixel**
### Step 6: Skip Website URL
1. Skip the website URL dialog
### Step 7: Select Manual Setup
1. In **Event setup method**, select **Manual set-up**
### Step 8: Select Conversion API
1. For **Set up web events**, select **Conversion API**
### Step 9: Choose Manual Set-up Method
1. For **Set-up method**, select **Manual set-up**
### Step 10: Select Events to Track
1. In **Event source set-up**, select all the events you want to track
### Step 11: Select Event Parameters
1. Select the parameters for your events
### Step 12: Copy Credentials
1. Copy the **Access Token** and **Pixel ID**
### Step 13: Configure Pixel on Linkrunner
1. Go back to Linkrunner and click on the three dots of the Snapchat account you added
2. Click on **Configure Pixel**
### Step 14: Paste Credentials
1. Paste the **Pixel ID** and **Access Token**
2. Click **Save Configuration**
**At this point your Snapchat Pixel setup is completed!** Your Snapchat Ads account is now connected to Linkrunner and ready to track conversions.
## 2. Setting up your first Snapchat campaign
After your pixel is configured, you can create your first Snapchat ad campaign optimized for app conversions.
### Step 1: Navigate to Manage Ads
1. Go to [Snapchat Ads Manager](https://ads.snapchat.com/)
2. Click the menu from the top left corner and select **Manage Ads**
### Step 2: Choose Campaign Objective
1. Choose the objective for your campaign
### Step 3: Select Manual Build
1. Choose **Manual Build**
### Step 4: Add Campaign Info
1. Add info for your Campaign
### Step 5: Add Ad Set Details
1. Add ad set details
### Step 6: Create Linkrunner Campaign Links
1. Go to [Linkrunner Dashboard](https://dashboard.linkrunner.io)
2. Create a new campaign for each ad creative you plan to run
3. Select **Snapchat** as the ad channel and select an Ad account
4. Copy the **Shareable campaign link** for each campaign
### Step 7: Add Website URL for the Creative
1. For every ad you create, add a different **Linkrunner Campaign Link** in the **Website URL** field
2. This allows you to track performance for each ad creative separately
### Step 8: Publish the Campaign
1. Review your campaign settings and publish
## Deferred Deep Linking
One of the powerful features of running Snapchat ads with Linkrunner is deferred deep linking support:
* If your campaign includes deep link parameters, Linkrunner ensures that **after the install and first open**, users land on the correct in-app content
* This means a user who clicked an ad for a specific product but hadn't installed your app yet will still be taken directly to that product screen inside the app after installation
* Works seamlessly across both iOS and Android platforms
## Why Run Web-to-App Ads?
Running **web-to-app ads** with Snapchat gives you more flexibility and control over your campaigns, while still unlocking:
* **In-app event optimization** — Snapchat can optimize on installs or post-install events like purchases
* **Deferred deep linking** — Users land on the right content inside your app after they install from the ad
* **Cross-platform consistency** — Track installs across iOS and Android with one unified flow
* **Better attribution** — More accurate tracking of user acquisition and in-app behavior
Start by optimizing on simple events like app installs or sign-ups before moving to more complex conversion events
like purchases. This helps Snapchat's algorithm gather initial data.
For any questions or assistance with Snapchat Ads integration, please contact us at [support@linkrunner.io](mailto:support@linkrunner.io).
# TikTok Ads
Source: https://docs.linkrunner.io/ad-networks/tiktok
Run TikTok ads for your app with in-app event optimization and deferred deep linking
## Overview
TikTok Ads integration with Linkrunner enables you to run powerful app growth campaigns while optimizing on real in-app events and providing seamless deferred deep linking. This integration allows you to track installs, sign-ups, purchases, and custom events across both Android and iOS platforms.
**📱 Works for both Android & iOS Apps**
### What You'll Get
* **Optimization on real in-app events** — Track and optimize on Install, Sign Up, Purchase, and custom events
* **Deferred deep linking** — Drop users into the right screen after first open, even if they need to install the app first
* **Consistent tracking** — Unified tracking across Android & iOS via the Linkrunner SDK
* **Cross-platform consistency** — Track installs across iOS and Android with one unified flow
## Setup Process
Setting up TikTok Ads integration involves two main steps: configuring a pixel in TikTok Events Manager and setting up your first campaign.
## 1. Configuring Pixel in TikTok Events Manager
This process takes approximately 5 minutes and connects your app tracking with TikTok's advertising platform.
### Step 1: Connect TikTok Account on Linkrunner
1. Go to [TikTok Integration tab on Linkrunner](https://dashboard.linkrunner.io/dashboard/integrations/tiktok-ads)
### Step 2: Add TikTok Account
1. Click **Add TikTok Account** to add your TikTok Ads account
### Step 3: Navigate to TikTok Ads Manager
1. Go to [TikTok Ads Manager](https://ads.tiktok.com/i18n/home)
2. Select the Ads Manager of the account that is being used
### Step 4: Access Events Manager
1. Select **Tools** from the top bar
2. Select **Events Manager**
### Step 5: Connect Data Source
1. Click on **Connect data source**
### Step 6: Select Web as Data Source
1. Select **Web** from the available options
### Step 7: Add Your Website
1. Enter your website link in the provided field
2. Click **Continue**
### Step 8: Choose Manual Setup
1. Click on **Manual setup** option
### Step 9: Select Events API
1. Click on **Events API** as your setup method
### Step 10: Name Your Pixel
1. Add a descriptive name for your TikTok Pixel
2. Click **Continue**
### Step 11: Select Template
1. Select a template (E-commerce recommended)
2. Click **Continue**
### Step 12: Copy Credentials
1. Copy the **Pixel ID** from the setup screen
2. Copy the **Access Token** from the setup screen
### Step 13: Configure Pixel on Linkrunner
1. Go back to Linkrunner and click on the TikTok Ads account you added
### Step 14: Paste Credentials
1. Paste the **Pixel ID** and **Access Token** on Linkrunner
**At this point your TikTok Pixel setup is completed!** Your TikTok Ads account is now connected to Linkrunner and ready to track conversions.
## 2. Setting up your first TikTok campaign
After your pixel is configured, you can create your first TikTok ad campaign optimized for app conversions.
### Step 1: Switch to Full Version
1. Click the **Switch to Full Version** button
### Step 2: Configure Campaign Settings
1. Select **Sales** as the conversion type
2. Select **Website** as the Sales Destination
3. Click **Continue**
### Step 3: Configure Optimization and Bidding
1. In the **Optimization and bidding** section:
* Select **Goal** as **Conversion**
* Select **Data connection** to the pixel you created and integrated with Linkrunner
* Select **Optimization event** based on which you would like to optimize your ad campaign (common events include: Install, Sign Up, Purchase, or custom events)
2. Click **Continue** to configure ads
### Step 4: Create Linkrunner Campaign Links
Before setting up your ads, create campaign links in Linkrunner:
1. Go to [Linkrunner Dashboard](https://dashboard.linkrunner.io)
2. Create a new campaign for each ad creative you plan to run
3. Copy the **Shareable campaign link** for each campaign
You can easily duplicate campaigns once created for every ad creative, making it faster to create multiple campaigns with different tracking links.
### Step 5: Add Destination URLs
1. For every ad you create, add a different **Linkrunner Campaign Link** in the **Destination URL** field
2. This allows you to track performance for each ad creative separately
### Step 6: Add More Ads to Ad Group
1. Add more ads with different destination links to the ad group
You can easily duplicate campaigns once created for every ad creative, making it faster to create multiple campaigns with different tracking links.
## Deferred Deep Linking
One of the powerful features of running TikTok ads with Linkrunner is deferred deep linking support:
* If your campaign includes deep link parameters, Linkrunner ensures that **after the install and first open**, users land on the correct in-app content
* This means a user who clicked an ad for a specific product but hadn't installed your app yet will still be taken directly to that product screen inside the app after installation
* Works seamlessly across both iOS and Android platforms
## Why Run Web-to-App Ads?
Running **web-to-app ads** with TikTok gives you more flexibility and control over your campaigns, while still unlocking:
* **In-app event optimization** — TikTok can optimize on installs or post-install events like purchases
* **Deferred deep linking** — Users land on the right content inside your app after they install from the ad
* **Cross-platform consistency** — Track installs across iOS and Android with one unified flow
* **Better attribution** — More accurate tracking of user acquisition and in-app behavior
Start by optimizing on simple events like app installs or sign-ups before moving to more complex conversion events
like purchases. This helps TikTok's algorithm gather initial data.
## Frequently Asked Questions
Unlike Meta campaigns, TikTok campaigns **do not appear automatically** on the Linkrunner dashboard. They only show up when users click on the Linkrunner campaign links you set up as destination URLs in your TikTok ads.
**Checklist to fix this:**
1. **Verify your campaign links are set up correctly** — Each TikTok ad must have a unique Linkrunner campaign link as the destination URL ([see Step 5](#step-5-add-destination-urls))
2. **Ensure your ads are active and receiving traffic** — Campaigns only appear after at least one click comes through the tracking link
3. **Check that the Linkrunner SDK is integrated in your app** — Your app must be live with the Linkrunner SDK for installs to be attributed
4. **Confirm your TikTok pixel is configured** — Without a properly configured pixel ([see Part 1](#1-configuring-pixel-in-tiktok-events-manager)), event postbacks to TikTok won't work
If installs or other events are not appearing in TikTok Events Manager, check the following:
1. **Pixel credentials are correct** — Verify that the **Pixel ID** and **Access Token** you pasted into Linkrunner match the ones in TikTok Events Manager ([see Step 12](#step-12-copy-credentials))
2. **Access token hasn't expired** — TikTok access tokens can expire or be revoked. Regenerate the token in TikTok Events Manager and update it in Linkrunner if needed
3. **Wait for propagation** — It can take up to 45 minutes to 1 hour for initial events to appear in TikTok Events Manager
4. **Users are clicking the campaign link** — Events are only sent to TikTok for installs that came through a Linkrunner campaign link with a valid TikTok click ID (`ttclid`)
**No.** This is a key difference between the two integrations:
* **Meta Ads** — Campaigns are detected and appear automatically on the Linkrunner dashboard once they generate installs
* **TikTok Ads** — You must create individual Linkrunner campaign links and use them as destination URLs in your TikTok ads. Campaigns only appear on the dashboard when users click through these links
This is because TikTok uses a web-to-app flow where the campaign link acts as the tracking mechanism.
`ttclid` is TikTok's **Click ID** — a unique identifier that TikTok appends to your destination URL when a user clicks on your ad. It is essential for attribution.
**Why it matters:**
* Without `ttclid`, Linkrunner cannot attribute an install back to a specific TikTok ad click
* If `ttclid` is missing, event postbacks (installs, purchases, etc.) will **not** be sent back to TikTok, which means TikTok cannot optimize your campaigns
**How to ensure it works:**
* Use Linkrunner campaign links as destination URLs — `ttclid` is automatically captured when a user clicks through
* Do not modify or strip URL parameters from your campaign links
**You can, but it's not recommended.** Using a unique Linkrunner campaign link for each ad creative allows you to:
* Track performance per creative separately
* See which specific ad is driving the most installs and conversions
* Make better optimization decisions based on granular data
You can quickly duplicate campaigns in Linkrunner to create multiple links ([see Step 4](#step-4-create-linkrunner-campaign-links)).
**Yes.** Linkrunner supports sending purchase and revenue events back to TikTok, which enables TikTok to optimize your campaigns for value.
To set this up:
1. Ensure your app sends revenue events with amount and currency through the Linkrunner SDK
2. Select **Purchase** as the optimization event when creating your TikTok campaign ([see Step 3](#step-3-configure-optimization-and-bidding))
Revenue data is sent in the currency your app provides (ISO 4217 format, e.g., USD, INR).
Start with optimizing on installs or sign-ups first. Once TikTok's algorithm has gathered enough conversion data, you can switch to optimizing on purchases for better ROAS.
Linkrunner uses a **web-to-app** flow for TikTok campaigns. Selecting **Website** as the destination lets you use Linkrunner campaign links as destination URLs, which enables:
* **Deferred deep linking** — Users land on the right in-app content after install
* **Cross-platform consistency** — One unified tracking flow for both Android and iOS
* **Better attribution** — Linkrunner captures the TikTok click ID for accurate attribution
* **In-app event optimization** — Events are sent back to TikTok via the Events API for campaign optimization
Selecting "App" would bypass Linkrunner's tracking entirely.
**No.** You only need one TikTok pixel per ad account. The same pixel can be used across all your TikTok campaigns. The pixel is configured once during the initial setup ([see Part 1](#1-configuring-pixel-in-tiktok-events-manager)) and is then shared across all campaigns running under that ad account.
For any questions or assistance with TikTok Ads integration, please contact us at [support@linkrunner.io](mailto:support@linkrunner.io).
# Create an Affiliate Account
Source: https://docs.linkrunner.io/affiliate-partners/create-account
Create your affiliate workspace, add your network details, and submit it for approval
Create an affiliate account before you receive Linkrunner tracking links or postbacks.
You need a work email, a network logo, a short description, and a valid post-install event URL. The install postback URL and website are optional.
## 1. Start your registration
Open [Affiliate Partner Sign Up](https://dashboard.linkrunner.io/affiliate/signup).
Choose **Continue with Google** or enter your **Work Email**, accept the terms, then click **Continue**.
If you use a work email, open the sign-in link sent to your inbox. The link expires after 15 minutes.
## 2. Add your network details
Upload a PNG, JPG, or WebP file up to 5 MB.
Enter your affiliate network name and a short description of the traffic or services you provide.
Enter your website URL if you have one. This field is optional.
Click **Continue** to add your callback URLs.
## 3. Add your postback URLs
Enter the endpoints where Linkrunner should send attributed conversions:
* **Install Postback URL:** Optional. Add it if you want a separate callback for installs.
* **Post-Install Event URL:** Required. Add the endpoint that receives signup, purchase, and other in-app event postbacks.
Use complete HTTPS URLs, including any credentials that Linkrunner should use. You can edit the URLs later from your affiliate settings.
Click **Complete setup**.
Linkrunner configures your postbacks after your application is approved. See [Postbacks and Macros](/affiliate-partners/postbacks) for supported macros and IP allowlisting.
## 4. Wait for approval
The **Pending verification** page confirms that Linkrunner received your application. You can close the page safely.
Linkrunner emails you when the workspace is approved. After approval, you can sign in and work with connected Linkrunner customers.
## Troubleshooting
**The email sign-in link expired.**
Return to [Affiliate Partner Sign Up](https://dashboard.linkrunner.io/affiliate/signup) and request a new link.
**Continue is disabled on the network details step.**
Upload a supported logo, enter an affiliate name, and add a description.
**Complete setup is disabled.**
Enter a valid HTTPS post-install event URL. The install postback URL can remain blank.
**Your workspace still shows Pending verification.**
Your application is waiting for Linkrunner approval. You receive an email after the review is complete.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Create an Affiliate Campaign Link
Source: https://docs.linkrunner.io/affiliate-partners/create-campaign-link
Create a tracking link from the affiliate dashboard with your template parameters included automatically
Do not reuse a standard campaign link created outside your affiliate dashboard. It may not appear in your workspace or include the parameters your platform needs.
## Before you start
You need:
* An approved Linkrunner affiliate account
* Access to the advertiser project where you will run the campaign
* The macro names your platform uses for click ID, campaign ID, and any optional passthrough values
Read [Tracking Links](/affiliate-partners/tracking-links) if you need to review how Linkrunner uses click IDs and passthrough parameters.
## 1. Open the advertiser project
Open [Linkrunner Dashboard](https://dashboard.linkrunner.io) and sign in with your approved affiliate account.
Use the project selector to open the advertiser workspace connected to your affiliate account.
Select **All Campaigns**, then click **Create Link**.
## 2. Create the campaign
Enter a name that your team can identify in the affiliate dashboard.
Add an optional **Display ID** if you need a stable identifier for reporting or campaign operations.
Enable deferred deep linking if the campaign should open a specific in-app destination after installation. Open **Advanced Options** only when the advertiser has given you the required redirect or deep-link settings.
Click **Create Campaign**. Linkrunner creates the campaign under your affiliate account and generates its tracking-link template.
## 3. Copy the generated template
Find the new campaign under **All Campaigns** and copy its link from the **Content** column.
The affiliate dashboard adds the template parameters configured for your account automatically. A template may look like this:
```text theme={null}
https://track.example.com/?c=CAMPAIGN_ID&tid={click_id}&s2={campaign_id}
```
Your template may use different macro names. Copy the exact URL shown in your dashboard.
Do not manually rename, remove, or add template parameters. If your platform uses different macros, update the affiliate configuration with Linkrunner before sending traffic.
## 4. Configure your platform
Paste the generated template into your affiliate platform as the campaign's destination or tracking URL.
Your platform must replace its own macros before it sends the click to Linkrunner. For example, the final request should contain real values:
```text theme={null}
https://track.example.com/?c=CAMPAIGN_ID&tid=a1b2c3d4e5&s2=campaign_88
```
It should not reach Linkrunner with placeholders such as `{click_id}` or `{campaign_id}` still present.
## 5. Test before launch
1. Generate a test click from your affiliate platform.
2. Confirm the final URL contains a unique, resolved click ID.
3. Check that the link redirects to the advertiser's intended destination.
4. Confirm the click appears under the campaign in Linkrunner.
5. Use [Postbacks and Macros](/affiliate-partners/postbacks) to test install and event postbacks before increasing traffic.
## Troubleshooting
Your affiliate account does not have an active connection to that project. Ask the advertiser or Linkrunner support to connect the project to your approved affiliate account.
The link was not created under your affiliate account. Open the advertiser project in the affiliate dashboard, then create a new link from **All Campaigns → Create Link**.
Do not add the parameter manually. Contact Linkrunner support to update the template configuration for your affiliate account, then create a new campaign link.
Your affiliate platform did not substitute its macros. Check the macro syntax configured on your platform before sending more traffic. The final URL must contain resolved values instead of placeholders such as `{click_id}` or `{campaign_id}`.
Confirm that every click sends a unique value in the generated template's click ID parameter. See [Tracking Links](/affiliate-partners/tracking-links) for examples.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Affiliate Partner Integration
Source: https://docs.linkrunner.io/affiliate-partners/overview
Send Linkrunner your clicks and receive server-to-server postbacks on installs and in-app events
## Overview
Linkrunner is a Mobile Measurement Partner (MMP). When you run traffic to an advertiser who uses Linkrunner, the integration works in two directions:
1. **You send us clicks.** You point your traffic at a Linkrunner tracking link and append your own identifiers (most importantly your click ID). Linkrunner records the click and uses it to attribute installs.
2. **We send you postbacks.** When an install or a post-install event is attributed to your click, Linkrunner fires a server-to-server postback to your endpoint with the data filled into the macros you define.
This is the same model used across the industry, so if you have integrated with other MMPs the flow will feel familiar.
New affiliate partner? [Create your affiliate account](/affiliate-partners/create-account) and submit your workspace for approval first.
```
Your traffic
│ click (you append tid + s2/s3/s4 params)
▼
Linkrunner tracking link ──► install attributed to your click
│
│ postback (we fill your macros: click_id, event_name, revenue, ...)
▼
Your endpoint
```
## How attribution works
The link between a click and a conversion is your **click ID**. You generate a unique ID for every click and pass it to Linkrunner on the tracking link as `tid`. When a conversion is attributed back to that click, Linkrunner returns the same value in the `{click_id}` and `{tid}` macros on the postback. You match the postback to your original click using that value.
Pass a unique `tid` on every click. Without it, Linkrunner cannot echo an identifier back to you on the postback, and you cannot reconcile conversions against your clicks.
## What you can receive postbacks for
| Conversion | When it fires |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Install** | A new app install is attributed to your click. |
| **Signup** | The user completes registration, as defined by the advertiser. |
| **In-app event** | A post-install event the advertiser tracks (for example a purchase or a custom event). Revenue and currency are included when the event carries them. |
The advertiser controls which of their events map to a postback to you and whether revenue is shared. You provide one postback URL template per conversion you want to receive.
## Next steps
Create your workspace and submit it for approval.
Create a link from the affiliate dashboard with your template parameters included.
The parameters included in your click tracking links.
The macros Linkrunner fills into your postback URLs, with examples.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Postbacks and Macros
Source: https://docs.linkrunner.io/affiliate-partners/postbacks
The macros Linkrunner fills into your postback URLs, with examples for each conversion
## Overview
When a conversion is attributed to your click, Linkrunner sends a server-to-server postback to your endpoint. You give Linkrunner a postback URL that contains macros. Linkrunner replaces each macro with the real value and sends the request.
You provide one URL template per conversion you want to receive (install, signup, in-app event). Linkrunner sends the request as an HTTP `GET`.
## Allowlist Linkrunner postback IPs
If your endpoint restricts incoming traffic by source IP, allowlist both Linkrunner production egress addresses:
```
8.234.94.188/32
8.234.80.204/32
```
Postbacks may originate from either address. Add both before testing or activating your integration.
These are postback URL macros. They are different from the macros your
platform may use in the click tracking link. Your platform must resolve click
macros before sending traffic to Linkrunner.
## Click tracking link
The Linkrunner dashboard shows a click tracking link for your campaign. For affiliate campaigns, the click link uses Linkrunner's click parameters:
```
https://track.example.com/?c=OgWmhiSXhG&tid={click_id}&s2={campaign_id}&s3={publisher_id}&gaid={gaid}
```
Your platform resolves the placeholders before redirecting the user to Linkrunner. The actual click should look like this:
```
https://track.example.com/?c=OgWmhiSXhG&tid=a1b2c3d4e5&s2=camp_88&s3=pub_4471&gaid=38400000-8cf0-11bd-b23e-10b96e40000d
```
| Parameter | Use |
| ---------------- | ------------------------------------------------------------------------------------------------ |
| `c` | Linkrunner campaign identifier. Keep the value from the dashboard. |
| `tid` | Your click ID. Linkrunner returns this in postbacks as `{click_id}` and `{tid}`. |
| `s2`, `s3`, `s4` | Optional passthrough values. Linkrunner returns them in postbacks as `{s2}`, `{s3}`, and `{s4}`. |
| `gaid` | Android Google Advertising ID, when available. |
| `idfa` | iOS advertising ID, when available. |
Use these parameter names in the URL. For example, use `tid={click_id}` instead of `utm_id={click_id}`, and use `s2={campaign_id}` instead of `cc={campaign_id}`.
Do not add postback-only values such as `postbackKey`, `event`, `event_name`, or `crc` to the click tracking link. If your postback endpoint requires a secret key, configure it privately with Linkrunner.
## Macro syntax
Macros are written in lowercase inside curly braces:
```
{macro_name}
```
Rules:
* Linkrunner replaces every macro it recognizes with the conversion's value.
* Values are URL-encoded automatically. Do not encode the macros yourself.
* A macro with no value for a given conversion is replaced with an empty string. For example, `{idfa}` is empty on an Android install.
* Text that is not a recognized macro is left untouched, so static query parameters are safe.
Only use the postback macros listed below in your postback URL template.
Linkrunner leaves unsupported campaign, event, platform, sub, and secret
placeholders unchanged.
## Macro reference
### Identifiers
| Macro | Description |
| -------------- | -------------------------------------------------------------------------------------------- |
| `{click_id}` | The `tid` value you sent on the tracking link. Use this to match the postback to your click. |
| `{tid}` | Same value as `{click_id}`. |
| `{install_id}` | Linkrunner's unique identifier for the install. |
### Event
| Macro | Description |
| -------------- | -------------------------------------------------------------------------------------------- |
| `{event_name}` | The conversion name: `install`, `signup`, or the in-app event name (for example `purchase`). |
| `{event}` | Same value as `{event_name}`. |
| `{timestamp}` | Time when Linkrunner builds the postback, as a Unix timestamp in seconds. |
### Revenue
| Macro | Description |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `{revenue}` | Revenue amount in major currency units, with two decimal places (for example `9.99`). Populated only on events that carry revenue. |
| `{sale_amount}` | Same value as `{revenue}`. |
| `{amount}` | Same value as `{revenue}`. |
| `{currency}` | ISO 4217 currency code (for example `USD`, `INR`). |
`{revenue}` is in major currency units, not minor units. `9.99` means 9.99,
not 9.99 cents. Parse it as a decimal amount.
### Device
| Macro | Description |
| -------- | -------------------------------- |
| `{gaid}` | Google Advertising ID (Android). |
| `{idfa}` | iOS Advertising ID (Apple). |
### Click passthrough
| Macro | Description |
| ------ | --------------------------------------------- |
| `{s2}` | The `s2` value you sent on the tracking link. |
| `{s3}` | The `s3` value you sent on the tracking link. |
| `{s4}` | The `s4` value you sent on the tracking link. |
### Static values
If your endpoint needs a fixed query parameter, add it as plain text in the URL template:
```
https://partner.example.com/postback?tid={click_id}&source=linkrunner
```
Do not use `{secret.postbackKey}`. If your endpoint requires a secret key, configure it privately with Linkrunner so it is not shown in dashboard tracking links or public examples.
## Examples
### Install postback
Your template:
```
https://partner.example.com/postback?tid={click_id}&event={event_name}&gaid={gaid}&s2={s2}&ts={timestamp}
```
What Linkrunner sends:
```
https://partner.example.com/postback?tid=a1b2c3d4e5&event=install&gaid=38400000-8cf0-11bd-b23e-10b96e40000d&s2=camp_88&ts=1751021400
```
### Signup postback
```
https://partner.example.com/postback?tid={click_id}&event={event_name}&s2={s2}&ts={timestamp}
```
### Purchase postback with revenue
Your template:
```
https://partner.example.com/postback?tid={click_id}&event={event_name}&amount={revenue}¤cy={currency}&ts={timestamp}
```
What Linkrunner sends:
```
https://partner.example.com/postback?tid=a1b2c3d4e5&event=purchase&amount=9.99¤cy=USD&ts=1751025000
```
## Deduplication
Linkrunner retries a postback if your endpoint does not respond with a success status. Treat postbacks as at-least-once. Deduplicate on your click ID plus event name, or use a fixed value you send in `s4` on the click link.
## Formatting reference
| Value | Format |
| -------------------------- | ------------------------------------------ |
| Timestamps (`{timestamp}`) | Unix seconds. |
| Revenue (`{revenue}`) | Decimal, two places, major currency units. |
| Currency (`{currency}`) | ISO 4217 three-letter code. |
| All values | URL-encoded. |
## Troubleshooting
**A macro arrives empty.**
The value was not available for that conversion. For example, `{idfa}` is empty on Android, and `{revenue}` is `0` on events without revenue. Device advertising IDs depend on the value being present on the click and on user consent.
**A macro arrives as literal text.**
That macro is not supported by the current Linkrunner postback renderer. Use one of the macros listed on this page.
**You receive the same conversion twice.**
Postbacks are at-least-once. Deduplicate on your click ID plus event name, or use a fixed value you send in `s4` on the click link.
**Revenue looks 100x too large or too small.**
`{revenue}` is in major units (`9.99`), not minor units. Do not multiply or divide by 100.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Tracking Links
Source: https://docs.linkrunner.io/affiliate-partners/tracking-links
Parameters you append to your Linkrunner click tracking links
## Overview
Create each campaign link from **All Campaigns → Create Link** in the affiliate dashboard. Linkrunner generates a tracking-link template with the parameters configured for your affiliate account.
Copy the exact template into your platform. Do not build the URL or add parameters manually. A generated template may look like this:
```
https://track.example.com/?c=CAMPAIGN_ID&tid={click_id}&s2={campaign_id}
```
The domain belongs to the advertiser. `c` is the Linkrunner campaign identifier. Values such as `{click_id}` and `{campaign_id}` are your platform's macros, using the syntax configured for your affiliate account.
Your platform replaces those macros before it sends the click to Linkrunner. Linkrunner does not expand them for you.
## The two forms of the link
The same link exists in two states, and mixing them up is the most common cause of missing attribution.
**The template you configure**, carrying your macros:
```
https://track.example.com/?c=OgWmhiSXhG&tid={click_id}&s2={campaign_id}
```
**The URL that actually reaches Linkrunner**, after your platform substitutes them:
```
https://track.example.com/?c=OgWmhiSXhG&tid=a1b2c3d4e5&s2=camp_88
```
Macro syntax varies by platform. `{click_id}`, `{TID}`, `##CLICKID##` and `[clickid]` are all common. Use whatever your own platform expands; Linkrunner never expands it for you.
Your platform must replace its own click macros before the click reaches Linkrunner. If the final URL still contains a value like `{click_id}` or `{campaign_id}`, Linkrunner stores it as text and the conversion cannot be matched back to your click. Linkrunner only fills macros in the server-to-server postback URL.
## Parameters
You fill each parameter with your own value at click time.
### Required
| Parameter | Description |
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tid` | A unique ID you generate for every click. Linkrunner returns this value in the `{click_id}` and `{tid}` postback macros, so you can match conversions back to clicks. |
### Passthrough values
| Parameter | Description |
| --------- | --------------------------------------------------------------------------------------------------------- |
| `s2` | Optional campaign, source, or publisher value. Linkrunner returns this in the `{s2}` postback macro. |
| `s3` | Optional ad set, placement, or sub-publisher value. Linkrunner returns this in the `{s3}` postback macro. |
| `s4` | Optional deduplication or extra tracking value. Linkrunner returns this in the `{s4}` postback macro. |
### Device and request
Pass device advertising IDs when you have them. They improve match rates, especially for device-ID matching.
| Parameter | Description |
| ----------- | ------------------------------------------------------------------------------------- |
| `gaid` | Google Advertising ID (Android). |
| `idfa` | iOS Advertising ID (Apple). |
| `device_id` | Optional fallback. Linkrunner treats it as GAID or IDFA based on the device platform. |
URL-encode every value before appending it. Linkrunner stores values as received.
## What not to put in the tracking link
Do not add postback-only values to the tracking link.
| Value | Where it belongs |
| ------------------------------------------ | ---------------------------------------------------------------------- |
| `postbackKey` or another secret key | Your postback URL template, not the user-facing tracking link. |
| `{event_name}` | The postback URL template. There is no event name at click time. |
| `{revenue}`, `{sale_amount}`, `{currency}` | The postback URL template. These values exist only after a conversion. |
## Example
A complete click tracking link:
```
https://track.example.com/?c=OgWmhiSXhG&tid=a1b2c3d4e5&s2=camp_88&s3=pub_4471&gaid=38400000-8cf0-11bd-b23e-10b96e40000d
```
## Troubleshooting
**Conversions arrive with an empty click ID.**
You did not send `tid` on the click, or the value was empty. Confirm the final clicked URL includes `tid=` with a unique value.
**The final URL still shows `{click_id}` or another macro.**
Your platform did not replace that macro before redirecting the user. Replace it in your platform first, then send the resolved value to Linkrunner.
**Installs are not attributed to your clicks.**
Check that traffic actually passes through the Linkrunner tracking link and that the campaign identifier (`c`) is correct. If you rely on device-ID matching, make sure `gaid` or `idfa` is present on the click.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Set Up Your Agency Workspace
Source: https://docs.linkrunner.io/agency-partners/accept-invitation
Accept a customer's invitation, add your agency details, and get your workspace approved
A customer invites you before you have anything on Linkrunner. The invitation email is your starting point, and it names the project you are being invited to run campaigns for.
You need your agency name, a logo, a short description, your website, and a contact email. Everything is required, and you can change all of it later from **Agency Settings**.
## 1. Open the invitation
The email subject reads **"\ has invited you to run their campaigns on Linkrunner"** and names the project you are being invited to. Click the setup link inside it.
The link is valid for 14 days. If it has expired, ask your customer to send a new one.
## 2. Sign in with the invited address
The landing page names the agency you are joining and the address the invitation was sent to. Click **Continue**.
Choose **Continue with Google** or enter your **Work Email** to get a sign-in link. Use the address the invitation was sent to. Any other address is refused, because the invitation is tied to that one.
Nothing is created until you follow the link and sign in. If you were not expecting the invitation, you can ignore the email.
## 3. Add your agency details
A PNG, JPG, or WebP file up to 5 MB. Customers see it when they pick your agency.
The registered name of your company, as customers should recognise it.
At least 20 characters on what your agency specialises in. This appears next to your name in the customer's agency directory.
Click **Continue** to move to your contact details.
## 4. Add your contact details
Enter your **Website** and a **Contact email**. Use an address your team monitors, because it is the address customers reach you on.
Click **Complete setup**.
## 5. Wait for review
Linkrunner reviews new agency workspaces. Until that is done your workspace exists but has nothing in it, and the **We are reviewing your workspace** screen is what you see.
You can close the page. Linkrunner emails you either way, and the customers who invited you appear in your workspace as soon as it is approved.
## 6. Start working
After approval, sign in at [dashboard.linkrunner.io](https://dashboard.linkrunner.io) and pick the customer's project from the project picker. From there you can create campaigns and track their performance.
Every customer that engages you afterwards lands in this same workspace. You never set it up again.
## Troubleshooting
**The invitation link expired.**
Invitations are valid for 14 days. Ask your customer to send a new one from **Settings → Members**.
**"This invitation belongs to a different email".**
You are signed in as someone else. Click **Sign out and use another account**, then sign in with the address the invitation was sent to.
**"This invitation has already been used".**
If you accepted it before, sign in with the invited address and you land back in your workspace setup. Otherwise ask your customer for a new invitation.
**Continue is disabled on the details step.**
Add a logo, an agency name of at least 2 characters, and a description of at least 20 characters.
**Complete setup is disabled.**
Enter a full website URL, including `https://`, and a valid contact email.
**Your workspace still says it is being reviewed.**
Linkrunner has not finished the review. You get an email when it is done.
**Your workspace was not approved, or is suspended.**
Contact [support@linkrunner.io](mailto:support@linkrunner.io).
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Invite an Agency
Source: https://docs.linkrunner.io/agency-partners/invite-an-agency
Bring an agency onto your project so they can create and measure campaigns for you
Engage an agency when you want someone outside your company to run campaigns in your Linkrunner project. They get a workspace of their own rather than a seat in your account, so you never share your team's access with them.
Only project admins can invite an agency.
## 1. Open the Members page
Open **Settings** (1), go to **Members** (2), then click **Invite Agency** (3).
## 2. Pick an agency, or invite a new one
The list holds every agency already set up on Linkrunner. Search by name, description, or website, tick one or more, then click **Invite agency**.
An agency you have already engaged shows **Invited** or **Active** instead of a checkbox, so it cannot be invited twice.
If your agency is not on Linkrunner yet, click **Invite them by email** at the bottom of the list, enter the address, then click **Send Invite**.
Send it to whoever should own the workspace. They set it up and invite the rest of their team themselves.
## 3. Track the engagement
Agencies appear in the members table underneath your people, marked **Agency**.
| Label | Meaning |
| ---------------------- | ------------------------------------------------------------------------------------------------ |
| **Agency · Invited** | Nobody at the agency has accepted yet, or their workspace is still being reviewed by Linkrunner. |
| **Agency** | Active. They have access to this project. |
| **Agency · Suspended** | Linkrunner has suspended the workspace. Their access is paused, and your engagement is kept. |
An agency invited by email needs to complete its workspace setup and pass Linkrunner's review before it becomes active. A new agency will not have access on the day you invite it.
## End an engagement
Click the remove icon on the agency's row, then **End Engagement**.
Everyone at that agency loses access to this project immediately. The campaigns they created stay with your project, along with all of their historical data. The agency's other customers are unaffected.
## Troubleshooting
**The Invite Agency button is missing.**
You need to be an admin on the project.
**"The agency list could not be loaded for this project".**
You are viewing the project as a partner rather than as an admin. Agencies are managed by a project admin.
**The invitation was refused as a duplicate.**
That address already belongs to an agency, or already has a pending invitation for this project. The error message names the company involved.
**The agency says they cannot see your campaigns.**
They only see campaigns their own agency created. Campaigns you or another partner created are never in their workspace, by design.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Agency Settings and Team
Source: https://docs.linkrunner.io/agency-partners/manage-workspace
Edit your agency details, invite your teammates, and work across several customers from one workspace
Your agency workspace lives at [dashboard.linkrunner.io/agency/settings](https://dashboard.linkrunner.io/agency/settings). It has two tabs: **Agency Details** and **Members**.
## Agency details
Click **Edit** to change your logo, agency name, description, website, or contact email, then **Save**.
Customers see your logo, name, and description in the agency directory when they choose who to engage. Keep the description current, because it is what a customer reads before inviting you.
## Your team
Open the **Members** tab to see everyone in your agency workspace.
Click **Invite**, enter their **Work Email**, then click **Send Invite**.
They get an invitation link and sign in with that address. Their row moves from **Invited** to **Active**.
An invitation that was never accepted has a **Resend** action, which sends a fresh link. An active member can be removed.
Everyone in your agency workspace gets access to every customer you are engaged on. There are no per-customer roles inside an agency.
Removing a member cuts their access to all of your customers' projects immediately. The campaigns they created stay with the customer.
## Working across customers
Each customer that engages you appears in your project picker. Switch between them there, and the campaigns, installs, and events on screen follow the customer you have selected.
You only see the campaigns your agency created. A customer's own campaigns, and those of their other partners, are not in your workspace.
## When an engagement ends
A customer can end an engagement from their **Settings → Members** page. When they do:
* The project disappears from your workspace, for everyone at your agency.
* The campaigns you created stay with the customer.
* Your workspace, your team, and your other customers are untouched.
## Troubleshooting
**A teammate never got their invitation.**
Open **Members**, find their **Invited** row, and click **Resend**. That sends a new link and invalidates the old one.
**A customer's project is missing from the picker.**
Either the engagement was ended, or your workspace is suspended. Check [dashboard.linkrunner.io/agency/settings](https://dashboard.linkrunner.io/agency/settings). If you land on a screen saying the workspace is suspended, contact support.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Agency Partner Integration
Source: https://docs.linkrunner.io/agency-partners/overview
Run campaigns for Linkrunner customers from an agency workspace, with your own team and your own campaign attribution
## Overview
An **agency workspace** lets you build and measure campaigns inside a customer's Linkrunner project without being a member of their company account.
You get one workspace, no matter how many Linkrunner customers you work with. Each customer that engages you appears in it, and you switch between them from the project picker.
There is no agency sign up page. A customer's invitation is the only way in, and the invitation is also the vetting. See [Set up your agency workspace](/agency-partners/accept-invitation).
## How an engagement starts
```
Customer invites you (Settings → Members → Invite Agency)
│
▼
You set up your workspace (name, logo, description, contact details)
│
▼
Linkrunner reviews it (you get an email either way)
│
▼
Their project appears in your workspace
```
A customer who engages an agency that is already on Linkrunner skips the first two steps. The project shows up in that agency's existing workspace straight away.
## What you can see
Your workspace is scoped to the campaigns you run. Campaigns you create are attributed to your agency, so a customer's other partners never see them, and you never see theirs.
## Next steps
Accept an invitation and complete your agency details.
Edit your details, invite teammates, and switch between customers.
For customers: bring an agency onto your project.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# AI Agent Skills
Source: https://docs.linkrunner.io/ai-agent-skills
Let your AI coding agent integrate the Linkrunner SDK and deep linking for you
If you use an AI coding agent (Claude Code, Cursor, GitHub Copilot, or Windsurf),
you can install a **Linkrunner skill** and have your agent do the integration for
you, including the deep-linking setup that is usually the trickiest part.
## Install
Run this in your app's repo. The installer detects which agent your project is
set up for and writes the skill in that agent's native format.
```bash theme={null}
npx @linkrunner/skills list # see every supported platform
npx @linkrunner/skills add flutter # install the skill for your platform
```
Then ask your agent, in its chat:
> integrate Linkrunner
or
> set up Linkrunner deep links
The agent picks up the skill, inspects your project, adds the SDK, wires up
initialization and user identification, configures deep linking, and runs a
validator to confirm your Universal Links / App Links actually verify.
## Supported platforms
| Platform | Command |
| ---------------- | ----------------------------------------- |
| Flutter | `npx @linkrunner/skills add flutter` |
| React Native | `npx @linkrunner/skills add react-native` |
| Expo | `npx @linkrunner/skills add expo` |
| iOS (native) | `npx @linkrunner/skills add ios` |
| Android (native) | `npx @linkrunner/skills add android` |
| Capacitor | `npx @linkrunner/skills add capacitor` |
| Cordova | `npx @linkrunner/skills add cordova` |
| Unity | `npx @linkrunner/skills add unity` |
## Beyond SDK integration
Cross-cutting skills that are not tied to one platform:
| Skill | Command |
| ------------------------------------ | -------------------------------------------------- |
| Deep link troubleshooter | `npx @linkrunner/skills add deep-links` |
| Event & revenue instrumentation | `npx @linkrunner/skills add events` |
| Webhooks receiver | `npx @linkrunner/skills add webhooks` |
| Meta Install Referrer (view-through) | `npx @linkrunner/skills add meta-install-referrer` |
The **deep link troubleshooter** diagnoses why Universal Links / App Links open the browser instead of the app. The **events** skill wires up the correct event taxonomy, revenue tracking, and server-side APIs. The **webhooks** skill writes a receiver in your backend that verifies the `linkrunner-key` header and handles install/signup payloads.
## Options
```bash theme={null}
# Force a specific agent instead of auto-detecting
npx @linkrunner/skills add ios --agent cursor
# Install into a subdirectory (monorepos)
npx @linkrunner/skills add react-native --dir ./apps/mobile
# Preview what will be written without changing anything
npx @linkrunner/skills add flutter --dry-run
```
Supported `--agent` values: `claude-code`, `cursor`, `windsurf`, `copilot`,
`agents-md`.
## What gets installed
Each skill bundles the integration steps, the deep-linking setup, the event and
revenue APIs, and a deep-link verification script. For Claude Code the skill is
written to `.claude/skills/`; for other agents it is compiled to that agent's
rules format, with the validator script placed under `.linkrunner/`.
The skills mirror this documentation, so what your agent does matches the manual
guides for each platform.
The skills are open source at
[github.com/linkrunner-labs/skills](https://github.com/linkrunner-labs/skills).
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Amplitude
Source: https://docs.linkrunner.io/analytics-integrations/amplitude
Connect Linkrunner with Amplitude to import attribution data and understand your user acquisition campaigns
Linkrunner integrates with Amplitude to automatically send attribution data, helping you understand which campaigns are driving user acquisition and engagement.
## How it works
Every time a user is attributed by Linkrunner, we automatically send a request to Amplitude to update the corresponding user's profile with custom properties:
* `lr_campaign` - The campaign identifier
* `lr_ad_network` - The ad network source
### Example
* User ID `24` came from campaign link: `https://app.linkrunner.io/?c=AXb1c2`
* In Amplitude, for user profile with ID `24`, you'll see `lr_campaign` set to `AXb1c2` and `lr_ad_network` set to an ad network source
## Prerequisites
| Requirement | Description |
| ------------------ | ------------------------------------------------------------------------ |
| Linkrunner account | A Linkrunner account is required to take advantage of this integration. |
| Amplitude account | An Amplitude account with API access. |
| iOS or Android app | This integration supports iOS and Android apps. |
| Linkrunner SDK | You must have the [Linkrunner SDK](/introduction) installed in your app. |
## Steps to setup
First, you need to get your API key from your Amplitude dashboard.
1. In your Amplitude dashboard, navigate to **Settings** > **Projects** > **API Keys**
2. Copy your **API Key**
3. In Linkrunner, navigate to **Integrations** from the left-hand panel
4. Under **Analytics**, click **Configure** for Amplitude
5. Enter your **API Key**
To properly link users between Linkrunner and Amplitude, you need to pass the correct identifier in the `signup` function.
**Important:** If you are using the `identify` function of the Amplitude SDK to set a user ID, pass the same user ID in the `userData` parameter of the Linkrunner `signup` function. If you are not using `identify`, pass the `distinct_id` or `device_id` instead.
```kotlin theme={null}
import io.linkrunner.sdk.LinkRunner
import io.linkrunner.sdk.models.request.UserDataRequest
val userData = UserDataRequest(
id = "123", // Your user ID (same as Amplitude identify)
// ...other user fields
amplitudeDeviceId = "AMPLITUDE_DEVICE_ID", // Required if not using identify
)
LinkRunner.getInstance().signup(userData = userData)
```
```swift theme={null}
import LinkrunnerKit
let userData = UserData(
id: "123", // Your user ID (same as Amplitude identify)
// ...other user fields
amplitudeDeviceId: "AMPLITUDE_DEVICE_ID" // Required if not using identify
)
try await LinkrunnerSDK.shared.signup(userData: userData)
```
```javascript theme={null}
import linkrunner from "rn-linkrunner";
await linkrunner.signup({
user_data: {
id: "123", // Your user ID (same as Amplitude identify)
// ...other user fields
amplitude_device_id: "AMPLITUDE_DEVICE_ID", // Required if not using identify
},
data: {},
});
```
```dart theme={null}
import 'package:linkrunner/main.dart';
await LinkRunner().signup(
userData: LRUserData(
id: '123', // Your user ID (same as Amplitude identify)
// ...other user fields
amplitudeDeviceId: 'AMPLITUDE_DEVICE_ID', // Required if not using identify
),
data: {},
);
```
You can get the `amplitudeDeviceId` from the Amplitude SDK in your app.
Use the **Sync** feature in the following scenarios:
* First time after adding your Amplitude API Key
* If you see discrepancies in attribution data on Amplitude profiles
To sync, click the **Sync with Amplitude** button in the Linkrunner integration settings. This process may take a while depending on the number of users.
## Viewing Attribution Data
After the integration is complete, you can view the attribution data in Amplitude:
1. Navigate to a user profile in the Amplitude dashboard
2. Look for the user properties section
3. You'll see `lr_campaign` and `lr_ad_network` attributes with the corresponding values
This data can be used to:
* Create cohorts based on acquisition source
* Personalize experiences based on campaign origin
* Analyze user behavior by acquisition channel
# Braze
Source: https://docs.linkrunner.io/analytics-integrations/braze
Connect Linkrunner with Braze to import attribution data and understand your user acquisition campaigns
Linkrunner integrates with Braze to automatically send attribution data, helping you understand which campaigns are driving user acquisition and engagement.
## How it works
Every time a user is attributed by Linkrunner, we automatically send a request to Braze to update the corresponding user's profile with custom attributes:
* `lr_campaign` - The campaign identifier
* `lr_ad_network` - The ad network source
### Example
* User ID `24` came from campaign link: `https://app.linkrunner.io/?c=AXb1c2`
* In Braze, for user profile with ID `24`, you'll see `lr_campaign` set to `AXb1c2` and `lr_ad_network` set to an ad network source
The data will be visible in the **Custom Attributes** section of the user profile in the Braze dashboard.
## Prerequisites
| Requirement | Description |
| ------------------ | ------------------------------------------------------------------------ |
| Linkrunner account | A Linkrunner account is required to take advantage of this integration. |
| Braze account | A Braze account with API access. |
| iOS or Android app | This integration supports iOS and Android apps. |
| Linkrunner SDK | You must have the [Linkrunner SDK](/introduction) installed in your app. |
## Steps to setup
First, you need to create an API key in your Braze dashboard with the required permissions.
1. In your Braze dashboard, navigate to **Settings** > **Setup and Testing** > **APIs and Identifiers** > **API Keys**
2. Click **Create API Key**
3. Under permissions, select the following under **User Data**:
* `users.track`
* `users.export.ids`
4. Save the API key
5. Copy the **API Key** and **REST Endpoint** from the Braze dashboard
6. In Linkrunner, navigate to **Integrations** from the left-hand panel
7. Under **Analytics**, click **Configure** for Braze
8. Enter your **API Key** and **REST Endpoint** (e.g., `https://rest.iad-01.braze.com`)
To properly link users between Linkrunner and Braze, you need to pass the correct identifier in the `signup` function.
**Important:** If you are using the `changeUser` function of the Braze SDK, pass the same user ID in the `userData` parameter of the Linkrunner `signup` function. If you are not using `changeUser`, pass the `brazeDeviceId` instead.
```kotlin theme={null}
import io.linkrunner.sdk.LinkRunner
import io.linkrunner.sdk.models.request.UserDataRequest
val userData = UserDataRequest(
id = "123", // Your user ID
// ...other user fields
brazeDeviceId = "BRAZE_DEVICE_ID", // Required if not using changeUser
)
LinkRunner.getInstance().signup(userData = userData)
```
```swift theme={null}
import LinkrunnerKit
let userData = UserData(
id: "123", // Your user ID
// ...other user fields
brazeDeviceId: "BRAZE_DEVICE_ID" // Required if not using changeUser
)
try await LinkrunnerSDK.shared.signup(userData: userData)
```
```javascript theme={null}
import linkrunner from "rn-linkrunner";
await linkrunner.signup({
user_data: {
id: "123", // Your user ID
// ...other user fields
braze_device_id: "BRAZE_DEVICE_ID", // Required if not using changeUser
},
data: {},
});
```
```dart theme={null}
import 'package:linkrunner/main.dart';
await LinkRunner().signup(
userData: LRUserData(
id: '123', // Your user ID
// ...other user fields
brazeDeviceId: 'BRAZE_DEVICE_ID', // Required if not using changeUser
),
data: {},
);
```
You can get the `brazeDeviceId` from the Braze SDK in your app.
Use the **Sync** feature in the following scenarios:
* First time after adding your Braze credentials
* If you see discrepancies in attribution data on Braze profiles
To sync, click the **Sync with Braze** button in the Linkrunner integration settings. This process may take a while depending on the number of users.
## Viewing Attribution Data
After the integration is complete, you can view the attribution data in Braze:
1. Navigate to a user profile in the Braze dashboard
2. Look for the **Custom Attributes** section
3. You'll see `lr_campaign` and `lr_ad_network` attributes with the corresponding values
This data can be used to:
* Create segments based on acquisition source
* Personalize messaging based on campaign origin
* Analyze user behavior by acquisition channel
# CleverTap
Source: https://docs.linkrunner.io/analytics-integrations/clevertap
Connect Linkrunner with CleverTap to import attribution data and understand your user acquisition campaigns
Linkrunner integrates with CleverTap to automatically send attribution data, helping you understand which campaigns are driving user acquisition and engagement.
## How it works
Every time a user is attributed by Linkrunner, we send an install event to CleverTap's attribution API for the corresponding user profile. The profile is matched using the CleverTap ID you pass to the Linkrunner SDK (see [Map User Identifiers](#steps-to-setup)).
The event carries these attribution fields:
| Field | Description |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `event_type` | `organic_install` or `inorganic_install`, depending on whether the install was attributed to a campaign. |
| `utm_source` | The acquisition source. For inorganic installs this is the ad network (for example `meta`, `google`, or `tiktok`). For organic installs this is `organic`. |
| `utm_campaign` | The campaign name. Sent only for inorganic installs. |
| `platform` | `android` or `ios`. |
| `timestamp` | When the install occurred. |
### Example
* User came from a Meta campaign named `Summer_Sale`
* In CleverTap, that user's profile receives an `inorganic_install` event with `utm_source` set to `meta` and `utm_campaign` set to `Summer_Sale`
* An unattributed user instead receives an `organic_install` event with `utm_source` set to `organic`
## Prerequisites
| Requirement | Description |
| ------------------ | ------------------------------------------------------------------------ |
| Linkrunner account | A Linkrunner account is required to take advantage of this integration. |
| CleverTap account | A CleverTap account with API access. |
| iOS or Android app | This integration supports iOS and Android apps. |
| Linkrunner SDK | You must have the [Linkrunner SDK](/introduction) installed in your app. |
## Steps to setup
First, you need to get your credentials from CleverTap.
1. Refer to [CleverTap's API Quickstart Guide](https://developer.clevertap.com/docs/api-quickstart-guide) to get the Project ID, Project Token, and Project Passcode
2. Note your **CleverTap Region** (EU, IN, US, SG, SK, ID, or UAE)
3. In Linkrunner, navigate to **Integrations** from the left-hand panel
4. Under **Analytics**, click **Configure** for CleverTap
5. Enter your **Project ID**, **Project Token**, **Project Passcode**, and select your **Region**
To properly link users between Linkrunner and CleverTap, you need to pass the CleverTap ID in the `signup` function.
**Important:** Make sure to use CleverTap's functions correctly to retrieve the CleverTap ID and send it to the Linkrunner SDK.
```kotlin theme={null}
import io.linkrunner.sdk.LinkRunner
import io.linkrunner.sdk.models.request.UserDataRequest
val userData = UserDataRequest(
id = "123", // Your user ID
// ...other user fields
clevertapId = "CLEVERTAP_ID", // Get from CleverTap SDK
)
LinkRunner.getInstance().signup(userData = userData)
```
```swift theme={null}
import LinkrunnerKit
let userData = UserData(
id: "123", // Your user ID
// ...other user fields
clevertapId: "CLEVERTAP_ID" // Get from CleverTap SDK
)
try await LinkrunnerSDK.shared.signup(userData: userData)
```
```javascript theme={null}
import linkrunner from "rn-linkrunner";
await linkrunner.signup({
user_data: {
id: "123", // Your user ID
// ...other user fields
clevertap_id: "CLEVERTAP_ID", // Get from CleverTap SDK
},
data: {},
});
```
```dart theme={null}
import 'package:linkrunner/main.dart';
await LinkRunner().signup(
userData: LRUserData(
id: '123', // Your user ID
// ...other user fields
clevertapId: 'CLEVERTAP_ID', // Get from CleverTap SDK
),
data: {},
);
```
You can get the CleverTap ID from the CleverTap SDK in your app using the appropriate method for your platform.
Use the **Sync** feature in the following scenario:
* If you see discrepancies in attribution data on Clevertap profiles
To sync, click the **Sync with CleverTap** button in the Linkrunner integration settings. This process may take a while depending on the number of users.
## Viewing Attribution Data
After the integration is complete, you can view the attribution data in CleverTap:
1. Navigate to a user profile in the CleverTap dashboard
2. Look for the install events section
3. You'll see an `organic_install` or `inorganic_install` event with the `utm_source`, `utm_campaign`, and `platform` values described above
This data can be used to:
* Create segments based on acquisition source
* Personalize messaging based on campaign origin
* Analyze user behavior by acquisition channel
# Google Analytics (GA4)
Source: https://docs.linkrunner.io/analytics-integrations/ga4
Connect Linkrunner with Google Analytics 4 to analyze install and campaign performance
## How does it work?
When a new install occurs, Linkrunner sends the touchpoint details (media source and campaign ID) to GA4.
## Minimum SDK versions
rn-linkrunner `2.6.3`
linkrunner `^3.6.3`
`3.7.1`
`3.5.3`
## Steps to setup
### 1. Set GA4 Integration in Linkrunner
In the **Integrations** tab, under **Analytics**, click **Configure** under **Google Analytics**. Firebase App ID and a Measurement Protocol API secret will be required. These are different for iOS and Android.
To retrieve the **Firebase App ID** and **Measurement Protocol API secret**:
1. Open **Admin** (settings icon) from the bottom-left of the Google Analytics interface.
2. Under **Property** → **Data collection and modification** → **Data streams**, click your **app data stream** (iOS or Android).
3. In the **App stream details** panel, copy the **Firebase App ID**.
4. Under **Events**, click **Measurement Protocol API secrets**.
5. Create (or reuse) an API secret and copy its value.
The Firebase App ID and API secret will be different for your iOS and Android apps, so repeat these steps for each platform.
### 2. Retrieve app instance ID in Linkrunner SDK
The app instance ID uniquely identifies a specific installation of a Firebase app. This value needs to be retrieved from the Firebase SDK. After collecting this ID, pass it to `linkrunner.signup` so that Linkrunner can associate GA4 app instance IDs with installs and campaigns.
Use the following examples to pass the app instance ID:
```kotlin theme={null}
import com.google.firebase.analytics.FirebaseAnalytics
import io.linkrunner.sdk.LinkRunner
import io.linkrunner.sdk.models.request.UserDataRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
private fun onSignup() {
CoroutineScope(Dispatchers.IO).launch {
try {
val firebaseAnalytics = FirebaseAnalytics.getInstance(context)
val appInstanceId = firebaseAnalytics.appInstanceId.await() // suspend extension from Kotlin coroutines
val userData = UserDataRequest(
id = "123", // Your user ID
// ...other user fields
gaAppInstanceId = appInstanceId, // GA4 app instance ID
)
LinkRunner.getInstance().signup(
userData = userData
)
} catch (e: Exception) {
println("Error during signup with GA4 app instance ID: ${e.message}")
}
}
}
```
```swift theme={null}
import FirebaseAnalytics
import LinkrunnerKit
func onSignup() async {
do {
let appInstanceId = try await Analytics.appInstanceID()
let userData = UserData(
id: "123", // Your user ID
// ...other user fields
gaAppInstanceId: appInstanceId // GA4 app instance ID
)
try await LinkrunnerSDK.shared.signup(
userData: userData
)
} catch {
print("Error during signup with GA4 app instance ID:", error)
}
}
```
```javascript theme={null}
import analytics from "@react-native-firebase/analytics";
import linkrunner from "rn-linkrunner";
const onSignup = async () => {
try {
const gaAppInstanceId = await analytics().getAppInstanceId();
await linkrunner.signup({
user_data: {
id: "123", // Your user ID
// ...other user fields
ga_app_instance_id: gaAppInstanceId, // GA4 app instance ID
},
data: {},
});
} catch (error) {
console.error("Error during signup with GA4 app instance ID:", error);
}
};
```
```dart theme={null}
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:linkrunner/main.dart';
Future onSignup() async {
try {
final analytics = FirebaseAnalytics.instance;
final gaAppInstanceId = await analytics.appInstanceId;
await LinkRunner().signup(
userData: LRUserData(
id: '123', // Your user ID
// ...other user fields
gaAppInstanceId: gaAppInstanceId, // GA4 app instance ID
),
data: {},
);
} catch (e) {
print('Error during signup with GA4 app instance ID: $e');
}
}
```
### 3. Retrieve GA4 session ID in Linkrunner SDK
The GA4 session ID uniquely identifies a session of a Firebase app. Similar to the app instance ID, this value is retrieved from the Firebase SDK and should be passed to `linkrunner.signup` so that session-level engagement can be connected to installs and campaigns.
Use the following examples to pass the session ID (for example, `ga_session_id` or `gaSessionId`, depending on the SDK's naming convention):
```kotlin theme={null}
import com.google.firebase.analytics.FirebaseAnalytics
import io.linkrunner.sdk.LinkRunner
import io.linkrunner.sdk.models.request.UserDataRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
private fun onSignup() {
CoroutineScope(Dispatchers.IO).launch {
try {
val firebaseAnalytics = FirebaseAnalytics.getInstance(context)
val sessionId = /* Retrieve GA4 session ID from your tracking layer */
val userData = UserDataRequest(
id = "123", // Your user ID
// ...other user fields
gaSessionId = sessionId, // GA4 session ID
)
LinkRunner.getInstance().signup(
userData = userData
)
} catch (e: Exception) {
println("Error during signup with GA4 session ID: ${e.message}")
}
}
}
```
```swift theme={null}
import LinkrunnerKit
func onSignup() async {
do {
let gaSessionId = /* Retrieve GA4 session ID from your tracking layer */
let userData = UserData(
id: "123", // Your user ID
// ...other user fields
gaSessionId: gaSessionId // GA4 session ID
)
try await LinkrunnerSDK.shared.signup(
userData: userData
)
} catch {
print("Error during signup with GA4 session ID:", error)
}
}
```
```javascript theme={null}
import analytics from "@react-native-firebase/analytics";
import linkrunner from "rn-linkrunner";
const onSignup = async () => {
try {
const gaSessionId = await analytics().getSessionId();
await linkrunner.signup({
user_data: {
id: "123", // Your user ID
// ...other user fields
ga_session_id: gaSessionId, // GA4 session ID
},
data: {},
});
} catch (error) {
console.error("Error during signup with GA4 session ID:", error);
}
};
```
```dart theme={null}
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:linkrunner/main.dart';
Future onSignup() async {
try {
final analytics = FirebaseAnalytics.instance;
int? sessionId = await analytics.getSessionId();
await LinkRunner().signup(
userData: LRUserData(
id: '123', // Your user ID
// ...other user fields
gaSessionId: sessionId, // GA4 session ID
),
data: {},
);
} catch (e) {
print('Error during signup with GA4 session ID: $e');
}
}
```
### 4. Validate the GA4 Integration
To validate that data is flowing correctly between Linkrunner and GA4:
* Use [BigQuery Export](https://support.google.com/analytics/answer/9823238#zippy=%2Cin-this-article) to explore the raw event data with all associated parameters.
* Using [GA4 Explorations](https://support.google.com/analytics/answer/7579450?hl=en\&ref_topic=12153537,12153943,2986333,\&sjid=20462331341270448-EU\&visit_id=638532662145815538-1056226594\&rd=1#access-analysis\&zippy=%2Cin-this-article).
* The Traffic acquisition report helps you understand where your app visitors are coming from. You can find this under Reports > Lifecycle > Acquisition > Traffic acquisition. For more information on the Traffic acquisition report, see the [Google documentation](https://support.google.com/analytics/answer/12923437?hl=en\&sjid=5454549956257318969-NC#zippy=%2Chow-do-i-filter-out-traffic-sources-from-the-report).
BigQuery data is visible as soon as the event is received by GA4, while it can take 24–48 hours for data to reflect in GA4 reports. [Read more here](https://support.google.com/analytics/answer/11198161?hl=en).
# Mixpanel
Source: https://docs.linkrunner.io/analytics-integrations/mixpanel
Connect Linkrunner with Mixpanel to import attribution data and understand your user acquisition campaigns
Linkrunner integrates with Mixpanel to automatically send attribution data, helping you understand which campaigns are driving user acquisition and engagement.
## How it works
Every time a user is attributed by Linkrunner, we automatically send a request to Mixpanel to update the corresponding user's profile with custom properties:
* `lr_campaign` - The campaign identifier
* `lr_ad_network` - The ad network source
### Example
* User ID `24` came from campaign link: `https://app.linkrunner.io/?c=AXb1c2`
* In Mixpanel, for user profile with ID `24`, you'll see `lr_campaign` set to `AXb1c2` and `lr_ad_network` set to an ad network source
## Prerequisites
| Requirement | Description |
| ------------------ | ------------------------------------------------------------------------ |
| Linkrunner account | A Linkrunner account is required to take advantage of this integration. |
| Mixpanel account | A Mixpanel account with API access. |
| iOS or Android app | This integration supports iOS and Android apps. |
| Linkrunner SDK | You must have the [Linkrunner SDK](/introduction) installed in your app. |
## Steps to setup
First, you need to get your project token from your Mixpanel dashboard.
1. In your Mixpanel dashboard, navigate to **Project Settings** > **Overview** > **Access Keys**
2. Copy your **Project Token**
3. In Linkrunner, navigate to **Integrations** from the left-hand panel
4. Under **Analytics**, click **Configure** for Mixpanel
5. Enter your **Mixpanel Project Token**
To properly link users between Linkrunner and Mixpanel, you need to pass the correct identifier in the `signup` function.
**Important:** If you are using the `identify` function of the Mixpanel SDK to set a user ID, pass the same user ID in the `userData` parameter of the Linkrunner `signup` function. If you are not using `identify`, pass the `distinct_id` or `device_id` instead.
```kotlin theme={null}
import io.linkrunner.sdk.LinkRunner
import io.linkrunner.sdk.models.request.UserDataRequest
val userData = UserDataRequest(
id = "123", // Your user ID (same as Mixpanel identify)
// ...other user fields
mixpanelDistinctId = "MIXPANEL_DISTINCT_ID", // Required if not using identify
)
LinkRunner.getInstance().signup(userData = userData)
```
```swift theme={null}
import LinkrunnerKit
let userData = UserData(
id: "123", // Your user ID (same as Mixpanel identify)
// ...other user fields
mixpanelDistinctId: "MIXPANEL_DISTINCT_ID" // Required if not using identify
)
try await LinkrunnerSDK.shared.signup(userData: userData)
```
```javascript theme={null}
import linkrunner from "rn-linkrunner";
await linkrunner.signup({
user_data: {
id: "123", // Your user ID (same as Mixpanel identify)
// ...other user fields
mixpanel_distinct_id: "MIXPANEL_DISTINCT_ID", // Required if not using identify
},
data: {},
});
```
```dart theme={null}
import 'package:linkrunner/main.dart';
await LinkRunner().signup(
userData: LRUserData(
id: '123', // Your user ID (same as Mixpanel identify)
// ...other user fields
mixpanelDistinctId: 'MIXPANEL_DISTINCT_ID', // Required if not using identify
),
data: {},
);
```
You can get the `mixpanelDistinctId` from the Mixpanel SDK in your app.
Use the **Sync** feature in the following scenarios:
* First time after adding your Mixpanel Project Token
* If you see discrepancies in attribution data on Mixpanel profiles
To sync, click the **Sync with Mixpanel** button in the Linkrunner integration settings. You will receive an email when the sync completes.
## Viewing Attribution Data
After the integration is complete, you can view the attribution data in Mixpanel:
1. Navigate to a user profile in the Mixpanel dashboard
2. Look for the user properties section
3. You'll see `lr_campaign` and `lr_ad_network` attributes with the corresponding values
This data can be used to:
* Create cohorts based on acquisition source
* Personalize experiences based on campaign origin
* Analyze user behavior by acquisition channel
# MoEngage
Source: https://docs.linkrunner.io/analytics-integrations/moengage
Connect Linkrunner with MoEngage to import attribution data and understand your user acquisition campaigns
Linkrunner integrates with MoEngage to automatically send attribution data, helping you understand which campaigns are driving user acquisition and engagement.
## How it works
Every time a user is attributed by Linkrunner, we automatically send a request to MoEngage to update the corresponding user's profile with custom properties:
* `lr_campaign` - The campaign identifier
* `lr_ad_network` - The ad network source
### Example
* User ID `24` came from campaign link: `https://app.linkrunner.io/?c=AXb1c2`
* In MoEngage, for user profile with ID `24`, you'll see `lr_campaign` set to `AXb1c2` and `lr_ad_network` set to an ad network source
## Prerequisites
| Requirement | Description |
| ------------------ | ------------------------------------------------------------------------ |
| Linkrunner account | A Linkrunner account is required to take advantage of this integration. |
| MoEngage account | A MoEngage account with API access. |
| iOS or Android app | This integration supports iOS and Android apps. |
| Linkrunner SDK | You must have the [Linkrunner SDK](/introduction) installed in your app. |
## Steps to setup
First, you need to get your credentials from your MoEngage dashboard.
1. Log in to your MoEngage Dashboard
2. Navigate to **Settings** > **Account** > **APIs** using the left navigation menu
3. Locate the **Workspace ID**: It is displayed under the Workspace ID (formerly known as App ID) section. You can use the copy icon to grab this unique identifier.
4. Locate the **Data API Key**:
* Scroll down to the API keys section on the same page
* Find the **Data** tile
* If you have not generated one yet, click **Generate Key**
* Click the copy icon in the Data tile to copy your Data API Key
5. In Linkrunner, navigate to **Integrations** from the left-hand panel
6. Under **Analytics**, click **Configure** for MoEngage
7. Enter your **Workspace ID** and **Data API Key**
To find your data center, look at the URL in your browser tab when you're on the MoEngage dashboard.
For example, if your URL is `https://dashboard-01.moengage.com/v4/#/settings-v2/accounts/api`, then "01" is your data center.
Select the appropriate data center based on your URL:
| Data Center | Region |
| ----------- | --------- |
| DC-01 | US |
| DC-02 | EU |
| DC-03 | India |
| DC-04 | US |
| DC-05 | Singapore |
| DC-06 | Indonesia |
To properly link users between Linkrunner and MoEngage, you need to pass the user ID in the `signup` function.
**Important:** Make sure to pass the `id` in the `userData` parameter of the signup function of the SDK to match with your MoEngage user profiles.
```kotlin theme={null}
import io.linkrunner.sdk.LinkRunner
import io.linkrunner.sdk.models.request.UserDataRequest
val userData = UserDataRequest(
id = "123", // Your user ID (same as MoEngage user ID)
// ...other user fields
)
LinkRunner.getInstance().signup(userData = userData)
```
```swift theme={null}
import LinkrunnerKit
let userData = UserData(
id: "123", // Your user ID (same as MoEngage user ID)
// ...other user fields
)
try await LinkrunnerSDK.shared.signup(userData: userData)
```
```javascript theme={null}
import linkrunner from "rn-linkrunner";
await linkrunner.signup({
user_data: {
id: "123", // Your user ID (same as MoEngage user ID)
// ...other user fields
},
data: {},
});
```
```dart theme={null}
import 'package:linkrunner/main.dart';
await LinkRunner().signup(
userData: LRUserData(
id: '123', // Your user ID (same as MoEngage user ID)
// ...other user fields
),
data: {},
);
```
Use the **Sync** feature in the following scenarios:
* First time after adding your MoEngage credentials
* If you see discrepancies in attribution data on MoEngage profiles
To sync, click the **Sync with MoEngage** button in the Linkrunner integration settings. This process may take a while depending on the number of users.
## Viewing Attribution Data
After the integration is complete, you can view the attribution data in MoEngage:
1. Navigate to a user profile in the MoEngage dashboard
2. Look for the user attributes section
3. You'll see `lr_campaign` and `lr_ad_network` attributes with the corresponding values
This data can be used to:
* Create segments based on acquisition source
* Personalize messaging based on campaign origin
* Analyze user behavior by acquisition channel
# Netcore
Source: https://docs.linkrunner.io/analytics-integrations/netcore
Connect Linkrunner with Netcore to import attribution data and understand your user acquisition campaigns
Linkrunner integrates with Netcore to automatically send attribution data, helping you understand which campaigns are driving user acquisition and engagement.
## How it works
Every time a user is attributed by Linkrunner, we automatically send a request to Netcore to update the corresponding user's profile with custom properties:
* `lr_campaign` - The campaign identifier
* `lr_ad_network` - The ad network source
### Example
* User ID `24` came from campaign link: `https://app.linkrunner.io/?c=AXb1c2`
* In Netcore, for user profile with ID `24`, you'll see `lr_campaign` set to `AXb1c2` and `lr_ad_network` set to an ad network source
## Prerequisites
| Requirement | Description |
| ------------------ | ------------------------------------------------------------------------ |
| Linkrunner account | A Linkrunner account is required to take advantage of this integration. |
| Netcore account | A Netcore account with API access. |
| iOS or Android app | This integration supports iOS and Android apps. |
| Linkrunner SDK | You must have the [Linkrunner SDK](/introduction) installed in your app. |
## Steps to setup
First, you need to get your credentials from Netcore.
1. Refer to [Netcore's Getting Started guide](https://developer.netcorecloud.com/reference/getting-started) to get the API Key and API Endpoint
2. Copy the following credentials:
* **API Endpoint** (e.g., `https://netcoreapi-main-prod-us.netcorein.com`)
* **API Key**
3. In Linkrunner, navigate to **Integrations** from the left-hand panel
4. Under **Analytics**, click **Configure** for Netcore
5. Enter your **API Endpoint** and **API Key**
To properly link users between Linkrunner and Netcore, you need to pass the correct identifier in the `signup` function.
**Important:** If you are using the `setUserIdentity` function of the Netcore SDK, pass the same user ID in the `userData` parameter of the Linkrunner `signup` function. If you are not using `setUserIdentity`, pass the `netcore_device_guid` instead.
```kotlin theme={null}
import io.linkrunner.sdk.LinkRunner
import io.linkrunner.sdk.models.request.UserDataRequest
val userData = UserDataRequest(
id = "123", // Your user ID (same as Netcore setUserIdentity)
// ...other user fields
netcoreDeviceGuid = "NETCORE_DEVICE_GUID", // Required if not using setUserIdentity
)
LinkRunner.getInstance().signup(userData = userData)
```
```swift theme={null}
import LinkrunnerKit
let userData = UserData(
id: "123", // Your user ID (same as Netcore setUserIdentity)
// ...other user fields
netcoreDeviceGuid: "NETCORE_DEVICE_GUID" // Required if not using setUserIdentity
)
try await LinkrunnerSDK.shared.signup(userData: userData)
```
```javascript theme={null}
import linkrunner from "rn-linkrunner";
await linkrunner.signup({
user_data: {
id: "123", // Your user ID (same as Netcore setUserIdentity)
// ...other user fields
netcore_device_guid: "NETCORE_DEVICE_GUID", // Required if not using setUserIdentity
},
data: {},
});
```
```dart theme={null}
import 'package:linkrunner/main.dart';
await LinkRunner().signup(
userData: LRUserData(
id: '123', // Your user ID (same as Netcore setUserIdentity)
// ...other user fields
netcoreDeviceGuid: 'NETCORE_DEVICE_GUID', // Required if not using setUserIdentity
),
data: {},
);
```
You can get the `netcoreDeviceGuid` from the Netcore SDK in your app.
Use the **Sync** feature in the following scenarios:
* First time after adding your Netcore credentials
* If you see discrepancies in attribution data on Netcore profiles
To sync, click the **Sync with Netcore** button in the Linkrunner integration settings. This process may take a while depending on the number of users.
## Viewing Attribution Data
After the integration is complete, you can view the attribution data in Netcore:
1. Navigate to a user profile in the Netcore dashboard
2. Look for the user attributes section
3. You'll see `lr_campaign` and `lr_ad_network` attributes with the corresponding values
This data can be used to:
* Create segments based on acquisition source
* Personalize messaging based on campaign origin
* Analyze user behavior by acquisition channel
# PostHog
Source: https://docs.linkrunner.io/analytics-integrations/posthog
Connect Linkrunner with PostHog to import attribution data and understand your user acquisition campaigns
Linkrunner integrates with PostHog to automatically send attribution data, helping you understand which campaigns are driving user acquisition and engagement.
## How it works
Every time a user is attributed by Linkrunner, we automatically send a request to PostHog to update the corresponding user's profile with custom properties:
* `lr_campaign` - The campaign identifier
* `lr_ad_network` - The ad network source
### Example
* User ID `24` came from campaign link: `https://app.linkrunner.io/?c=AXb1c2`
* In PostHog, for user profile with ID `24`, you'll see `lr_campaign` set to `AXb1c2` and `lr_ad_network` set to an ad network source
## Prerequisites
| Requirement | Description |
| ------------------ | ------------------------------------------------------------------------ |
| Linkrunner account | A Linkrunner account is required to take advantage of this integration. |
| PostHog account | A PostHog account with access to the Project Token. |
| iOS or Android app | This integration supports iOS and Android apps. |
| Linkrunner SDK | You must have the [Linkrunner SDK](/introduction) installed in your app. |
## Steps to setup
First, you need to get your Project Token from your PostHog dashboard.
1. In your PostHog dashboard, navigate to **Settings** > **General** > **Project Token**
2. Copy your **Project Token** (starts with `phc_`)
3. In Linkrunner, navigate to **Integrations** from the left-hand panel
4. Under **Analytics**, click **Configure** for PostHog
5. Enter your **Project Token**
To properly link users between Linkrunner and PostHog, you need to pass the correct identifier in the `signup` function.
**Important:** If you are using the `identify` function of the PostHog SDK to set a user ID, pass the same user ID in the `userData` parameter of the Linkrunner `signup` function. If you are not using `identify`, pass the `distinct_id` or `device_id` instead.
```kotlin theme={null}
import io.linkrunner.sdk.LinkRunner
import io.linkrunner.sdk.models.request.UserDataRequest
val userData = UserDataRequest(
id = "123", // Your user ID (same as PostHog identify)
// ...other user fields
posthogDistinctId = "POSTHOG_DISTINCT_ID", // Required if not using identify
)
LinkRunner.getInstance().signup(userData = userData)
```
```swift theme={null}
import LinkrunnerKit
let userData = UserData(
id: "123", // Your user ID (same as PostHog identify)
// ...other user fields
posthogDistinctId: "POSTHOG_DISTINCT_ID" // Required if not using identify
)
try await LinkrunnerSDK.shared.signup(userData: userData)
```
```javascript theme={null}
import linkrunner from "rn-linkrunner";
await linkrunner.signup({
user_data: {
id: "123", // Your user ID (same as PostHog identify)
// ...other user fields
posthog_distinct_id: "POSTHOG_DISTINCT_ID", // Required if not using identify
},
data: {},
});
```
```dart theme={null}
import 'package:linkrunner/main.dart';
await LinkRunner().signup(
userData: LRUserData(
id: '123', // Your user ID (same as PostHog identify)
// ...other user fields
posthogDistinctId: 'POSTHOG_DISTINCT_ID', // Required if not using identify
),
data: {},
);
```
You can get the `posthogDistinctId` from the PostHog SDK in your app.
Use the **Sync** feature in the following scenarios:
* First time after adding your PostHog Project Token
* If you see discrepancies in attribution data on PostHog profiles
To sync, click the **Sync with PostHog** button in the Linkrunner integration settings. This process may take a while depending on the number of users.
## Viewing Attribution Data
After the integration is complete, you can view the attribution data in PostHog:
1. Navigate to a user profile in the PostHog dashboard
2. Look for the person properties section
3. You'll see `lr_campaign` and `lr_ad_network` attributes with the corresponding values
This data can be used to:
* Create cohorts based on acquisition source
* Personalize experiences based on campaign origin
* Analyze user behavior by acquisition channel
# RevenueCat
Source: https://docs.linkrunner.io/analytics-integrations/revenuecat
Connect RevenueCat to track revenue and subscription data in Linkrunner with automatic webhook-based event mapping
Linkrunner integrates with RevenueCat to automatically track revenue and subscription data. Once configured, RevenueCat will send webhook events whenever subscription or revenue events occur. Linkrunner will automatically match these events with attributed users to provide you with revenue analytics per campaign.
## How it works
Unlike other analytics integrations that push data from Linkrunner, RevenueCat uses a **webhook-based** approach:
1. RevenueCat sends webhook events to Linkrunner whenever a subscription or revenue event occurs
2. Linkrunner automatically matches these events with attributed users
3. RevenueCat events are automatically mapped to Linkrunner revenue types
4. Events are categorized into three status types based on payment outcome
## Prerequisites
| Requirement | Description |
| ------------------ | ------------------------------------------------------------------------ |
| Linkrunner account | A Linkrunner account is required to take advantage of this integration. |
| RevenueCat account | A RevenueCat account with webhook access. |
| iOS or Android app | This integration supports iOS and Android apps. |
| Linkrunner SDK | You must have the [Linkrunner SDK](/introduction) installed in your app. |
## Steps to setup
In your Linkrunner dashboard, click on **Integrations** from the left-hand panel. Under the **Analytics** section, find **RevenueCat** and click **Configure**.
In the RevenueCat configuration panel, you'll see the **Webhook URL**. Copy this URL — you'll need to add it to your RevenueCat dashboard.
The webhook URL is: `https://api.linkrunner.io/webhook/revenuecat`
Click **Generate Authorization Token** in the Linkrunner configuration panel. Copy the generated token — you'll need this for the authorization field in RevenueCat.
Now head to your RevenueCat dashboard to set up the webhook:
1. In your [RevenueCat dashboard](https://app.revenuecat.com), navigate to the **Integrations** page from the left sidebar
2. Under **RevenueCat core tools**, click on **Webhooks** to configure the integration
3. Click **Add Webhook** and fill in the following details:
* **Webhook name**: `Linkrunner`
* **Webhook URL**: Paste the webhook URL copied from the Linkrunner dashboard
* **Authorization header value**: Paste the authorization token generated in the Linkrunner dashboard
* **Environment to send events for**: Select **Both Production and Sandbox** (recommended)
* **Events filter**: Select **All apps** and **All events** (recommended)
4. Click **Add webhook** to save
## Event Type Mapping
RevenueCat events are automatically mapped to Linkrunner revenue types. For subscription events, Linkrunner reads `period_type` and `is_trial_conversion` from the webhook payload to keep trial activity separate from real payments:
| RevenueCat Event | Condition | Linkrunner Revenue Type | Description |
| ----------------------- | ----------------------------- | ---------------------------- | -------------------------------------------- |
| INITIAL\_PURCHASE | `period_type` = TRIAL | TRIAL\_STARTED | Free trial started (no revenue) |
| INITIAL\_PURCHASE | `period_type` = NORMAL | FIRST\_PAYMENT | First paid purchase |
| RENEWAL | `is_trial_conversion` = true | FIRST\_PAYMENT | Trial converted to paid (first real payment) |
| RENEWAL | `is_trial_conversion` = false | SUBSCRIPTION\_RENEWED | Subscription renewal payment |
| NON\_RENEWING\_PURCHASE | | ONE\_TIME | One-time purchase |
| CANCELLATION | `period_type` = TRIAL | TRIAL\_CANCELLED | User cancelled during trial |
| CANCELLATION | `period_type` = NORMAL | SUBSCRIPTION\_CANCELLED | User cancelled a paid subscription |
| UNCANCELLATION | | SUBSCRIPTION\_RENEWED | User reactivated subscription |
| REFUND | | SUBSCRIPTION\_REFUNDED | Payment refunded to user |
| BILLING\_ISSUE | | SUBSCRIPTION\_BILLING\_ISSUE | Payment failed or billing problem |
| PRODUCT\_CHANGE | | SUBSCRIPTION\_RENEWED | User changed subscription tier |
| EXPIRATION | | DEFAULT | Subscription expired naturally |
FIRST\_PAYMENT always means a real paid transaction. If your app offers a free trial, the trial start arrives as TRIAL\_STARTED and the trial-to-paid conversion arrives as FIRST\_PAYMENT. Use TRIAL\_STARTED for trial-optimization events (for example, mapping to Meta's StartTrial) and FIRST\_PAYMENT for purchase events.
## Event Status Mapping
Events are categorized into three statuses based on the payment outcome:
| Status | Events |
| --------------------- | ------------------------------------------------------------------------------------ |
| **Payment Completed** | INITIAL\_PURCHASE, RENEWAL, NON\_RENEWING\_PURCHASE, UNCANCELLATION, PRODUCT\_CHANGE |
| **Payment Failed** | BILLING\_ISSUE |
| **Payment Cancelled** | CANCELLATION, REFUND |
## Viewing Revenue Data
After the integration is complete, Linkrunner will automatically:
* Match incoming RevenueCat events with attributed users
* Map events to the corresponding Linkrunner revenue types
* Provide revenue analytics per campaign in your Linkrunner dashboard
This data helps you understand which campaigns are driving the most valuable users in terms of subscription revenue and lifetime value.
# WebEngage
Source: https://docs.linkrunner.io/analytics-integrations/webengage
Connect Linkrunner with WebEngage to send install attribution events and understand your user acquisition campaigns
Linkrunner integrates with WebEngage to automatically send install attribution events, helping you understand which campaigns are driving user acquisition and engagement.
## How it works
Every time an install is attributed by Linkrunner, we send an event to WebEngage's Events API. Linkrunner sends one of two event names:
| Event | Description |
| ------------------- | ------------------------------------------------------ |
| `organic_install` | Sent when the install is not attributed to a campaign. |
| `inorganic_install` | Sent when the install is attributed to a campaign. |
The event carries these attribution fields in `eventData`:
| Field | Description |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `utm_source` | The acquisition source. For inorganic installs this is the ad network (for example `meta`, `google`, or `tiktok`). For organic installs this is `organic`. |
| `utm_campaign` | The campaign name. Sent when campaign data is available. |
| `campaign_id` | The Linkrunner campaign ID. Sent for inorganic installs. |
| `campaign_display_id` | The Linkrunner campaign display ID. Sent when available. |
| `ad_network_id` | The Linkrunner ad network ID. Sent for inorganic installs. |
| `ad_network_code` | The ad network code, such as `META`, `GOOGLE`, or `TIKTOK`. Sent when available. |
| `platform` | The app platform. |
Linkrunner uses your app user ID when it is available. If the user ID is not available at install time, Linkrunner uses the install instance ID as the WebEngage `userId` so the install event is still captured.
### Example
* User came from a Meta campaign named `Summer_Sale`
* In WebEngage, that user's profile receives an `inorganic_install` event with `utm_source` set to `meta` and `utm_campaign` set to `Summer_Sale`
* An unattributed install instead receives an `organic_install` event with `utm_source` set to `organic`
## Prerequisites
| Requirement | Description |
| ---------------------- | --------------------------------------------------------------------------------------------------------------- |
| Linkrunner account | A Linkrunner account is required to take advantage of this integration. |
| WebEngage account | A WebEngage account with REST API access. |
| WebEngage License Code | Your WebEngage account license code. Copy it exactly as shown in WebEngage, including a leading `~` if present. |
| WebEngage API Key | Your WebEngage REST API key for the Events API. |
| Data Center Region | The WebEngage data center for your account: India, Global/US, KSA, or Europe (Frankfurt). |
| iOS or Android app | This integration supports iOS and Android apps. |
| Linkrunner SDK | You must have the [Linkrunner SDK](/introduction) installed in your app. |
## Steps to set up
In WebEngage, copy the REST API credentials that Linkrunner needs to send install attribution events.
1. Log in to your WebEngage dashboard
2. Open **Data Platform** > **Integrations** > **REST API**
3. In **Project Credentials**, click **Show** if the key is hidden
4. Copy the **License Code**
5. Copy the **API Key for Team Member**
6. Confirm the data center used by your WebEngage account
Copy the License Code exactly as WebEngage shows it. If your license code includes a leading `~`, include it in Linkrunner as well.
Add the WebEngage credentials to the Linkrunner project that owns your app.
1. In Linkrunner, navigate to **Integrations** from the left-hand panel
2. Under **Analytics**, select **WebEngage**
3. Paste the WebEngage **License Code** into **WebEngage License Code**
4. Paste the WebEngage **API Key for Team Member** into **WebEngage API Key**
5. Select the matching **Data Center Region**
6. Click **Save**
| WebEngage value | Paste in Linkrunner |
| --------------------------- | -------------------------- |
| **License Code** | **WebEngage License Code** |
| **API Key for Team Member** | **WebEngage API Key** |
| Account data center | **Data Center Region** |
The WebEngage API Key is used by Linkrunner server-side and should be pasted only in the Linkrunner dashboard, not in your mobile app.
Select the WebEngage data center that matches your account. This controls the WebEngage API host Linkrunner sends events to.
| Region in Linkrunner | WebEngage API host |
| -------------------- | ----------------------- |
| India | `api.in.webengage.com` |
| Global/US | `api.webengage.com` |
| KSA | `api.ksa.webengage.com` |
| Europe (Frankfurt) | `api.eug.webengage.com` |
If the wrong data center is selected, WebEngage can reject events even when the License Code and API key are correct.
If you connected WebEngage after Linkrunner already attributed installs for this project, click **Sync with WebEngage** in the WebEngage integration screen.
Linkrunner replays the eligible attributed install events to WebEngage and emails you when the sync is complete.
WebEngage install attribution events are triggered from the normal Linkrunner install flow. No separate WebEngage ID needs to be passed to the Linkrunner SDK.
**Important:** Make sure your app calls the Linkrunner SDK initialization flow after install. Linkrunner uses that install data to determine whether the install is organic or inorganic before sending the WebEngage event.
## Viewing Attribution Data
After the integration is complete and Linkrunner has sent install attribution events, you can view them in WebEngage:
1. Navigate to a user profile in the WebEngage dashboard
2. Look for the user's event timeline
3. You'll see an `organic_install` or `inorganic_install` event with the attribution fields described above
For aggregate visibility, open **Analytics** > **Events** in WebEngage, add/select `organic_install` or `inorganic_install`, and view occurrences over time.
This data can be used to:
* Create segments based on acquisition source
* Trigger journeys based on organic or inorganic installs
* Personalize messaging based on campaign origin
* Analyze user behavior by acquisition channel
# Campaign APIs
Source: https://docs.linkrunner.io/api-reference/campaign-apis
Documentation for Linkrunner Campaign APIs
## Base URL
```
https://api.linkrunner.io/api/v1
```
## Authentication
All API requests require authentication using an API key. You must include this key in the header of every request.
### API Key Header
Include the following header in all API requests:
```
linkrunner-key: YOUR_API_KEY
```
Replace `YOUR_API_KEY` with your actual API key.
### Obtaining Your API Key
You can find your API key on the Linkrunner settings page:
1. Go to [https://dashboard.linkrunner.io/settings?s=data-apis](https://dashboard.linkrunner.io/settings?s=data-apis)
2. Locate your Server key on this page
3. Use this key in the `linkrunner-key` header for all API requests
Keep your API key confidential. Do not share it or expose it in client-side code. Always make API requests from a secure server-side environment.
### Error Responses
* **401 Unauthorized**
* `"Server key missing!"`
* `"Server key invalid!"`
* **429 Too Many Requests**
* Message: `"Rate limit exceeded. Please try again later."`
* Cause: You've exceeded the rate limit of 10 requests per second.
### Rate Limit Details
* Rate: 10 requests per second
* Status code on limit exceeded: 429 (Too Many Requests)
## Endpoints
### 1. List Campaigns
For getting/listing existing campaigns, please refer to the [List Campaigns API documentation](https://docs.linkrunner.io/api-reference/data-apis#1-list-campaigns).
### 2. Create Campaign
Create a new campaign. You can optionally create a short link by including the `is_shortlink: true` parameter. To group Linkrunner links under a project-level custom channel such as WhatsApp, Offline QR, or CleverTap Push, include `custom_channel_names`.
#### Request
```
POST /create-campaign
```
#### Request Body Examples
**Creating Campaign:**
```json theme={null}
{
"name": "Summer Sale 2023",
"deeplink": "https://app.domain.com/promo/summer25",
"link_for_desktop_users": "https://www.your-website.com",
"custom_display_id": "summer-sale-2025",
"is_shortlink": false //pass this as true to create a shortened campaign link
}
```
**Creating Campaign with a Custom Channel:**
```json theme={null}
{
"name": "WhatsApp Summer Sale 2025",
"deeplink": "https://app.domain.com/promo/summer25",
"link_for_desktop_users": "https://www.your-website.com",
"custom_display_id": "whatsapp-summer-sale-2025",
"custom_channel_names": ["WhatsApp"]
}
```
#### Request Parameters
| Parameter | Type | Required | Description |
| ------------------------ | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `name` | string | Yes | The display name of your campaign. This helps you identify the campaign in your dashboard. |
| `deeplink` | string | No | The URL that mobile app users will be directed to when they click your campaign link. Should be a valid deep link URL for your mobile application. |
| `link_for_desktop_users` | string | No | The fallback URL for desktop users who click your campaign link. Typically points to your website or web application. |
| `android_web_redirect` | string | No | Provide this only if you want the user to be explicitly redirected to a specific web URL on Android. |
| `ios_web_redirect` | string | No | Provide this only if you want the user to be explicitly redirected to a specific web URL on iOS. |
| `custom_display_id` | string | No | A custom identifier for your campaign. Must be unique across your account. If not provided, a random ID will be generated. |
| `is_shortlink` | boolean | No | Set to `true` to create a shortened campaign link. When enabled, creates a compact URL format suitable for sharing. |
| `domain` | string | No | The domain name to use for this campaign (e.g., "app.example.com"). Must belong to your project. If not provided, the project's primary domain will be used. |
| `store_listing_ids` | string\[] | No | Array of store listing IDs to associate with this campaign. Get store listing IDs from your [dashboard settings](https://dashboard.linkrunner.io/dashboard/settings/store-listings). Allows targeting different store URLs per platform (iOS/Android). |
| `custom_channel_names` | string\[] | No | Array containing one custom channel name to group this campaign under. If the active channel already exists for the project, the campaign is attached to it; otherwise, a new custom channel is created. |
##### Note: If `android_web_redirect` or `ios_web_redirect` is provided, it will override the default store listing link for that platform within the campaign and redirect users to the specified URL instead.
#### Domain and Store Listing Features
##### Domain (`domain`)
The `domain` parameter allows you to specify which domain should be used for generating campaign links. This is useful when you have multiple domains configured for your project.
**Parameter Type**: `string` (domain name)
**Behavior**:
* If provided and valid: Campaign will use the specified domain for all link generation
* If not provided: Campaign will automatically use the project's primary domain
* Must be a valid domain name that belongs to your project
**Examples**:
```json theme={null}
// Valid domain names
{ "domain": "app.example.com" }
{ "domain": "promo.yourapp.io" }
{ "domain": "track.mysite.com" }
// Invalid - will return error
{ "domain": "other-company.com" } // Error: Domain doesn't belong to your project
```
##### Multiple Store Listings (`store_listing_ids`)
The `store_listing_ids` parameter allows you to associate multiple store listings with a single campaign.
**Parameter Type**: `string[]` (array of store listing ID strings)
**Getting Store Listing IDs**:
* Navigate to your [Store Listings Dashboard](https://dashboard.linkrunner.io/dashboard/settings/store-listings)
* View or create store listings for your project
* Copy the store listing IDs to use in your API requests
**Behavior**:
* Accepts an array of store listing ID strings
* Each ID must be a non-empty string
* All store listings must belong to your project
* Cannot have multiple store listings for the same platform (only one iOS and one Android listing per campaign)
**Validation**:
```json theme={null}
// Valid store listing IDs
{ "store_listing_ids": ["ios-main-v1", "android-promo-v2"] }
{ "store_listing_ids": ["my-custom-listing-id"] }
// Invalid - will return errors
{ "store_listing_ids": "ios-main-v1" } // Error: "store_listing_ids must be an array of non-empty strings!"
{ "store_listing_ids": ["", "android-promo-v2"] } // Error: "store_listing_ids must be an array of non-empty strings!"
{ "store_listing_ids": ["non-existent-id"] } // Error: "Store listing(s) not found for this project"
{ "store_listing_ids": ["ios-v1", "ios-v2"] } // Error: "Cannot assign multiple store listings with the same platform to a campaign!"
```
##### Custom Channel Grouping (`custom_channel_names`)
The `custom_channel_names` parameter lets you group Linkrunner campaigns under a custom channel for dashboard filtering and channel-level aggregation.
##### Note: Linkrunner currently supports only one custom channel per campaign.
**Parameter Type**: `string[]` (array containing at most one custom channel name)
**Behavior**:
* If the channel already exists and is active for the project, the campaign is attached to it
* If the channel does not exist, Linkrunner creates it for the project and attaches the campaign to it
* The channel code is generated from the name by lowercasing it and replacing non-alphanumeric characters with underscores
* A campaign can have at most one custom channel
* Default channel names such as Google, Meta, Reddit, Snapchat, TikTok, LinkedIn, Apple Search Ads, Organic, Linkrunner Links, and None cannot be used as custom channel names
**Examples**:
```json theme={null}
// Valid custom channel names
{ "custom_channel_names": ["WhatsApp"] }
{ "custom_channel_names": ["Offline QR"] }
{ "custom_channel_names": ["CleverTap Push"] }
// Invalid - will return errors
{ "custom_channel_names": ["WhatsApp", "Offline QR"] } // Error: "A campaign can have at most one custom channel"
{ "custom_channel_names": ["Google"] } // Error: reserved channel name
{ "custom_channel_names": [""] } // Error: HTML/scripts are not allowed
```
#### Response
**Standard Campaign Response:**
```json theme={null}
{
"msg": "Campaign created successfully!",
"status": 201,
"data": {
"id": 123,
"name": "Summer Sale 2023",
"link": "https://app.domain.com/promo/summer25?c=summer-sale-2025",
"website": "https://www.your-website.com",
"display_id": "summer-sale-2025",
"created_at": "2023-05-01T12:00:00Z",
"android_web_redirect": null,
"ios_web_redirect": null,
"domain": "app.domain.com",
"custom_channels": [
{
"id": 456,
"name": "WhatsApp",
"slug": "whatsapp"
}
],
"store_listings": [
{
"store_listing_id": "android-store-v1",
"name": "Android Main Store",
"platform": "ANDROID"
}
]
}
}
```
**Short Link Campaign Response:**
```json theme={null}
{
"msg": "Campaign created successfully!",
"status": 201,
"data": {
"id": 124,
"name": "Spring Launch 2024",
"link": "https://app.domain.com/?c=spring-launch-2024",
"website": null,
"display_id": "spring-launch-2024",
"created_at": "2023-05-01T12:00:00Z",
"android_web_redirect": null,
"ios_web_redirect": null,
"domain": "app.domain.com",
"custom_channels": [],
"store_listings": []
}
}
```
#### Response Properties
| Property | Type | Description |
| ---------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | number | Unique numerical identifier for the campaign in the system. |
| `name` | string | The campaign name as provided in the request. |
| `link` | string | The generated shareable campaign URL. For short links, this will be in format `https://app.domain.com/?c={display_id}`. |
| `website` | string\|null | The desktop fallback URL. Will be `null` for short link campaigns. |
| `display_id` | string | The campaign's display identifier (custom or auto-generated). |
| `created_at` | string | ISO 8601 timestamp of when the campaign was created. |
| `domain` | string\|null | The domain name used for this campaign. |
| `store_listings` | StoreListing\[] | Array of store listings associated with this campaign. Each object contains `store_listing_id` (string), `name` (string), and `platform` ("IOS" \| "ANDROID"). |
| `android_web_redirect` | string\|null | The Android web redirect URL if specified, otherwise `null`. |
| `ios_web_redirect` | string\|null | The iOS web redirect URL if specified, otherwise `null`. |
| `custom_channels` | CustomChannel\[] | Custom channels attached to this campaign. At most one custom channel is currently supported. |
##### Note: Some responses may include legacy custom-channel aliases such as `custom_channel`, `custom_channel_code`, `custom_channel_name`, `custom_channel_codes`, or `custom_channel_names`. Use `custom_channels` as the canonical response field.
### 3. Edit Campaign
Update an existing campaign.
#### Request
```
PATCH /campaigns/:display_id
```
#### Request Body
```json theme={null}
{
"name": "campaign name",
"active": false,
"website": "https://www.your-website.com",
"android_web_redirect": "https://www.your-website.com/android",
"ios_web_redirect": "https://www.your-website.com/ios",
"custom_channel_names": ["WhatsApp"]
}
```
To remove the custom channel from a campaign, pass an empty array:
```json theme={null}
{
"custom_channel_names": []
}
```
To clear a web field, pass an empty string or `null`. Omitting a field leaves its stored value unchanged:
```json theme={null}
{
"android_web_redirect": "",
"ios_web_redirect": null
}
```
| Parameter | Type | Description |
| ---------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| name | string | **Optional**. Name of the campaign |
| active | boolean | **Optional**. Campaign status |
| website | string\|null | **Optional**. The desktop fallback URL. This is the same field the Create Campaign endpoint accepts as `link_for_desktop_users`; either name is accepted here, but sending both with different values is rejected. Pass an empty string or `null` to clear it. |
| android\_web\_redirect | string\|null | **Optional**. Web URL that Android users are redirected to, overriding the campaign's store listing link for that platform. Must be an absolute `http(s)` URL. Pass an empty string or `null` to clear it. |
| ios\_web\_redirect | string\|null | **Optional**. Web URL that iOS users are redirected to, overriding the campaign's store listing link for that platform. Must be an absolute `http(s)` URL. Pass an empty string or `null` to clear it. |
| custom\_channel\_names | string\[] | **Optional**. Omit to leave unchanged, pass one name to set or replace the campaign custom channel, or pass an empty array to clear it. Linkrunner currently supports only one custom channel per campaign. |
Changes to `website`, `android_web_redirect` and `ios_web_redirect` are applied to live campaign links immediately — the cached copy the click path serves is cleared as part of the update.
#### Responses
1. **200** Campaign updated successfully
2. **400** Invalid request parameters
3. **404** Campaign not found
4. **500** Internal server error
#### Success Response
```json theme={null}
{
"msg": "Campaign updated successfully!",
"status": 200,
"data": {
"display_id": "TOhmGM",
"name": "campaign name",
"created_at": "2025-07-09T17:18:41.912Z",
"update_at": "2025-07-17T08:58:26.740Z",
"google": false,
"meta": false,
"meta_campaign_id": "",
"meta_web_to_app": false,
"active": false,
"default_link": true,
"website": "https://www.your-website.com",
"android_web_redirect": "https://www.your-website.com/android",
"ios_web_redirect": "https://www.your-website.com/ios",
"attributed_users": 0,
"custom_channels": [
{
"id": 456,
"name": "WhatsApp",
"slug": "whatsapp"
}
]
}
}
```
#### Error Responses
| HTTP Status | Message | When/Why |
| ----------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- |
| 400 | "Campaign display ID is required!" | If the display\_id param is missing |
| 400 | "At least one field (name, active, website, android\_web\_redirect or ios\_web\_redirect) is required for update!" | If no supported update field is provided |
| 400 | "Please enter a valid URL (e.g., [https://example.com/android](https://example.com/android))" | If a web redirect is not an absolute http(s) URL |
| 400 | "website and link\_for\_desktop\_users refer to the same field and cannot be sent with different values!" | If both spellings of the website field are sent with different values |
| 400 | "Campaign name cannot be empty!" | If name is provided but is empty or only whitespace |
| 400 | "Active field must be a boolean!" | If active is provided but is not a boolean |
| 400 | "A campaign can have at most one custom channel" | If more than one custom channel name is provided |
| 400 | "The system MAIN\_DOMAIN campaign cannot be renamed." | The MAIN\_DOMAIN campaign is a system marker and cannot be renamed |
| 400 | "A campaign cannot be renamed to the reserved name MAIN\_DOMAIN." | MAIN\_DOMAIN is reserved |
| 404 | "Campaign not found!" | If the campaign with the given display\_id does not exist |
| 500 | "Internal server error" | |
### 4. Delete Campaign
Delete an existing campaign.
#### Request
```
DELETE /campaigns/:display_id
```
#### Responses
1. **204** Campaign deleted successfully
2. **400** Invalid request parameters
3. **404** Campaign not found
4. **500** Internal server error
#### Success Response
```json theme={null}
{
"msg": "Campaign deleted successfully!",
"status": 204
}
```
#### Error Responses
| HTTP Status | Message | When/Why |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| 400 | "Campaign display ID is required!" | If the display\_id param is missing |
| 400 | "Only manually-created LinkRunner campaigns can be deleted. System (MAIN\_DOMAIN) and ad-network campaigns cannot be deleted." | The campaign is the system MAIN\_DOMAIN campaign or is owned by an ad-network sync |
| 404 | "Campaign not found!" | If the campaign with the given display\_id does not exist |
| 500 | "Internal server error" | |
## Best Practices
1. **Campaign naming**: Use descriptive names that identify the purpose of the campaign
2. **Deep links**: Ensure your deep links are properly formatted and lead to valid destinations
3. **Custom IDs**: Use meaningful custom display IDs that are easy to recognize and remember. Duplicate ids are not allowed!
4. **Desktop links**: Provide a `link_for_desktop_users` URL to ensure desktop visitors are redirected to a relevant webpage instead of app stores.
## Examples
### Creating a New Campaign
```javascript theme={null}
fetch("https://api.linkrunner.io/api/v1/create-campaign", {
method: "POST",
headers: {
"Content-Type": "application/json",
"linkrunner-key": "YOUR-SERVER-KEY",
},
body: JSON.stringify({
name: "Product Launch 2023",
deeplink: "https://app.domain.com/promo/summer25",
link_for_desktop_users: "https://www.your-website.com",
custom_display_id: "summer-sale-2025",
custom_channel_names: ["WhatsApp"],
}),
})
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => console.error("Error:", error));
```
### Editing a Campaign
```javascript theme={null}
fetch("https://api.linkrunner.io/api/v1/campaigns/TOhmGM", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"linkrunner-key": "YOUR-SERVER-KEY",
},
body: JSON.stringify({
name: "Updated Campaign Name",
active: true,
website: "https://www.your-website.com",
android_web_redirect: "https://www.your-website.com/android",
// Pass an empty string or null to clear a field; omit it to leave it unchanged.
ios_web_redirect: "",
}),
})
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => console.error("Error:", error));
```
### Deleting a Campaign
```javascript theme={null}
fetch("https://api.linkrunner.io/api/v1/campaigns/TOhmGM", {
method: "DELETE",
headers: {
"linkrunner-key": "YOUR-SERVER-KEY",
},
})
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => console.error("Error:", error));
```
## Error Handling
The API will return appropriate HTTP status codes along with error messages when issues occur. Common errors across all endpoints include:
* **400 Bad Request**: Missing required parameters or invalid input
* **401 Unauthorized**: API key is required or invalid
* **404 Not Found**: Campaign not found
* **429 Too Many Requests**: You've exceeded the rate limit, please try again later
* **500 Internal Server Error**: Contact support if this persists
Specific error responses for each endpoint are detailed in their respective sections above.
### Domain and Store Listing Specific Errors
| Status Code | Error Message | Description |
| ----------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| 400 | "Domain '' not found for this project!" | The specified domain name doesn't exist or isn't in your project |
| 400 | "store\_listing\_ids must be an array of non-empty strings!" | store\_listing\_ids parameter is not an array or contains empty strings |
| 400 | "Store listing(s) not found for this project: " | One or more store listing IDs don't exist in your project |
| 400 | "Cannot assign multiple store listings with the same platform to a campaign!" | Attempting to add more than one iOS or Android store listing |
### Custom Channel Specific Errors
| Status Code | Error Message | Description |
| ----------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| 400 | "A campaign can have at most one custom channel" | More than one value was provided in `custom_channel_names`. |
| 400 | "Custom channel name must be less than 80 characters" | A custom channel name is longer than the allowed limit. |
| 400 | "Custom channel name cannot contain HTML tags or scripts" | A custom channel name contains HTML or script content. |
| 400 | `"{name}" is a reserved channel name` | The custom channel name resolves to a default or reserved channel. |
| 409 | "Channel name conflicts with a previously deleted channel - pick a different name" | The channel code belongs to an inactive custom channel. |
For any help please reach out to [support@linkrunner.io](mailto:support@linkrunner.io)
# Data APIs
Source: https://docs.linkrunner.io/api-reference/data-apis
Documentation for Linkrunner Data APIs
## Base URL
```
https://api.linkrunner.io/api/v1
```
## Authentication
All API requests require authentication. Include your server key in the request headers.
### Error Responses
* **401 Unauthorized**
* `"Unauthorized access."` - Missing or invalid API key
* **429 Too Many Requests**
* Message: `"Rate limit exceeded. Please try again later."`
* Cause: You've exceeded the rate limit of 30 requests per second.
### Rate Limit Details
* Rate: 30 requests per second
* Status code on limit exceeded: 429 (Too Many Requests)
## Authentication
All API requests require authentication using an API key. You must include this key in the header of every request.
### API Key Header
Include the following header in all API requests:
```
linkrunner-key: YOUR_API_KEY
```
Replace `YOUR_API_KEY` with your actual API key. (Refer instructions below)
### Obtaining Your API Key
You can find your API key on the Linkrunner settings page:
1. Go to [https://dashboard.linkrunner.io/settings?s=data-apis](https://dashboard.linkrunner.io/settings?s=data-apis)
2. Locate your Server key on this page
3. Use this key in the `linkrunner-key` header for all API requests
Keep your API key confidential. Do not share it or expose it in client-side code. Always make API requests from a secure server-side environment.
## Endpoints
### 1. List Campaigns
Retrieve a paginated list of campaigns with optional filtering by status or specific link.
#### Request
```
GET /campaigns
```
#### Query Parameters
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| filter | string | No | Filter campaigns by status. Options: `ACTIVE`, `INACTIVE`, `ALL` (default: `ALL`) |
| channel | string | No | Filter campaigns by advertising channel/network. Options: `GOOGLE`, `META`, `TIKTOK`, `APPLE_SEARCH_ADS`, or any affiliate name (case-insensitive). For affiliates, use underscores for spaces (e.g., `AFFILIATE_EXAMPLE` for "affiliate example") |
| domain | string | No | Filter campaigns by domain name (e.g., `app.example.com`). Only returns campaigns associated with the specified domain. |
| link | string | No | Filter campaigns by a specific link URL. The system will extract the campaign from the provided link. |
| page | number | No | The page number to retrieve. Must be a positive integer. (default: `1`) |
| limit | number | No | The number of campaigns to return per page. Must be between 1 and 1000. (default: `100`, max: `1000`) |
#### Channel Filter Behavior
| Value | Behavior |
| ------------------- | ---------------------------------------------------------------------------------------------------- |
| `GOOGLE` | Returns campaigns where `google = true` |
| `META` | Returns campaigns where `meta = true` |
| `TIKTOK` | Returns campaigns linked to a TikTok ad network |
| `APPLE_SEARCH_ADS` | Returns campaigns linked to Apple Search Ads ad network |
| `AFFILIATE_EXAMPLE` | Returns campaigns linked to an affiliate named "affiliate example". Replace spaces with underscores. |
| *(empty/invalid)* | No channel filter applied (returns all channels) |
**Affiliate Channel Naming**:
* Affiliate names should use underscores instead of spaces
* Example: If the affiliate is named "affiliate example", use `channel=AFFILIATE_EXAMPLE`
* Channel names are case-insensitive
#### Pagination
Pagination is automatically applied to all requests with the following defaults:
* **Default page**: 1
* **Default limit**: 100 campaigns per page
* **Maximum limit**: 1000 campaigns per page
#### Example Requests
1. **Basic Request** (returns first 100 campaigns):
```
GET /campaigns
```
2. **Filter Active Campaigns with Pagination**:
```
GET /campaigns?filter=ACTIVE&page=2&limit=50
```
3. **Get Campaign by Link**:
```
GET /campaigns?link=https://yourdomain.com?c=XYZ123
```
4. **All Campaigns with Custom Limit**:
```
GET /campaigns?filter=ALL&limit=200
```
5. **Filter by Channel (Google)**:
```
GET /campaigns?channel=GOOGLE
```
6. **Filter by Channel (Meta)**:
```
GET /campaigns?channel=META
```
7. **Filter by Channel (TikTok)**:
```
GET /campaigns?channel=TIKTOK
```
8. **Combine Channel with Other Filters**:
```
GET /campaigns?channel=google&filter=ACTIVE&page=1&limit=50
```
#### Response
**Standard Response (no channel filter):**
```json theme={null}
{
"msg": "Successfully retrieved 10 campaigns (all campaigns). Showing page 1 of 4.",
"status": 200,
"data": {
"total_campaigns": 10,
"campaigns": [
{
"display_id": "XYZ123",
"name": "Summer_Promo_2024",
"created_at": "2024-09-30T09:37:06.989Z",
"update_at": "2024-09-30T09:37:06.989Z",
"google": false,
"meta": true,
"meta_campaign_id": "987654321098765432",
"meta_web_to_app": false,
"active": true,
"default_link": true,
"attributed_users": 491,
"link": "https://yourdomain.com?c=XYZ123",
"shareable_link": "https://yourdomain.com/XYZ123",
"domain": "yourdomain.com",
"store_listings": [
{
"store_listing_id": "ios-store-v1",
"name": "iOS Main Listing",
"platform": "IOS"
},
{
"store_listing_id": "android-promo-v2",
"name": "Android Promo Listing",
"platform": "ANDROID"
}
]
}
// ... more campaigns
],
"pagination": {
"total": 35,
"pages": 4,
"page": 1,
"limit": 10
}
}
}
```
#### Error Responses
| Status Code | Error Message | Description |
| ----------- | ------------------------------------------------------------ | ----------------------------------------------------------------- |
| 400 | "Invalid request parameters." | Invalid link, domain not found, or campaign not found |
| 400 | "Invalid domain ''. Domain not found for this project." | Specified domain does not exist or doesn't belong to your project |
| 400 | "Invalid channel ''. Channel not found." | Specified channel/ad network does not exist |
| 401 | "Unauthorized access." | Missing or invalid API key |
| 422 | "Invalid page number ''. Page must be a positive integer..." | Page parameter is not a positive integer |
| 422 | "Invalid limit ''. Limit must be between 1 and 1000..." | Limit parameter is not between 1 and 1000 |
| 422 | "Limit exceeds maximum allowed value of 1000..." | Limit exceeds 1000 |
#### TypeScript Types
```tsx theme={null}
interface StoreListing {
store_listing_id: string;
name: string;
platform: "IOS" | "ANDROID";
}
interface Campaign {
display_id: string;
name: string;
created_at: string;
update_at: string;
google: boolean;
meta: boolean;
meta_campaign_id: string | null;
meta_web_to_app: boolean;
active: boolean;
default_link: boolean;
attributed_users: number;
link: string;
shareable_link: string;
website: string | null;
domain: string | null;
store_listings: StoreListing[];
network_account: NetworkAccount | null;
}
interface NetworkAccount {
id: number;
name: string | null;
status: string;
capi_configured: boolean;
}
interface PaginationInfo {
total: number;
pages: number;
page: number;
limit: number;
}
interface CampaignsResponse {
msg: string;
status: number;
data: {
total_campaigns: number;
campaigns: Campaign[];
pagination: PaginationInfo;
};
}
```
### 2. Get Attributed Users
Retrieve a paginated list of users attributed to a specific campaign with optional time range filtering.
#### Request
```
GET /attributed-users
```
#### Query Parameters
| Parameter | Type | Required | Description |
| ---------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------- |
| display\_id | string | Yes | The display ID of the campaign to retrieve attributed users for |
| start\_timestamp | string | No | Start time in RFC 3339 format with a UTC or offset suffix (e.g., `2025-06-23T10:29:26Z` or `2025-06-23T10:29:26+05:30`) |
| end\_timestamp | string | No | End time in RFC 3339 format with a UTC or offset suffix (e.g., `2025-06-24T10:29:26Z` or `2025-06-24T10:29:26+05:30`) |
| page | number | No | The page number to retrieve. Must be a positive integer. (default: `1`) |
| limit | number | No | The number of users to return per page. Must be between 1 and 1000. (default: `50`, max: `1000`) |
#### Pagination
Pagination is automatically applied to all requests with the following defaults:
* **Default page**: 1
* **Default limit**: 50 users per page
* **Maximum limit**: 1000 users per page
#### Time Format Details
* **RFC 3339 Format**: Use `YYYY-MM-DDThh:mm:ssZ` for UTC (e.g., `2025-06-23T10:29:26Z`) or include an explicit UTC offset (e.g., `2025-06-23T10:29:26+05:30`)
* **Offset is required**: Timestamps without a `Z` or offset suffix are rejected with a `400` error
* **Timezone handling**: Express your local time window through the offset. For example, June 23 midnight to June 24 midnight IST is `2025-06-23T00:00:00+05:30` to `2025-06-24T00:00:00+05:30`
* **Time Range**: Both start and end timestamps are optional; you can filter by start time only, end time only, or both
The legacy `timezone` query parameter is accepted for backward compatibility
but has no effect. Use an explicit offset in the timestamps instead.
#### Example Requests
1. **Basic Request** (returns first 50 users):
```
GET /attributed-users?display_id=XYZ123
```
2. **With Time Range** (UTC):
```
GET /attributed-users?display_id=XYZ123&start_timestamp=2025-06-23T10:29:26Z&end_timestamp=2025-06-24T10:29:26Z
```
3. **With Pagination**:
```
GET /attributed-users?display_id=XYZ123&page=2&limit=100
```
4. **With All Parameters** (timestamps in IST, URL-encode `+` as `%2B`):
```
GET /attributed-users?display_id=XYZ123&start_timestamp=2025-06-23T00:00:00%2B05:30&end_timestamp=2025-06-24T00:00:00%2B05:30&page=3&limit=200
```
#### Response
```json theme={null}
{
"msg": "Successfully retrieved 123 attributed users for campaign XYZ123. Showing page 1 of 3.",
"status": 200,
"data": {
"total_users": 50,
"users": [
{
"attributed_at": "2024-09-23T08:13:34.417Z",
"ad_channel": "META",
"campaign_display_id": "XYZ123",
"campaign_name": "Summer_Promo_2024",
"link": null,
"installed_at": "2024-08-12T20:59:03.000Z",
"store_click_at": "2024-08-12T20:58:25.000Z",
"meta_ad_id": "138726495012847390",
"ad_creative_id": "138726495084763210",
"ad_creative_name": "Variation_8",
"ad_set_id": "138726384957162840",
"ad_set_name": "Engagement",
"publisher_platform": "instagram",
"platform_position": null,
"user_data": {
"id": "user123456789",
"name": "John Smith",
"email": "john.smith@example.com",
"phone": "+1234567890",
"device_data": {
"brand": "Samsung",
"base_os": "",
"version": "0.7.6",
"build_id": "XYZ123.456",
"api_level": 33,
"bundle_id": "com.example.app",
"device_id": "DEVICE123456",
"android_id": "ANDROID987654",
"device_name": "Galaxy S21",
"device_type": "user",
"build_number": "2219",
"connectivity": "Wi-Fi",
"manufacturer": "Samsung",
"application_name": "ExampleApp"
}
}
}
// ... more users
],
"pagination": {
"total": 123,
"pages": 3,
"page": 1,
"limit": 50
}
}
}
```
#### Error Responses
| Status Code | Error Message | Description |
| ----------- | ------------------------------------------------------------ | ----------------------------------------------------------- |
| 400 | "Invalid timestamp format." | Timestamp is not RFC 3339 or is missing a `Z`/offset suffix |
| 401 | "Authentication failed." | Invalid or missing API key |
| 404 | "Resource not found." | Campaign does not exist in the project |
| 422 | "Required parameter missing." | Missing required display\_id parameter |
| 422 | "Invalid page number ''. Page must be a positive integer..." | Page parameter is not a positive integer |
| 422 | "Invalid limit ''. Limit must be between 1 and 1000..." | Limit parameter is not between 1 and 1000 |
| 422 | "Limit exceeds maximum allowed value of 1000..." | Limit exceeds 1000 |
#### TypeScript Types
```tsx theme={null}
interface DeviceData {
brand: string;
base_os: string;
version: string;
build_id: string;
api_level: number;
bundle_id: string;
device_id: string;
android_id: string;
device_name: string;
device_type: string;
build_number: string;
connectivity: string;
manufacturer: string;
application_name: string;
}
interface UserData {
id: string | null;
name: string | null;
email: string | null;
phone: string | null;
device_data: DeviceData;
// Additional user data fields may be included
[key: string]: any;
}
interface AttributedUser {
attributed_at: string;
ad_channel: "GOOGLE" | "META" | "TIKTOK" | null;
campaign_display_id: string;
campaign_name: string;
link: string | null;
installed_at: string | null;
store_click_at: string | null;
meta_ad_id: string;
ad_creative_id: string;
ad_creative_name: string;
ad_set_id: string;
ad_set_name: string;
publisher_platform: string | null;
platform_position: string | null;
user_data: UserData;
}
interface PaginationInfo {
total: number;
pages: number;
page: number;
limit: number;
}
interface AttributedUsersResponse {
msg: string;
status: number;
data: {
total_users: number;
users: AttributedUser[];
pagination: PaginationInfo;
};
}
```
## Campaign Domain and Store Listings
### Domain Field
Each campaign response now includes a `domain` field that indicates which domain is being used for the campaign links:
* **Type**: `string | null`
* **Description**: The domain name used to generate campaign links
* **Behavior**: If the campaign has a specific domain assigned, it will be shown. Otherwise, it falls back to the project's primary domain.
* **Example**: `"yourdomain.com"` or `"promo.example.com"`
### Store Listings Array
Campaigns can now include multiple store listings in the response. Each store listing represents a different App Store or Play Store configuration.
**Store Listing Object Structure**:
| Field | Type | Description |
| ------------------ | ------------------ | --------------------------------------------------- |
| `store_listing_id` | string | Unique identifier for the store listing |
| `name` | string | Display name of the store listing |
| `platform` | "IOS" \| "ANDROID" | Platform this store listing is for (iOS or Android) |
**Managing Store Listings**:
* Create and manage store listings in your [Dashboard Settings](https://dashboard.linkrunner.io/dashboard/settings/store-listings)
* Each store listing has a unique `store_listing_id` that you use when creating campaigns
* Store listings allow you to configure different App Store/Play Store parameters per campaign
### 3. Get Attribution Result
Retrieve attribution data for a specific user or device. This endpoint returns campaign and ad network information for attributed installs.
#### Request
```
GET /get-attribution-result
```
#### Query Parameters
| Parameter | Type | Required | Description |
| ------------------ | ------ | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| device\_identifier | string | Conditional | The unique device ID to look up. For Android, this is the GAID (Google Advertising ID). For iOS, this is the IDFA (Identifier for Advertisers). Required if `user_id` is not provided. |
| user\_id | string | Conditional | The user ID to look up. Required if `device_identifier` is not provided. |
**Note**: At least one of `device_identifier` or `user_id` must be provided. If both are provided and they match different installs, the `user_id` based result will be returned as it's more reliable.
#### Example Requests
1. **Get Attribution by Device Identifier**:
```
GET /get-attribution-result?device_identifier=550e8400-e29b-41d4-a716-446655440000
```
2. **Get Attribution by User ID**:
```
GET /get-attribution-result?user_id=user123456
```
3. **Get Attribution with Both Identifiers**:
```
GET /get-attribution-result?device_identifier=550e8400-e29b-41d4-a716-446655440000&user_id=user123456
```
#### Response
**Success Response (200)**:
```json theme={null}
{
"msg": "Attribution result retrieved successfully.",
"status": 200,
"data": {
"campaign_name": "Summer Sale 2024",
"display_id": "XYZ123",
"ad_network_campaign_id": "120214682829390250",
"deeplink": "https://app.domain.com/promo/summer",
"domain_name": "app.domain.com",
"ad_set_id": "138726384957162840",
"ad_set_name": "Engagement Campaign",
"ad_creative_id": "138726495084763210",
"ad_creative_name": "Creative Variation 1",
"keyword_id": "",
"keyword_name": "",
"ad_network": "META",
"attribution_type": "INORGANIC"
}
}
```
**No Attribution Data (204)**:
If no attribution data is found for the provided identifier, the API returns a `204 No Content` status with an empty response body.
#### Response Properties
| Property | Type | Description |
| ------------------------ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `campaign_name` | string\|null | The name of the campaign that the user was attributed to |
| `display_id` | string\|null | The campaign's display identifier |
| `ad_network_campaign_id` | string\|null | The ad-network campaign ID (resolves to the Google campaign ID, else Meta campaign ID, else generic ad-network campaign ID) |
| `deeplink` | string\|null | The deeplink URL associated with the campaign |
| `domain_name` | string\|null | The domain name used for the campaign |
| `ad_set_id` | string\|null | The ad set ID from the advertising platform (e.g., Meta Ad Set ID) |
| `ad_set_name` | string\|null | The ad set name from the advertising platform |
| `ad_creative_id` | string\|null | The ad creative ID from the advertising platform |
| `ad_creative_name` | string\|null | The ad creative name from the advertising platform |
| `keyword_id` | string\|null | The keyword ID (primarily for Apple Search Ads campaigns) |
| `keyword_name` | string\|null | The keyword name (primarily for Apple Search Ads campaigns) |
| `ad_network` | string\|null | The advertising network. Can be `GOOGLE`, `META`, `TIKTOK`, `APPLE_SEARCH_ADS`, or other network codes |
| `attribution_type` | "ORGANIC"\|"INORGANIC" | Whether the install was matched to a paid or owned source (`INORGANIC`) or not (`ORGANIC`) |
| `user_id` | string\|null | Your identifier for the user, as sent to `signup` |
| `gaid` | string\|null | Google Advertising ID (Android) |
| `idfa` | string\|null | Identifier for Advertisers (iOS) |
| `store_click_at` | string\|null | When the store click was recorded (ISO 8601 UTC) |
| `installed_at` | string\|null | When the install was recorded (ISO 8601 UTC) |
#### Error Responses
| Status Code | Error Message | Description |
| ----------- | ---------------------------------------------------- | ----------------------------------------------------- |
| 400 | "Either device\_identifier or user\_id is required." | Neither device\_identifier nor user\_id was provided |
| 401 | "Unauthorized access." | Missing or invalid API key |
| 204 | No Content | No attribution data found for the provided identifier |
#### TypeScript Types
```tsx theme={null}
type AdNetwork = "GOOGLE" | "META" | "TIKTOK" | "APPLE_SEARCH_ADS" | string;
type AttributionType = "ORGANIC" | "INORGANIC";
interface AttributionResult {
campaign_name: string | null;
display_id: string | null;
ad_network_campaign_id: string | null;
deeplink: string | null;
domain_name: string | null;
ad_set_id: string | null;
ad_set_name: string | null;
ad_creative_id: string | null;
ad_creative_name: string | null;
keyword_id: string | null;
keyword_name: string | null;
ad_network: AdNetwork | null;
attribution_type: AttributionType;
user_id: string | null;
gaid: string | null;
idfa: string | null;
store_click_at: string | null;
installed_at: string | null;
}
interface AttributionResultResponse {
msg: string;
status: number;
data: AttributionResult;
}
```
## Notes
* Request timestamps use RFC 3339 format with a `Z` or offset suffix; response timestamps are returned in ISO 8601 UTC.
* The `device_data` object contains detailed information about the user's device.
* The `active` field in the campaign data indicates whether the campaign is currently active.
* The `attributed_users` field in the campaign data shows the number of users attributed to that campaign.
* User data provided in this documentation is randomized for privacy reasons. Actual API responses will contain real user data.
* The TypeScript types provided are based on the example responses
* The `domain` field shows which domain is being used for campaign link generation
* The `store_listings` array contains store listing configurations associated with the campaign
* Store listings can be created and managed in the [Dashboard Settings](https://dashboard.linkrunner.io/dashboard/settings/store-listings)
* The `get-attribution-result` endpoint returns a 204 status code with no body when no attribution data is found
For any help please reach out to [support@linkrunner.io](mailto:support@linkrunner.io)
# Events Capture API
Source: https://docs.linkrunner.io/api-reference/event-capture
Documentation for Linkrunner Event Capture API
**Using an AI coding agent?** Let it instrument your events and revenue correctly (taxonomy, dedupe, refunds, and these server-side APIs):
```bash theme={null}
npx @linkrunner/skills add events
```
See [Linkrunner Agent Skills](/ai-agent-skills).
This documentation is for tracking custom events from your backend only! For tracking events from your app please go through the [Flutter](https://docs.linkrunner.io/sdk/flutter/usage#tracking-custom-events) or [React Native](https://docs.linkrunner.io/sdk/react-native/usage#tracking-custom-events) documentation.
Events are stored for all users, including organic ones. Events from users with no matching click are stored without campaign attribution. Each request must identify the user with `user_id` or `install_instance_id`. You can verify your events are being captured on the [Events](https://dashboard.linkrunner.io/dashboard/events) page. For capturing revenue, it is recommended to use the [capture-payment](/api-reference/revenue-tracking) API instead of capture-event.
Use [Test Custom Events and Payments](/testing/event-and-revenue-testing) to decide whether an action belongs in this API or the Revenue Tracking API.
## Base URL
```
https://api.linkrunner.io/api/v1
```
## Authentication
Generate your server key from [https://dashboard.linkrunner.io/settings?s=data-apis](https://dashboard.linkrunner.io/settings?p_id=4\&s=data-apis)
In the request header add the below attribute:
```
linkrunner-key: YOUR-SERVER-KEY
```
## Capture Event
```
POST: /capture-event
```
### Request Body
```json theme={null}
{
"event_name": "product_viewed",
"event_data": {
"product_id": "ABC123",
"category": "electronics",
"amount": 249.99,
"currency": "USD",
"is_featured": true
},
"user_id": "user_12345",
"event_id": "evt_12345"
}
```
| Parameter | Type | Description |
| --------------------- | ------ | ------------------------------------------------------------------------------------------------------------------ |
| event\_name | string | **Required**. Name of the event to track |
| event\_data | object | **Optional**. Additional data associated with the event |
| user\_id | string | **Required** unless `install_instance_id` is sent. User identifier to associate with the event |
| install\_instance\_id | string | **Required** unless `user_id` is sent. Linkrunner's ID for the install |
| event\_id | string | **Optional**. Your own unique identifier for the event, useful for deduplication and correlating with your backend |
### Responses
1. **200** Event captured successfully
2. **400** Missing required parameters, or neither `user_id` nor `install_instance_id` was sent
3. **401** Invalid server key
#### Sample Response
Upon successful event capture, the API returns:
```json theme={null}
{
"msg": "Event capture request received!",
"status": 200,
"data": null
}
```
## Common Event Names
Here are some common event names you might want to track:
| Event Name | Description |
| ---------------------- | ---------------------------------- |
| `purchase_initiated` | User starts a purchase |
| `purchase_completed` | User completes a purchase |
| `item_viewed` | User views an item/product |
| `cart_added` | User adds item to cart |
| `checkout_started` | User starts checkout |
| `search_performed` | User performs a search |
| `content_viewed` | User views content |
| `level_completed` | User completes a level (for games) |
| `achievement_unlocked` | User unlocks an achievement |
| `user_referred` | User refers someone |
## Revenue Sharing with Ad Networks
To enable revenue sharing with ad networks like Google Ads and Meta, include an `amount` parameter as a number in your custom event data. This allows the ad networks to optimize campaigns based on the revenue value of conversions:
```json theme={null}
{
"event_name": "purchase_completed",
"event_data": {
"product_id": "ABC123",
"category": "electronics",
"amount": 149.99
},
"user_id": "user_12345"
}
```
For revenue sharing with ad networks to work properly, ensure the `amount`
parameter is passed as a number, not as a string.
## Best Practices
1. **Consistent naming**: Use consistent naming conventions for your events (snake\_case is recommended)
2. **Structured data**: Include structured data with each event to get more insights
3. **Meaningful events**: Track events that provide valuable insights into user behavior
4. **Data efficiency**: Don't include sensitive or unnecessary data in event payloads
## Example
### Tracking a Purchase Event
```javascript theme={null}
// Using fetch API
fetch("https://api.linkrunner.io/api/v1/capture-event", {
method: "POST",
headers: {
"Content-Type": "application/json",
"linkrunner-key": "YOUR-SERVER-KEY",
},
body: JSON.stringify({
event_name: "purchase_completed",
event_data: {
order_id: "ORD-12345",
product_ids: ["P-001", "P-002"],
total_amount: 125.99,
currency: "USD",
payment_method: "credit_card",
},
user_id: "user_12345",
}),
})
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => console.error("Error:", error));
```
## Error Handling
The API will return appropriate HTTP status codes along with error messages when issues occur:
* **400 Bad Request**: Check your request parameters
* **401 Unauthorized**: Verify your server key
* **429 Too Many Requests**: You've exceeded the rate limit, please try again later
* **500 Internal Server Error**: Contact support if this persists
For any help please reach out to [support@linkrunner.io](mailto:support@linkrunner.io)
## Meta Ecommerce Events
If you are tracking Ecommerce events (like `add_to_cart` or `view_content`) to sync with Meta Catalog Sales, **you must first map your custom event with the standard commerce event in the Linkrunner Dashboard** before sending the event.
*Note: Any event you want to send for an add to cart action should be mapped with **AddToCart** for Commerce Event Manager. For example, map **add\_to\_cart** with **AddToCart**.*
*Similarly, any event you want to send for viewing a product should be mapped with **ViewContent**. For example, map **item\_viewed** with **ViewContent** or **view\_content** with **ViewContent**.*
While you can include any custom attributes in the `event_data` object, Meta requires specific fields for ecommerce events in order to correctly attribute catalog sales and optimize campaigns.
### Example Ecommerce Payload
Here is an example of the exact `event_data` structure you need to send for Meta Commerce Manager:
For comprehensive details on each field requirement, refer to our [Meta Commerce Manager](/ecommerce-manager/meta-commerce-manager#understanding-event_data) documentation.
```json theme={null}
{
"event_name": "add_to_cart",
"event_data": {
"content_ids": ["whshct4mwc"],
"contents": [
{
"id": "whshct4mwc",
"quantity": 2,
"item_price": 1000
}
],
"content_type": "product",
"currency": "INR",
"value": 2000.0,
"num_items": 2,
"order_id": "order_id_1234"
},
"user_id": "user_12345"
}
```
To verify your events are being correctly received by Meta, please follow our [Testing Ecommerce Events](/ecommerce-manager/meta-commerce-manager#testing-ecommerce-events) guide.
# Reporting API
Source: https://docs.linkrunner.io/api-reference/reporting-campaigns
Programmatic access to campaign analytics: clicks, installs, signups, spend, revenue, ROAS, ad sets, ad creatives, and keywords.
The Reporting API returns the same campaign analytics shown on the Linkrunner dashboard.
## Base URL
```
https://api.linkrunner.io/api/v1
```
## Authentication
Pass your project's server key in the `linkrunner-key` header. This is the same key used by other `/api/v1` endpoints. Find it under [Settings → Data APIs](https://dashboard.linkrunner.io/settings?s=data-apis):
```
linkrunner-key: YOUR_API_KEY
```
## Postman collection
[Download the Postman collection](https://gist.githubusercontent.com/RathodDarshil/20d04e80c2d97f8280e1b3beffb58465/raw/reporting-campaigns.postman_collection.json). Import it into Postman, set the `linkrunner_key` collection variable to your key, and run any request.
## Rate limit and freshness
* **1 request per minute per API key** (`429` with `Retry-After: 60` when exceeded).
* 30 req/sec per source IP (shared across all `/api/v1` endpoints).
* Underlying analytics refresh on roughly the same cadence, so cache responses for at least 60 seconds.
## Endpoint
```
GET https://api.linkrunner.io/api/v1/reporting/campaigns
```
Do not call the docs page URL (`/api-reference/reporting-campaigns`) as the API endpoint. The API path is `/api/v1/reporting/campaigns`.
### Example request
```bash theme={null}
curl 'https://api.linkrunner.io/api/v1/reporting/campaigns?from=2026-04-01&to=2026-04-30&network=meta&limit=20' \
-H 'linkrunner-key: YOUR_API_KEY'
```
```js theme={null}
const response = UrlFetchApp.fetch(
"https://api.linkrunner.io/api/v1/reporting/campaigns?from=2026-04-01&to=2026-04-30&limit=20",
{
method: "get",
headers: {
"linkrunner-key": "YOUR_API_KEY",
},
}
);
const data = JSON.parse(response.getContentText());
```
### Response
```json theme={null}
{
"msg": "Campaigns fetched successfully",
"status": 200,
"data": {
"campaigns": [ /* see TypeScript types below */ ],
"pagination": { "total": 142, "pages": 8, "page": 1, "limit": 20 },
"display_currency": "USD",
"view": "user_acquisition"
}
}
```
`pagination.total` is the number of campaigns matching your filters, and `pages` is `total / limit` rounded up.
Numeric fields are returned as **formatted strings** (`"3,201"`, `"$12,540.50"`). Strip the formatting before doing math: `Number(value.replace(/[^0-9.-]/g, ""))`.
### Query parameters
| Parameter | Type | Default | Notes |
| ----------------- | ------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `from`, `to` | string | (none) | `YYYY-MM-DD`, inclusive, in your project's timezone. `start_date` / `end_date` are accepted aliases. Sets the metrics window; never filters the campaign list (see note below). |
| `active` | string | (all) | `true` or `false`. |
| `network` | string | (all) | `meta`, `google`, `apple_search_ads`, `tiktok`, `snapchat`, `sandbox_ads`, etc. |
| `meta_account_id` | number | (none) | Restrict to a single Meta network account. |
| `platform` | string | (both) | `ios` or `android`. |
| `search` | string | (none) | Matches name, display ID, domain, deeplink, or full tracking URL. |
| `view` | string | `user_acquisition` | `user_acquisition` (new users: `installs`, `cost_per_install`) or `retargeting` (re-engaged users: `reinstalls`, `reengagements`, `cost_per_reinstall`). |
| `sort_field` | string | `installs` | `clicks`, `installs`, `signups`, `revenue`, `spend`, `created_at`, `uninstalls`, `conversion`, `suspicious_installs`, `roas`. |
| `sort_order` | string | `descending` | `ascending` or `descending`. |
| `page` | number | `1` | 1-indexed. |
| `limit` | number | `10` | **Max 100.** Larger values return `422`. |
`from` and `to` choose the window over which metrics (clicks, installs, revenue, spend) are computed. They do not filter which campaigns are returned: a campaign created after `to` still appears, with zeros for the window. This matches the dashboard campaign table, where the date picker changes the numbers, not the list. To fetch only campaigns created in a range, see [Troubleshooting](#troubleshooting).
See [Event columns](#event-columns) below for `events`, `payment_events`, `unique_user_events`, `cost_per_event`, and `active_users`.
### Event columns
Event columns are opt-in. Name the events you want and the response carries those and no others, which keeps the payload small and the query fast.
| Parameter | Effect | Example |
| -------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| `events` | Return `custom_events` for these event names. | `events=Purchase,AddToCart` |
| `payment_events` | Return `payment_events` for these revenue types. | `payment_events=PURCHASE` |
| `cost_per_event` | Add `cost_per_custom_events` / `cost_per_payment_events` for these names (spend divided by that event's count). | `cost_per_event=Purchase,PURCHASE` |
| `unique_user_events` | Make these events' `count` distinct users instead of total occurrences. The field name stays `count`. | `unique_user_events=Purchase` |
| `active_users` | Override the active-users window, in days. Defaults to 7. | `active_users=14` |
All five take a comma-separated list. Unknown event names are dropped silently, since they match no rows.
Naming an event in **any** of `events`, `payment_events`, `cost_per_event` or `unique_user_events` puts its count in the response, because a cost-per value is spend divided by that count and a distinct-user count replaces it. The extra columns are what you add on top:
* `events=Purchase` returns `custom_events.Purchase` and nothing else.
* `cost_per_event=Purchase` returns `cost_per_custom_events.Purchase` **and** `custom_events.Purchase`.
* `unique_user_events=Purchase` returns `custom_events.Purchase` whose `count` is distinct users rather than occurrences.
Send none of them and the response contains no event columns at all: `custom_events`, `payment_events`, `cost_per_custom_events` and `cost_per_payment_events` all come back as `{}`. Every other metric (clicks, installs, signups, spend, revenue, ROAS, retention) is unaffected.
Find your project's event names under [Dashboard → Events](https://dashboard.linkrunner.io/dashboard/events).
**This changed on 2026-09-11.** Previously the endpoint tried to infer which
events you wanted from the saved dashboard column settings of one of your
project admins, so the same request returned different events for different
projects, changed whenever that person edited their dashboard, and gave no
way to tell which events you were looking at. Those settings are no longer
read.
If you were relying on events appearing without asking for them, add an
`events=` list naming them. If a count dropped, that event was previously
being counted per distinct user: add it to `unique_user_events` as well.
#### Example: two events, with cost-per and distinct users
```bash theme={null}
curl -G 'https://api.linkrunner.io/api/v1/reporting/campaigns' \
-H 'linkrunner-key: YOUR_API_KEY' \
--data-urlencode 'from=2026-04-01' \
--data-urlencode 'to=2026-04-30' \
--data-urlencode 'events=Purchase,AddToCart' \
--data-urlencode 'cost_per_event=Purchase' \
--data-urlencode 'unique_user_events=Purchase'
```
Each campaign, ad set and ad creative in the response then carries:
```json theme={null}
{
"custom_events": {
"Purchase": { "count": "412", "amount": "0.00" },
"AddToCart": { "count": "1,905", "amount": "0.00" }
},
"cost_per_custom_events": { "Purchase": "258.20" },
"payment_events": {},
"cost_per_payment_events": {}
}
```
`Purchase` reports distinct users because it was named in `unique_user_events`; `AddToCart` reports total occurrences. Both use the `count` field, so the parameter you sent is the only thing that tells them apart. `payment_events` is empty because no revenue types were requested.
### Errors
| Status | When |
| ------ | --------------------------------------------------------- |
| `401` | Missing or invalid `linkrunner-key`. |
| `402` | Billing account suspended. Body includes `payment_link`. |
| `422` | `limit` is non-numeric, less than 1, or greater than 100. |
| `429` | Rate limit exceeded. |
| `500` | Unexpected server error. Retry after a short backoff. |
## Troubleshooting
This is expected. `from` and `to` set the metrics window; they never remove campaigns from the list. To keep only campaigns created in a range, request `sort_field=created_at&sort_order=descending`, then drop rows client-side once `created_at` falls before `from`.
The limit is 1 request per minute per API key. Honor the `Retry-After: 60` header and cache responses for at least 60 seconds. The underlying analytics refresh on roughly the same cadence, so polling faster returns the same data.
Compare the same date range and the same `view` (`user_acquisition` vs `retargeting`). The API interprets `from` and `to` in your project's timezone, exactly like the dashboard date picker. If you are doing math on the values, remember they are formatted strings (see the warning under [Response](#response)).
## TypeScript types
```ts theme={null}
export interface ReportingCampaignsResponse {
msg: string;
status: number;
data: {
campaigns: Campaign[];
pagination: { total: number; pages: number; page: number; limit: number };
display_currency?: string;
view?: "user_acquisition" | "retargeting";
};
}
export interface Campaign {
id: number;
active: boolean;
created_at: string;
display_id: string;
link: string;
name: string;
// Counts (formatted strings)
clicks: string;
installs: string;
reinstalls?: string;
reengagements?: string;
"sign-ups": string;
uninstalls?: string;
suspicious_installs?: string;
click_through_attribution?: string;
view_through_attribution?: string;
conversion: string;
// Money (formatted strings, in display_currency)
revenue: string;
revenue_event_count?: string;
spend: string;
roas?: string;
cost_per_install?: string;
cost_per_reinstall?: string;
cost_per_signup?: string;
// Network flags
meta: boolean;
meta_web_to_app: boolean;
google: boolean;
google_web_to_app: boolean;
tiktok?: boolean;
snapchat?: boolean;
linkedin?: boolean;
ad_network_code?: string;
// Platforms
ios: boolean;
android: boolean;
// Misc
activity_in_last_x_days: number;
has_date_filter: boolean;
domain?: { id: number; name: string } | null;
custom_store_listing: string | null;
active_users?: number | string;
// Custom & payment events: map of event name → count/amount
custom_events?: { [name: string]: { count: string; amount?: string } | string };
payment_events?: { [type: string]: { count: string; total: string } };
cost_per_custom_events?: { [name: string]: string };
cost_per_payment_events?: { [type: string]: string };
// Retention buckets (rates as percentages). `retention` carries cumulative retention.
retention: { d1: number | string; d7: number | string; d14: number | string; d30: number | string };
rolling_retention?: { d1: number | string; d7: number | string };
classic_retention?: { d1?: number | string; d7?: number | string; d14?: number | string; d30?: number | string };
daywise_revenue?: { d0?: number | string; d3?: number | string; d7?: number | string; d30?: number | string };
// Connected ad-account (Meta / Google / Apple Search Ads)
network_account?: {
id: number;
name: string;
email: string;
status: string;
capi_configured: boolean;
account_name?: string;
external_customer_id?: string;
} | null;
// Hierarchy: campaign → adSets → adCreatives + keywords
adSets?: AdSet[];
}
export interface AdSet {
id: string;
name: string;
clicks: string;
installs: string;
"sign-ups": string;
spend: string;
revenue: string;
revenue_event_count?: string;
conversion: string;
roas?: string;
cost_per_install?: string;
cost_per_signup?: string;
suspicious_installs?: string;
custom_events?: Campaign["custom_events"];
payment_events?: Campaign["payment_events"];
cost_per_custom_events?: { [name: string]: string };
cost_per_payment_events?: { [type: string]: string };
daywise_revenue?: Campaign["daywise_revenue"];
ios: boolean;
android: boolean;
retention: { d1: number | string; d7: number | string; d14: number | string; d30: number | string };
rolling_retention?: { d1: number | string; d7: number | string };
adCreatives?: AdCreative[];
keywords?: Keyword[];
}
export interface AdCreative {
id: string;
name: string;
type: string;
deeplink: number;
clicks: string;
installs: string;
"sign-ups": string;
spend: string;
revenue: string;
revenue_event_count?: string;
conversion: string;
roas?: string;
cost_per_install?: string;
cost_per_signup?: string;
custom_events?: Campaign["custom_events"];
payment_events?: Campaign["payment_events"];
cost_per_custom_events?: { [name: string]: string };
cost_per_payment_events?: { [type: string]: string };
retention: { d1: number | string; d7: number | string; d14: number | string; d30: number | string };
rolling_retention?: { d1: number | string; d7: number | string };
}
// Apple Search Ads keyword-level rows. Same shape as AdCreative metrics.
export interface Keyword {
id: string;
name: string;
clicks: string;
installs: string;
"sign-ups": string;
spend: string;
revenue: string;
revenue_event_count?: string;
conversion: string;
roas?: string;
cost_per_install?: string;
cost_per_signup?: string;
suspicious_installs?: string;
custom_events?: Campaign["custom_events"];
payment_events?: Campaign["payment_events"];
cost_per_custom_events?: { [name: string]: string };
cost_per_payment_events?: { [type: string]: string };
retention?: { d1: number | string; d7: number | string; d14: number | string; d30: number | string };
rolling_retention?: { d1: number | string; d7: number | string };
}
```
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Revenue Tracking API
Source: https://docs.linkrunner.io/api-reference/revenue-tracking
Documentation for Linkrunner Revenue Tracking API
Revenue is stored for all users, including organic ones. Payments from users with no matching click are stored without campaign attribution. Each request must identify the user with `user_id` or `install_instance_id`. You can verify your events are being captured on the [Events](https://dashboard.linkrunner.io/dashboard/events) page.
Use [Test Custom Events and Payments](/testing/event-and-revenue-testing) to verify event classification, payment fields, and deduplication.
## Base URL
```
https://api.linkrunner.io/api/v1
```
## Authentication
Generate your server code from [https://dashboard.linkrunner.io/settings?s=data-apis](https://dashboard.linkrunner.io/settings?p_id=4\&s=data-apis)
In the request header add the below attribute:
```
linkrunner-key: YOUR-SERVER-KEY
```
## Capture payment
```
POST: /capture-payment
```
### Request Body
```json theme={null}
{
"user_id": "666",
"payment_id": "ABC", // required, must be unique for each transaction
"amount": 25096, // Send amount in one currency only
"type": "FIRST_PAYMENT", // optional
// "type": "SECOND_PAYMENT", // optional
"status": "PAYMENT_COMPLETED" // optional
}
```
**NOTE:** If you accept payments in multiple currencies convert them to one currency before calling the API.
**Deduplication:** We perform idempotent deduplication using a combination of `type` and `payment_id`. For each unique combination of payment type and payment ID, only one record will be created. If you send multiple requests with the same `type` and `payment_id` combination, only the first will be recorded and subsequent ones will be ignored. To ensure proper deduplication, always provide a unique `payment_id` for each transaction of a given type.
### Payment types
* **FIRST\_PAYMENT**: User's first payment
* **SECOND\_PAYMENT**: User's second payment
* **WALLET\_TOPUP**: Adding funds to a wallet
* **FUNDS\_WITHDRAWAL**: Withdrawing funds
* **SUBSCRIPTION\_CREATED**: Subscription started
* **SUBSCRIPTION\_RENEWED**: Subscription renewed
* **ONE\_TIME**: One-time payment
* **RECURRING**: Recurring payment
* **DEFAULT**: Generic/unspecified payment type
If `type` is omitted, it defaults to **DEFAULT**.
### Payment status
* **PAYMENT\_INITIATED**: Payment has been initiated
* **PAYMENT\_COMPLETED**: Payment completed successfully
* **PAYMENT\_FAILED**: Payment attempt failed
* **PAYMENT\_CANCELLED**: Payment was cancelled
If `status` is omitted, it defaults to **PAYMENT\_COMPLETED**.
### Responses
1. **201** Payment captured
2. **401** Invalid server key
## Remove captured payment
```
POST: /remove-payment
```
### Request Body
```json theme={null}
{
"user_id": "666",
"payment_id": "ABC"
}
```
**NOTE:** `user_id` or `payment_id` is required in order to remove a payment entry. If you pass `user_id`, all the payments attributed to that user will be removed!
### Responses
1. **200** Payment entry/entries deleted
2. **400** No payment found with the given payment id or user id
3. **401** Invalid server key
## Meta Ecommerce Events
If you are tracking `Purchase` events to sync with Meta Catalog Sales, **you must first map the purchase event with the standard commerce event in the Linkrunner Dashboard** before sending it.
*Note: Any Payment event you send should be mapped with **Purchase** for Commerce Event Manager. For example, map **FIRST\_PAYMENT** with **Purchase** or **DEFAULT** with **Purchase**.*
While you can include any custom attributes in the `event_data` object, Meta requires specific fields for ecommerce events to correctly attribute catalog sales. Your `event_data` object should include the following fields:
For comprehensive details on each field requirement, refer to our [Meta Commerce Manager](/ecommerce-manager/meta-commerce-manager#understanding-event_data) documentation.
```json theme={null}
{
"user_id": "666",
"payment_id": "ABC",
"amount": 25096,
"type": "DEFAULT", // Event is mapped with the Purchase in the Linkrunner Meta Integration Settings
"status": "PAYMENT_COMPLETED",
"event_data": {
"content_ids": ["whshct4mwc"],
"contents": [
{
"id": "whshct4mwc",
"quantity": 2,
"item_price": 1000
}
],
"content_type": "product",
"currency": "INR",
"value": 2000.0,
"num_items": 2,
"order_id": "order_id_1234"
}
}
```
*Note: In the example above, the `type` field is omitted from the request body, which means it defaults to **DEFAULT**. Ensure this default payment type is properly mapped with **Purchase** in your Linkrunner dashboard.*
To verify your events are being correctly received by Meta, please follow our [Testing Ecommerce Events](/ecommerce-manager/meta-commerce-manager#testing-ecommerce-events) guide.
# Billing Overview
Source: https://docs.linkrunner.io/billing/overview
Understand how billing works on Linkrunner
## Pricing Plans
Linkrunner offers three plans based on your stage and scale:
| Plan | Best For | Price |
| -------------- | ---------------------- | -------------------------------------------- |
| **Free** | Testing and evaluation | \$0 (up to 25,000 installs) |
| **Growth** | Production apps | From \$0.012/install, decreasing with volume |
| **Enterprise** | High-volume apps | Custom pricing |
You can start on the Free plan and upgrade anytime from your [dashboard billing page](https://dashboard.linkrunner.io/dashboard/billing).
## Billable Installs
Every **attributed** install is billable. An attributed install is one Linkrunner can match to a click or impression on a paid or owned marketing source within the attribution window.
**Organic installs are free.** If there's no matching click or impression, we don't bill for it.
**Reinstalls count if they're attributed.** If a user reinstalls your app from a new ad, it's a new billable install. If they reinstall on their own, it's organic and free.
We never bill the same install twice.
## Retargeting & Reattribution
Retargeting that brings back a user who uninstalled your app is billed exactly like any other install. There's no separate retargeting rate.
**A reattributed user is a billable install.** Reattribution is when a user uninstalls your app, returns through a paid or owned source, and reinstalls within the [reattribution window](/features/remarketing). It counts as one attributed install at your normal per-install tier rate.
**Re-engaging an active user is free.** We only ever count installs. If an existing user taps a retargeting ad without reinstalling, there's no new install, so there's nothing to bill.
We never bill the same install twice. A user counts once per attributed install, whether it's their first install or a reattribution.
The reattribution eligibility window (default 90 days) is configurable per project. See [Remarketing & Reattribution](/features/remarketing) for how returns are attributed.
## Free Installs
Every new account receives a **one-time pool of 25,000 free installs**. This pool is shared across all projects in your account. It is not per-project.
* Free installs are consumed as your apps receive attributed installs
* Once the pool is exhausted, you'll need to be on a paid plan to continue tracking
* The free pool does not reset monthly
If a specific project needs its own dedicated free install allocation, contact support to set up a per-project override.
## Volume-Based Pricing
On the Growth plan, your per-install cost decreases as your volume increases. All installs within a billing month are charged at the same tier rate based on your total volume for that month.
Higher volume means a lower rate on **every** install, not just the ones above a threshold. Combined with postpaid billing, this means you never overpay: your rate automatically adjusts to your actual usage each month.
For your exact rate card, visit the [billing page](https://dashboard.linkrunner.io/dashboard/billing) in your dashboard.
## Pay After You Use, Not Before
Unlike most attribution platforms that require upfront commitments or annual contracts, Linkrunner billing is **monthly** and **postpaid**. You only pay after the month is over for the installs you actually used.
* No upfront payments or prepaid credits
* No annual contracts or long-term lock-ins
* No minimum spend requirements
* Invoices are generated on the **1st of each month**, covering usage from the prior calendar month
This means you can scale up during a big launch and scale back down the next month. You'll only ever pay for what you used.
## Invoices & Payments
View and pay invoices from your [dashboard billing page](https://dashboard.linkrunner.io/dashboard/billing). Click **Pay Now** on a pending invoice to see every option available to you.
**Supported payment methods:**
* Bank transfer (INR invoices)
* [Linkrunner Wallet](/billing/wallet)
* Razorpay (cards, UPI, netbanking)
* Cashfree (cards, UPI, netbanking, for INR invoices)
* Stripe (cards, for USD invoices)
* PayPal
You'll receive an email notification when a new invoice is ready.
### Pay by bank transfer
Available for **INR invoices**. The **Pay Now** dialog shows the account name, account number, IFSC, and the exact amount due.
Transfer the **full amount shown** from your bank. An invoice is settled in full or not at all, so a partial transfer cannot clear it.
Paste the UTR (transaction reference) from your bank into **Transfer reference (UTR)** and click **Submit reference**. Do this straight after transferring. You do not need to wait for the money to arrive.
We check the reference against our bank statement and mark the invoice paid. It stays **Pending** until then, and you get a confirmation email with a receipt once it clears.
Each reference can be used only once, and one transfer clears one invoice. If you mistype it, submit the correct one after the first is rejected.
### Pay by card, UPI, or netbanking
Click the payment link in the **Pay Now** dialog. It opens a hosted payment page, and the invoice is marked paid as soon as the payment succeeds. There is no approval step.
### Pay from your wallet
If your account has the wallet enabled, a pending invoice is settled from your balance automatically. See [Linkrunner Wallet](/billing/wallet).
## What happens when an invoice is overdue?
| Timeline | Action |
| -------- | ------------------------------------------------------------------------------------------------- |
| Day 1 | Invoice generated and payment link sent |
| Day 5 | Payment reminder sent |
| Day 30 | Account access restricted. Dashboard and reporting API stop until the outstanding invoice is paid |
Attribution tracking continues uninterrupted while access is restricted. **No data is lost**. Dashboard and reporting API access reactivate automatically as soon as the outstanding invoice is cleared.
## Billing Setup
Before your first invoice, you'll need to complete your billing profile:
Enter your company name, address, and tax ID (e.g., GST number) in the billing settings.
Choose between **USD** and **INR**. This determines the currency for all future invoices. Once set, your billing currency cannot be changed from the dashboard. Contact [support@linkrunner.io](mailto:support@linkrunner.io) if you need to update it.
Confirm your plan selection and verify your rate card on the billing page.
Manage all billing settings from your [dashboard](https://dashboard.linkrunner.io/dashboard/billing).
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Linkrunner Wallet
Source: https://docs.linkrunner.io/billing/wallet
Prepay a balance and let your invoices settle themselves
The wallet holds a balance on your account. When an invoice is due, it is settled from that balance instead of you paying it by hand each month.
Billing stays postpaid either way. The wallet changes how an invoice gets paid, not what you are charged.
The wallet is being rolled out account by account and is not enabled everywhere yet. If you do not see **Wallet** on your billing page, contact [support@linkrunner.io](mailto:support@linkrunner.io). You can still pay invoices normally in the meantime. See [Billing Overview](/billing/overview).
## 1. Add funds
Open **Wallet** on your [billing page](https://dashboard.linkrunner.io/dashboard/billing) and click **Add funds**.
We suggest 3, 6, or 12 months of your average bill, based on your last three paid invoices. You can enter your own amount instead.
If your recharge earns bonus free installs, the number is shown before you confirm.
**Bank transfer** (INR): transfer the amount, then submit the UTR. We match it against our bank statement and credit your wallet.
**Card, UPI, or netbanking**: pay through the hosted gateway page. The balance is credited as soon as the payment succeeds.
Once the funds land, any pending invoice is paid from the balance automatically. You do not need to do anything else.
Recharging by bank transfer avoids the gateway entirely, which is the simplest route for large amounts.
## 2. How invoices settle
Two things happen automatically.
**After you add funds.** Any invoice already pending is settled from your balance. An invoice is settled **in full or not at all**, so if your balance is short, the invoice stays pending and the wallet screen shows the top-up needed to clear it.
**When your next invoice is generated.** Your balance is applied as the invoice is created, and the invoice shows the arithmetic:
```
Total amount - Wallet deducted = Net payable
```
If the balance covers the whole bill, the net payable is zero and there is nothing to pay. If it covers part of it, you pay the remainder using any [supported payment method](/billing/overview).
## 3. Available vs locked balance
Your balance is split into two numbers on the wallet screen:
| Balance | What it is |
| ------------- | ------------------------------------------------------ |
| **Locked** | Held against your estimated bill for the current month |
| **Available** | Everything above that, which you can withdraw |
The locked portion is what your next invoice will be settled against, so it cannot be withdrawn. The estimate updates through the month as your installs accumulate.
## 4. Withdraw funds
Click **Withdraw** on the wallet screen and enter an amount up to your available balance.
* The amount is deducted from your balance when you raise the request, so it cannot be spent twice.
* A processing fee is deducted from the payout. The fee and the net amount are shown before you confirm.
* You enter the account name, account number, and IFSC to be paid out to as part of the request.
* If a withdrawal is rejected or the payout fails, the full amount is returned to your balance.
Bonus free installs reward money kept in the wallet. Withdrawing gives up the whole unused bonus, so withdraw only what you are sure you do not need.
## Troubleshooting
Settlement is all-or-nothing. If your balance is less than the invoice, the invoice waits. The wallet screen shows exactly how much more you need.
Transfers are credited once we match your UTR against our bank statement. If it is still uncredited a full working day after you submitted the reference, contact [support@linkrunner.io](mailto:support@linkrunner.io) with the UTR.
The portion locked against your estimated bill for the current month is not withdrawable. Only the available balance can be withdrawn.
Yes, but adding funds by bank transfer is available for INR only. USD accounts recharge through the payment gateway.
No. You are billed for attributed installs at your normal tier rate. The wallet only changes how the invoice is paid.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Google Commerce Manager
Source: https://docs.linkrunner.io/ecommerce-manager/google-commerce-manager
Setting up Google Commerce Manager with Google Ads for ecommerce tracking and attribution via Linkrunner.
## Overview
Google Merchant Center lets you upload your product catalog so it can appear across Google surfaces such as Search, Shopping, Display, and more. To run Shopping or Performance Max campaigns, you need to link your Merchant Center account to Google Ads and have your products approved. Once that's done, you can start sending ecommerce event data through Linkrunner for proper attribution.
**Before you begin, make sure you have:**
* A Google Ads account set up and linked to Linkrunner (see [Google Ads setup](/ad-networks/google-ads))
* Products ready to upload to Google Merchant Center
## Step 1: Create a Google Merchant Center Account and Link to Google Ads
First, create your Google Merchant Center account at [merchant.google.com](https://business.google.com/in/merchant-center). During setup, you'll provide your business name, website URL, and country.
Once your account is created, you need to link it to your Google Ads account so your product data can be used in campaigns.
To link your accounts:
1. In Google Merchant Center, go to **Settings** → **Linked accounts**.
2. Under **Google Ads**, click **Link account**.
3. Enter your Google Ads customer ID and send the link request.
4. In Google Ads, go to **Tools** → **Linked accounts** → **Google Merchant Center** and approve the request.
Once linked, your product catalog will be available to use in Google Ads campaigns like Shopping and Performance Max.
## Step 2: Add Products to Google Merchant Center
With your account set up and linked, the next step is adding your products. You can do this manually, via a spreadsheet feed, or through a scheduled data feed URL.
When adding products, make sure to fill in all required attributes:
* **Title**: clear, descriptive product name
* **Description**: accurate product details
* **Link**: the product page URL on your website
* **Image link**: high-quality product image URL
* **Price**: must match the price on your website
* **Availability**: `in stock`, `out of stock`, or `preorder`
* **Brand**, **GTIN** or **MPN**: for product identification
After uploading, Google will review your products. This typically takes **1–3 business days**. You can monitor the status in **Products** → **All products** in Merchant Center.
## Step 3: Match Product IDs with Your Backend
This is a critical step. The product IDs in Google Merchant Center must exactly match the product IDs you use in your backend and send through Linkrunner events.
In Google Merchant Center, each product has an **ID** (also called `offer_id`). This is the value you'll use when sending ecommerce events through Linkrunner. If these IDs don't match, Google won't be able to attribute events back to the correct products.
Make sure the product `id` you send in your Linkrunner events exactly matches the **ID (`offer_id`)** of the product in Google Merchant Center. A mismatch will break product attribution and lower your campaign performance.
## Step 4: Event Mapping
Once your products are approved and your campaign is live, you need to map your custom app events to the standard Google ecommerce events in the Linkrunner Dashboard. This tells Linkrunner which of your events correspond to which Google conversion actions.
There are **3 essential events** to map:
* Your view item event (e.g., `view_item`, `item_viewed`) → maps to **`view_item`**
* Your add to cart event (e.g., `add_to_cart`) → maps to **`add_to_cart`**
* Your purchase event (e.g., `FIRST_PAYMENT`, `DEFAULT`) → maps to **`ecommerce_purchase`**
*In the Linkrunner Dashboard, go to your Google Ads integration settings and map each of your custom event names to the corresponding standard Google event.*
## Understanding `event_data` for Google
When sending events via the [Capture Event API](/api-reference/event-capture), [Capture Payment API](/api-reference/revenue-tracking), or the Linkrunner SDK (`trackEvent` and `capturePayment` methods), you need to include specific fields in `event_data` so Google can correctly attribute conversions and match products in your Merchant Center catalog.
### Required Fields
| Parameter | Type | Required for | Description |
| ------------- | ------ | ------------------------------------------------ | --------------------------------------------------------------------------------- |
| `content_ids` | array | `view_item`, `add_to_cart`, `ecommerce_purchase` | Product IDs. Must exactly match the **ID (`offer_id`)** in Google Merchant Center |
| `value` | number | `add_to_cart`, `ecommerce_purchase` | Total revenue value. No currency symbols. |
| `currency` | string | `add_to_cart`, `ecommerce_purchase` | 3-letter ISO currency code (e.g., `"INR"`, `"USD"`) |
| `num_items` | number | `add_to_cart`, `ecommerce_purchase` | Total quantity of items |
| `order_id` | string | `ecommerce_purchase` | Unique order ID from your backend |
### view\_item
Fired when a user views a product page.
```javascript SDK theme={null}
await linkrunner.trackEvent(
"view_item", // Map this custom event to "view_item" in the Linkrunner Dashboard
{
content_ids: ["sku_blue_tshirt"], // Must match Merchant Center ID (offer_id)
},
);
```
```json API theme={null}
// POST /capture-event
{
"token": "your_project_token",
"event_name": "view_item",
"user_id": "user_123",
"install_instance_id": "abc-123",
"event_data": {
"content_ids": ["sku_blue_tshirt"] // Must match Merchant Center ID (offer_id)
}
}
```
### add\_to\_cart
Fired when a user adds a product to their cart.
```javascript SDK theme={null}
await linkrunner.trackEvent(
"add_to_cart", // Map this custom event to "add_to_cart" in the Linkrunner Dashboard
{
content_ids: ["sku_blue_tshirt"], // Must match Merchant Center ID (offer_id)
value: 1598, // Required: total value (Google uses this as revenue)
currency: "INR", // Required
num_items: 2, // Required
},
);
```
```json API theme={null}
// POST /capture-event
{
"token": "your_project_token",
"event_name": "add_to_cart",
"user_id": "user_123",
"install_instance_id": "abc-123",
"event_data": {
"content_ids": ["sku_blue_tshirt"], // Must match Merchant Center ID (offer_id)
"value": 1598, // Required: total value (Google uses this as revenue)
"currency": "INR", // Required
"num_items": 2 // Required
}
}
```
### ecommerce\_purchase
Fired when a user completes a purchase.
```javascript SDK theme={null}
await linkrunner.capturePayment({
amount: 2298.00,
userId: "user_456",
paymentId: "order_98765",
type: "FIRST_PAYMENT", // Map this payment type to "ecommerce_purchase" in the Linkrunner Dashboard
status: "PAYMENT_COMPLETED",
eventData: {
content_ids: ["sku_blue_tshirt", "sku_red_shoes"], // Must match Merchant Center ID (offer_id)
value: 2298, // Required
currency: "INR", // Required
num_items: 2, // Required
order_id: "order_98765", // Required for purchase events
},
});
```
```json API theme={null}
// POST /capture-payment
{
"token": "your_project_token",
"payment_id": "order_98765",
"user_id": "user_456",
"amount": 2298.00,
"type": "FIRST_PAYMENT",
"status": "PAYMENT_COMPLETED",
"install_instance_id": "abc-123",
"event_data": {
"content_ids": ["sku_blue_tshirt", "sku_red_shoes"], // Must match Merchant Center ID (offer_id)
"value": 2298,
"currency": "INR",
"num_items": 2,
"order_id": "order_98765"
}
}
```
*The SDK examples above use React Native syntax. The same `eventData` fields apply across all SDKs. For platform-specific examples, refer to the Ecommerce Events section in each SDK guide: [React Native](/sdk/react-native#ecommerce-events), [Flutter](/sdk/flutter#ecommerce-events), [iOS](/sdk/ios#ecommerce-events), [Android](/sdk/android#ecommerce-events).*
The `content_ids` you send must exactly match the **ID (`offer_id`)** of the product in Google Merchant Center (the same ID from Step 3). If they don't match, Google won't be able to link the conversion back to the correct product in your catalog.
The `amount` field in `capturePayment` and the `value` field in `event_data` should be consistent. Both represent the total order value. Google reads `value` from `event_data` for conversion reporting.
## Verifying Events in Google Ads
Once you start sending events through Linkrunner, you can verify they're being received in Google Ads under **Goals → Summary**. You'll see your mapped conversion actions (`view_item`, `add_to_cart`, `ecommerce_purchase`) listed there with their recorded conversion counts.
It can take up to **24 hours** for conversion data to appear after your first events are sent. If you don't see data after that window, double-check your event mapping in the Linkrunner Dashboard and verify the `content_ids` match your Merchant Center catalog.
***
> **Note:** The `event_data` fields above can be sent via the Linkrunner SDK (`trackEvent` and `capturePayment` methods) or directly via the APIs. For full request schemas, refer to the [Capture Event API](/api-reference/event-capture) and [Capture Payment API](/api-reference/revenue-tracking) documentation.
# Meta Commerce Manager
Source: https://docs.linkrunner.io/ecommerce-manager/meta-commerce-manager
Setting up Ecommerce manager events for Meta to ensure proper tracking and attribution.
## Overview
Meta Catalog Sales campaigns dynamically show products from your catalog to users based on their browsing and purchase behavior — for example, retargeting someone who viewed a product but didn't buy. For these campaigns to work, Meta needs real-time signals about how users interact with your products.
By sending ecommerce events (purchases, add to cart, content views) through Linkrunner, you feed Meta the data it needs to match users to the right products and optimize your ad spend. Linkrunner seamlessly syncs these events with your connected Meta dataset.
**Before you begin, make sure you have:**
* A product catalog set up in [Meta Commerce Manager](https://business.facebook.com/commerce)
* Meta Ads integration configured in Linkrunner (see [Meta Ads setup](/ad-networks/meta-ads))
* Events integrated in your app via the Linkrunner SDK or API
## Step 1: Select Catalogue Products
To begin, you need to select your catalogue products within the Meta Commerce Manager. Ensure your product feed is active and products are properly categorized.
Make sure to add products with the correct `content_ids` and `item_group_ids`. There are various ways to add products — for example, manually or via CSV upload.
Click the **+ Add** button to add products to the catalogue. From the Products Manager, you can either add products manually or upload a CSV file to add products with specific content IDs.
Make sure that whenever you add a product to the Meta catalogue, it includes a **content ID**. This content ID should match the product ID on your side, and it must be the same ID you send through the Linkrunner SDK.
When uploading products via CSV, use the `item_group_id` column to assign group IDs to your products. This is how Meta groups product variants together ensure these match the `item_group_ids` you send in your events.
The screenshot below shows exactly where to add the content ID when adding or uploading a product to your Meta catalogue:
Each product in your catalog has a **Content ID** and an **Item group ID**. These are the values you'll use in the `content_ids` or `item_group_ids` fields when sending events — they're how Meta matches your events back to the correct catalog products.
## Step 2: Verify Dataset ID Connection
Once your catalogue is set up, navigate to the events section. Check if the Dataset ID connected with Linkrunner is present and active.
If the Dataset ID is not linked:
1. Press on **Manage Connections**.
2. Find the Dataset ID that is linked with Linkrunner.
3. Turn the connection **ON**.
*Make sure the linking part is successfully completed before proceeding.*
## Step 3: Required Events
View the dataset ID and check the events list. There are **3 essential events** we actually need to send:
* `Purchase` (processed via the [Capture Payment API](/api-reference/revenue-tracking) or SDK)
* `AddToCart` and `ViewContent` (processed via the [Event Capture API](/api-reference/event-capture) or SDK)
*Note: Any event you want to send for these actions should be mapped with their respective standard commerce manager events in the Linkrunner Dashboard. For example, map your custom add to cart event (e.g., **add\_to\_cart**) with **AddToCart**, your view event (e.g., **item\_viewed** or **view\_content**) with **ViewContent**, and your payment event (e.g., **FIRST\_PAYMENT** or **DEFAULT**) with **Purchase**.*
Make sure you have integrated the appropriate APIs in your application to send these events.
The **Catalogue match rate** shown in Commerce Manager measures the percentage of product IDs in your events that successfully match products in your catalog. Meta recommends maintaining a match rate of **90% or higher** for optimal ad targeting and delivery. If your match rate is low, double-check that the `content_ids` or `item_group_ids` in your events exactly match the IDs in your catalog.
## Understanding `event_data`
When sending events via the [Capture Event API](/api-reference/event-capture) or [Capture Payment API](/api-reference/revenue-tracking), you must include specific fields in your `event_data` payload for Meta to correctly attribute and optimize your catalogue sales.
### Required Fields for Ecommerce Events
| Parameter | Type | Description |
| -------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `content_ids` / `item_group_ids` | array | **Required for `Purchase`, `AddToCart`, `ViewContent`.**
`content_ids`: Use an array of single product IDs for variants. Example: `['id1', 'id2']`.
`item_group_ids`: Use for full product groups to boost the entire group. |
| `contents` | array of objects | **Required for `Purchase`, `AddToCart`, `ViewContent`.**
Array detailing items included:
`id`: Matches `content_ids` / `item_group_ids`.
`quantity`: Number of items.
`item_price`: Unit price (must match Meta Products catalog). |
| `content_type` | string | **Required for `Purchase`, `AddToCart`, `ViewContent`.**
Set to `"product"` for `content_ids`, or `"product_group"` for `item_group_ids`. |
| `value` | number | **Required for `Purchase`, `AddToCart`.**
Total numeric value (e.g., `item_price` \* `quantity`). No currency symbols. |
| `currency` | string | **Required when `value` is sent.**
The 3-letter ISO currency code (e.g., `"USD"`, `"INR"`). |
| `num_items` | number | **Required for `Purchase`, `AddToCart`.**
Total quantity of items in the `contents` array. |
| `order_id` | string | **Required for `Purchase`.**
Backend-generated unique purchase order ID. |
The `item_price` in the `contents` array should match the product price in your Meta catalog. Mismatched prices can lower your catalogue match rate and affect ad optimization.
**Example 1: Using `content_ids` (individual product variants)**
Use `content_ids` when you want to target specific product variants (e.g., a particular size or color). Set `content_type` to `"product"`.
```json theme={null}
"event_data": {
// Use content_ids for individual product variants.
// These IDs must exactly match the Content ID in your Meta catalogue.
// For example, "sku_blue_tshirt_m" is the Content ID of a medium blue t-shirt
// and "sku_black_jeans_32" is the Content ID of size-32 black jeans.
"content_ids": [
"sku_blue_tshirt_m",
"sku_black_jeans_32"
],
// Each entry in contents must have an "id" that matches one of the content_ids above.
// "quantity" is how many units the user interacted with.
// "item_price" is the unit price — this should match the price in your Meta catalogue.
"contents": [
{
"id": "sku_blue_tshirt_m",
"quantity": 1,
"item_price": 799
},
{
"id": "sku_black_jeans_32",
"quantity": 1,
"item_price": 1499
}
],
// Set to "product" when using content_ids (individual variants).
"content_type": "product",
// ISO 4217 currency code.
"currency": "INR",
// Total value = sum of (item_price * quantity) for all items.
// 799 * 1 + 1499 * 1 = 2298
"value": 2298.00,
// Total number of items across all entries in contents.
"num_items": 2,
// Unique order ID from your backend (required for Purchase events).
"order_id": "order_98765"
}
```
**Example 2: Using `item_group_ids` (product groups)**
Use `item_group_ids` when you want to boost an entire product group rather than a specific variant. For example, if a user views "Blue T-Shirt" (which comes in S, M, L), you send the group ID so Meta can optimize across all variants. Set `content_type` to `"product_group"`.
```json theme={null}
"event_data": {
// Use item_group_ids to target entire product groups.
// "tshirt_blue" is the Item Group ID in your Meta catalogue that groups
// all size variants (S, M, L, XL) of the blue t-shirt.
// This must match the item_group_id column in your CSV upload
// or the Item Group ID field in Commerce Manager.
"item_group_ids": [
"tshirt_blue"
],
// When using item_group_ids, the "id" in contents should match the group ID.
"contents": [
{
"id": "tshirt_blue",
"quantity": 1,
"item_price": 799
}
],
// Set to "product_group" when using item_group_ids.
"content_type": "product_group",
"currency": "INR",
"value": 799.00,
"num_items": 1,
"order_id": "order_10234"
}
```
**When to use which?**
* Use `content_ids` + `content_type: "product"` when you know the exact variant the user interacted with (e.g., they added "Blue T-Shirt Size M" to cart).
* Use `item_group_ids` + `content_type: "product_group"` when you want Meta to optimize across all variants in a group (e.g., the user viewed the "Blue T-Shirt" product page without selecting a size).
***
> **Note:** For full integration details and request examples, refer to the [Capture Event API](/api-reference/event-capture) and [Capture Payment API](/api-reference/revenue-tracking) documentation.
***
## Testing Ecommerce Events
After you have integrated the APIs and started sending events, you will naturally want to verify everything is working correctly.
1. **Send a Test Event:** Trigger an event using the [Capture Event API](/api-reference/event-capture) (e.g., `add_to_cart`) or the [Capture Payment API](/api-reference/revenue-tracking) (e.g., `Purchase`).
2. **Check the Meta Events Manager:** Navigate to your **Commerce Manager > Events** section.
3. **Wait for Status Updates:** Within **15 minutes** of sending your event, you should start seeing the status reflect that you are successfully hitting the Meta Commerce dataset.
*Note: While the real-time hits will show up in Events Manager within 15 minutes, please be aware that **it can take a few days for the actual data to fully reflect and populate** across all Commerce Manager dashboards.*
## Creating a Catalog Sales Campaign
Once your events are flowing and your catalogue match rate is healthy, you can create a Catalog Sales campaign in Meta Ads Manager:
1. Go to **Meta Ads Manager** → **Create Campaign** → select the **Sales** objective and choose your catalog.
2. Select the catalog that is connected to your Linkrunner dataset.
3. Under ad set, choose your targeting strategy:
* **Broad audience** — Meta finds new customers likely to purchase from your catalog.
* **Retargeting** — target users who have already interacted with your products (e.g., viewed content or added to cart but didn't purchase).
4. Configure your budget, placements, and creative, then publish.
Retargeting audiences are automatically built from the `ViewContent`, `AddToCart`, and `Purchase` events you send through Linkrunner. The more event data Meta receives, the better it can optimize delivery.
# Attribution Windows: Default Click Lookback Windows
Source: https://docs.linkrunner.io/features/attribution-windows
The default click-to-install lookback windows Linkrunner uses per ad network, why Google and Meta are 30 days, and how to change them per project.
**A click window is how far back Linkrunner looks for a click when crediting a new install.** If the matching click is older than the window, it is not used, and the install is recorded as organic instead.
This is applied per ad network, so a click from Google Ads and a click from an affiliate can be judged against different windows.
## Default windows
| Ad network | Default click window |
| :------------------ | :------------------- |
| Google Ads | 30 days |
| Meta | 30 days |
| Every other network | 7 days |
Google and Meta default to 30 days because installs on those networks often report well after the click. Everything else defaults to 7 days.
These defaults apply automatically. You do not need to set anything to get them.
## Change a window
Go to **Settings → Attribution Windows** in the [dashboard](https://dashboard.linkrunner.io/dashboard/settings/attribution-windows).
The timeline shows each connected channel and its current click-to-install window. Italic values use the Linkrunner default for that channel.
Click the current day value for a connected channel. To configure another available channel, click **Add channel** and choose it.
Select a value from **1 day** to **30 days**. Linkrunner saves the change automatically for this project.
To return a channel to its default, select the italic default value. Changes take effect within about five minutes.
## Limits
Windows can be set from **1 to 30 days**.
The 30 day ceiling is not arbitrary. Linkrunner retains clicks for 30 days, so a click older than that cannot be matched to an install at all, and a longer window would have no effect.
## What this does not cover
This setting governs **click matching only**. View-through and probabilistic attribution are not affected.
It is also separate from the **Lookback Window** on the Remarketing page. That one controls the reattribution window for users who already have your app. This one controls how a brand new install gets credited.
# Custom Store Listings
Source: https://docs.linkrunner.io/features/custom-store-listing
Drive users to custom store listings for targeted app marketing
## Overview
Custom Store Listings let you create targeted marketing campaigns with tailored app presentations on Google Play and App Store. This feature integrates seamlessly with Linkrunner, allowing you to direct different audience segments to specialized store listings with unique screenshots, descriptions, and promotional content.
## Custom Store Listings
A **custom store listing** is a separate version of your app's store page with different screenshots, descriptions, and promotional content. It is **not** the same as your regular/default Play Store or App Store link.
Your regular store link (e.g. `https://play.google.com/store/apps/details?id=com.yourapp`) is **not** a custom store listing. Linkrunner already redirects users to your default store link automatically — you don't need to add it here.
### Google Play - Custom Store Listings
On Google Play, custom store listings are alternate versions of your store page targeted at specific audiences. When you create one in the Google Play Console, it generates a URL with a `listing` parameter:
```
https://play.google.com/store/apps/details?id=com.yourapp&listing=your-custom-listing-id
```
The `&listing=your-custom-listing-id` part is what makes it a custom store listing.
Learn how to create custom store listings in the Google Play Console
### App Store - Custom Product Pages
On the App Store, these are called **Custom Product Pages**. When you create one in App Store Connect, it generates a URL with a `ppid` parameter:
```
https://apps.apple.com/app/your-app/id123456789?ppid=your-custom-page-id
```
The `?ppid=your-custom-page-id` part is what makes it a custom product page.
Learn how to create custom product pages in App Store Connect
## How It Works
When creating a marketing campaign, you can specify a custom store listing for users to be directed to when they click on your campaign link. The system automatically appends the correct parameters to the store URLs.
This allows for targeted marketing campaigns with different store presentations for different audience segments.
## Setting Up Custom Store Listings
### Step 1: Create a Custom Listing on Google Play / App Store
Before adding a custom store listing to Linkrunner, you need to create one on the respective platform first:
* **Google Play**: Go to Google Play Console → Store presence → Custom store listings → [Create custom store listing](https://support.google.com/googleplay/android-developer/answer/9867158?hl=en)
* **App Store**: Go to App Store Connect → Custom Product Pages → [Create custom product page](https://developer.apple.com/app-store/custom-product-pages/)
Once created, copy the URL of your custom store listing. It should contain a `listing` parameter (Google Play) or `ppid` parameter (App Store).
### Step 2: Access Store Listings in Linkrunner
Navigate to the Store Listings section in your project settings:
1. Go to [https://dashboard.linkrunner.io/settings?s=store-listings](https://dashboard.linkrunner.io/settings?s=store-listings)
2. Or access it through Settings → Store Listings in the navigation menu
### Step 3: Add the Custom Store Listing
1. Click on the "+ Add Listing" button
2. Fill in the required information:
* **Name**: A descriptive name for your store listing (e.g. "Holiday Campaign - US")
* **Store Listing Link**: The full URL of your custom store listing including the `listing` or `ppid` parameter
3. Click "Create" to save your store listing
Linkrunner will automatically extract the custom listing identifier from the URL. If you see an error, make sure your URL contains the `listing` (Google Play) or `ppid` (App Store) parameter.
### Step 4: Manage Your Store Listings
From the Store Listings dashboard, you can:
* View all your existing store listings
* Edit listings by clicking the edit icon
* Visit the actual store page by clicking the visit icon
* Delete listings when they're no longer needed
## Using Custom Store Listings in Campaigns
### When Creating a Campaign
1. Start creating a new campaign as usual
2. Expand the "More options" section
3. Navigate to the "Redirect to a custom store listing" option
4. Select your desired store listing from the dropdown menu
5. Complete the campaign creation process
The selected store listing will be automatically applied to all links generated from this campaign.
## Best Practices
* **Create Targeted Listings**: Design different store listings for different audience segments or marketing channels
* **Track Performance**: Compare conversion rates between different custom store listings to optimize your marketing strategy
* **Update Regularly**: Keep your custom store listings fresh with updated screenshots and descriptions
* **Combine with Deep Linking**: For the best user experience, pair custom store listings with deep linking to create a seamless journey from ad to in-app content
***
For any questions or assistance with setting up custom store listings, please contact us at [support@linkrunner.io](mailto:support@linkrunner.io).
# Data Export: CSV exports for campaigns and users
Source: https://docs.linkrunner.io/features/data-export
How to export Linkrunner campaign and user data to CSV, what each export type contains, and how to read the numbers correctly.
**Data Export gives you your Linkrunner data as a CSV file.** You choose what you want, we build the file in the background, and we email it to you as an attachment when it is ready. Most exports finish in a few minutes.
There are two places to export from: **Settings** for a whole project, and the **campaign drawer** for a single campaign.
## Export a whole project
Go to **Settings**, open **Data Export**, then the **Export Data** tab. Pick a date range if you want one, then open **Export as CSV** and choose what to export.
The menu is split into two groups, **Install Attribution** and **Re-engagement**, and each group offers the same four datasets. Overall Data has an arrow because it opens into two choices, Campaign and Ad Set and Creative.
Leaving the date range empty exports the last 3 months. You can export at most 3 calendar months at a time.
## Export a single campaign
Open the **Campaigns** page and click any campaign. In the drawer that opens, click the download icon in the top right. You can pick a date range here too.
A campaign export gives you the two user lists, **All Onboarded Users** and **Paying Users**. Overall Data is a project level export only.
**The campaign drawer follows the view the Campaigns page is already on.** There is no separate picker in the drawer. If you switch the Campaigns page to Re-engagement and then export from a campaign, you get re-engagement users. Switch it back to Install Attribution to export install attribution users.
## Install Attribution or Re-engagement
Every export is one of these two views.
| View | What it covers |
| ----------------------- | ------------------------------------------------------------------------ |
| **Install Attribution** | How users first arrived. New installs and the campaigns that drove them. |
| **Re-engagement** | Users you brought back. Re-engagement and retargeting activity only. |
## The four datasets
One row per campaign per day. Use this when you want numbers that match the Campaigns dashboard.
Breaks each campaign down into its ad sets, creatives and keywords. Use this to see which ad set did the work.
One row per user who signed up, with their device and campaign details.
One row per user who paid, with revenue, payment count and payment dates.
### Which Overall Data should I pick?
Pick **Campaign** if you are reconciling against the dashboard or sharing totals with someone.
Pick **Ad Set and Creative** if you are deciding which ad set or creative to keep running.
They are different on purpose, and the next section explains why the totals will not match.
## Sample files
Download any of these to see the exact columns before you run your own export.
Sample CSV
Sample CSV
Sample CSV
Sample CSV
The names, revenue and user details in these samples are made up. The columns and their order are exactly what a real export gives you.
## Reading the numbers correctly
A few things in these files surprise people. None of them are errors.
Some spend and installs cannot be tied to a single ad set. Google Performance Max and Meta Advantage+ campaigns report at campaign level only, so that activity has no ad set to sit under.
The Campaign file includes it. The Ad Set and Creative file cannot, so its totals are lower. The difference between the two files is the amount your ad network did not break down for you.
This is why we offer both files instead of one.
Sign Ups counts each user once across the whole date range, not once per day. A user who signs up twice in a month is still one sign up.
Because of that, the total for the range sits on the campaign's first row and every later row shows 0. Add the column up and you get the correct total, which matches the dashboard. Read a single row on its own and the number will not mean much.
Some column headers say **(per-day, not summable)**, including installs, ROAS, CPI and active users.
These count unique things per day, so adding the days together double counts anyone who appears on more than one day. Use the dashboard for a range total on these columns.
A row of zeros means the campaign was live that day and had no activity. That is real data, so we keep it.
Rows with nothing in them at all are removed, so you should not see blank rows in a current export.
Apple's SKAdNetwork does not report an ad set breakdown for iOS. For Meta and Google iOS campaigns, the Ad Set and Creative file shows the campaign row instead of drilling further down.
An Apple Search Ads ad group is described by its keywords, so those campaigns show keyword rows where other networks show creative rows.
GAID is an Android advertising ID and IDFA is an iOS one, so each is filled for one platform only.
IDFA is also only available when the user allows tracking when iOS asks. For most apps that is a small share of iOS users, so expect the IDFA column to be mostly empty.
In a project level Onboarded Users or Paying Users file, each user appears once, even if they were active on several campaigns. The campaign columns say **Latest Campaign** because they show where that user was last seen.
Grouping a project level user file by campaign will not match per campaign numbers on the dashboard. Export from the campaign drawer instead if you want users for one campaign.
## How the file reaches you
The CSV arrives as an **attachment on an email**, not as a download link. Files above 10 MB are zipped first.
Email has a **25 MB** attachment limit. If an export is still larger than that after zipping, the email cannot be sent and the export is marked failed. Run it again over a shorter date range, or split it into a few exports.
## Limits
| Limit | Value |
| ------------------------ | ------------------- |
| Date range in one export | 3 calendar months |
| Exports per user | 5 every 5 minutes |
| Attachment size | 25 MB after zipping |
The export limit is per user, not per project, so exporting several different projects in a row shares the same budget.
## Currency
Revenue and spend in an Overall Data file are converted to your project's **display currency**, so the numbers line up with what the dashboard shows.
Spend is converted per campaign, because ad accounts bill in their own currency. ROAS is recalculated from the converted revenue and spend rather than taken as is, since the two move at different rates.
## Who can export
Only project **admins** and **managers** can run an export.
## Common questions
Usually a few minutes. Larger date ranges take longer. The file is built in the background, so you can close the page and carry on working while you wait for the email.
No. A single export covers at most 3 calendar months. Run several exports if you need a longer period.
There was no data for the view, dataset and filters you chose in that date range. Widening the date range or clearing filters usually explains it.
Yes. Network, platform, ad account and date range all carry into the file.
Email attachments are capped at 25 MB, and that is measured after we zip the file. A wide date range on a busy project can pass it.
Export a shorter range, or split one export into two or three.
You can start 5 exports every 5 minutes. Past that the button will not queue anything new until the window resets.
Each export takes a few minutes to arrive, so this is usually only reached by clicking repeatedly while waiting.
# Create Deep Links for App Destinations
Source: https://docs.linkrunner.io/features/deep-link-management
Create deep links with dynamic app destination parameters, with or without campaign reporting
Create links for app destinations such as `product/{variant_id}` by adding your own query parameters to a Linkrunner URL. Your app receives these parameters in the `deeplink` field returned by `getAttributionData()`.
You do not need to create a separate deep link record for every product, offer, or screen.
## Before you start
* [Configure a Linkrunner subdomain](/features/subdomain-setup), such as `get.example.com`.
* [Set up deep linking in your app](/features/deep-linking-setup).
* Decide which query parameters your app uses. For example, `destination=product&variant_id=987`.
## Choose a link type
| What you need | Link to use | Dashboard reporting |
| ------------------------------------------------------------- | ------------------------------------------------------- | ------------------- |
| Attribute clicks and installs to a marketing campaign | A generated campaign link with your parameters appended | Yes |
| Send users to an app destination without campaign attribution | Your Linkrunner subdomain with your parameters appended | No |
## Track the link as a campaign
Open [Create Campaign](https://dashboard.linkrunner.io/dashboard?m=create-campaign), enable **Deferred Deep Linking**, and enter the base destination your app handles.
For a product page, the base destination could be:
```text theme={null}
https://get.example.com/product
```
Copy the campaign link generated by Linkrunner. Append the values when you display or send the link.
```text theme={null}
Campaign link:
https://get.example.com/AbCdEf
Link for variant 987:
https://get.example.com/AbCdEf?variant_id=987
```
Add more app-defined values when needed:
```text theme={null}
https://get.example.com/AbCdEf?variant_id=987&promo=summer
```
If the generated campaign link already contains a query string, add your first parameter with `&` instead of `?`. Keep the campaign identifier unchanged.
A value already set on the campaign's deferred deep link takes priority over a value with the same name on the clicked link. Keep dynamic values, such as `variant_id`, out of the campaign destination.
After the user installs and opens your app, call `getAttributionData()`. Linkrunner adds the parameters from the clicked campaign link to the configured destination:
```json theme={null}
{
"deeplink": "https://get.example.com/product?variant_id=987&promo=summer"
}
```
Parse `variant_id` and route the user to the matching product page. The campaign click and install remain available in Linkrunner reporting.
## Use a deep link without campaign reporting
If you do not need campaign attribution, use your Linkrunner subdomain directly:
```text theme={null}
https://get.example.com/product?variant_id=987&promo=summer
```
Linkrunner returns the URL in the `deeplink` field from `getAttributionData()`. Your app parses the parameters and opens the destination as usual.
Direct subdomain links are not associated with a campaign, so they do not appear as campaign performance in the dashboard.
## Get attribution data
Use the SDK guide for your platform:
* [Android](/sdk/android#getting-attribution-data)
* [iOS](/sdk/ios#getting-attribution-data)
* [React Native](/sdk/react-native#getting-attribution-data)
* [Flutter](/sdk/flutter#getting-attribution-data)
* [Capacitor](/sdk/capacitor#getting-attribution-data)
* [Cordova](/sdk/cordova#getting-attribution-data)
* [Unity](/sdk/unity#get-attribution-data)
## Testing
1. Uninstall the app from a real device.
2. Open the complete link, including its query parameters.
3. Install and open the app.
4. Confirm that `getAttributionData()` returns the expected `deeplink`.
5. Confirm that your app opens the correct destination.
Deferred attribution is stored from the first install. Uninstall the app before repeating a test with different parameters.
## Troubleshooting
**The parameters are missing from `deeplink`.** Confirm that the parameters were present on the URL the user clicked. Check whether another redirect or URL shortener removed them.
**The link does not appear under a campaign.** Direct subdomain links do not have campaign reporting. Create a campaign and append your parameters to its generated link.
**`deeplink` is null.** Initialize the Linkrunner SDK before calling `getAttributionData()`. For deferred deep link testing, install the app through the test link.
**The wrong screen opens.** Confirm that your app maps the destination and parameter names correctly. Linkrunner forwards the values, while your app controls navigation.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Deep Linking Setup
Source: https://docs.linkrunner.io/features/deep-linking-setup
Complete guide to setting up, verifying, and debugging deep links with Linkrunner for React Native and Flutter apps
Deep links allow users to navigate directly to specific content within your app by clicking on a URL. This guide walks you through the complete setup, from creating your verification config files and saving them in Linkrunner to making the necessary code changes in your app.
There are two primary approaches to deep linking:
1. **[HTTP/HTTPS Deep Links](#httphttps-deep-linking)**: URLs with `http://` or `https://` protocols that can open your app when clicked. Requires domain verification.
2. **[Custom URI Schemes](#custom-uri-schemes)**: URLs with a custom protocol like `myapp://` that are registered to your app. No verification needed.
***
## HTTP/HTTPS Deep Linking
HTTP/HTTPS deep links (including App Links on Android and Universal Links on iOS) require you to prove domain ownership before they work reliably. The setup has four parts:
1. Create your verification config files
2. Save them in Linkrunner
3. Update native configuration (Android & iOS)
4. Configure your app's navigation
### Step 1: Create Verification Config Files
#### Create the Digital Asset Links file
Create a file named `assetlinks.json` with the following content:
```json theme={null}
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "your.package.name",
"sha256_cert_fingerprints": ["SHA-256:XX:XX:XX:..."]
}
}
]
```
Replace:
* `your.package.name` with your actual Android package name
* `SHA-256:XX:XX:XX:...` with your app's SHA-256 fingerprint
#### Get your app's SHA-256 fingerprint
For debug builds:
```bash theme={null}
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android
```
For release builds:
```bash theme={null}
keytool -list -v -keystore your_release_keystore.keystore -alias your_key_alias
```
Look for the "SHA-256 Certificate fingerprint" line in the output.
Debug and release builds are signed with different keystores, so they produce different SHA-256 fingerprints. App Links verification will only succeed for the build whose fingerprint is currently saved in Linkrunner.
Before testing or shipping a release build, either:
* Update the `sha256_cert_fingerprints` value in Linkrunner to your release keystore's fingerprint, or
* List both fingerprints in the array so debug and release builds both verify:
```json theme={null}
"sha256_cert_fingerprints": [
"SHA-256:DEBUG:FINGERPRINT:...",
"SHA-256:RELEASE:FINGERPRINT:..."
]
```
If you use Google Play App Signing, get the release fingerprint from **Play Console → Setup → App integrity**, not your local upload keystore.
#### Create the Apple App Site Association file
Create a file named `apple-app-site-association` (no file extension) with the following content:
```json theme={null}
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAM_ID.BUNDLE_ID",
"paths": ["/*"]
}
]
}
}
```
Replace:
* `TEAM_ID` with your Apple Developer Team ID (found in the [Apple Developer Portal](https://developer.apple.com/account) under Membership Details)
* `BUNDLE_ID` with your app's bundle identifier (found in your Xcode project settings)
The `paths` array can be customized to include only specific paths your app should handle. Use `/*` to handle all paths.
### Step 2: Save Verification Config in Linkrunner
Linkrunner hosts your verification files automatically so you don't have to manage server configuration yourself.
1. Open [Settings → Deeplinks](https://dashboard.linkrunner.io/dashboard/settings/deeplinks) in your Linkrunner dashboard
2. Paste your `apple-app-site-association` JSON into **iOS Verification Object**
3. Paste your `assetlinks.json` content into **Android Verification Object**
4. Click **Save Changes**
Linkrunner will automatically host these files at:
* iOS: `https://your-domain.io/.well-known/apple-app-site-association`
* Android: `https://your-domain.io/.well-known/assetlinks.json`
### Step 3: Update Native Configuration
These changes are the same whether you're using React Native or Flutter.
Open `android/app/src/main/AndroidManifest.xml` and add the following inside the `` section:
```xml theme={null}
```
#### Enable install detection (Continue to app popup)
To let the Linkrunner redirect page detect that your app is installed and show a [Continue to app popup](/features/link-redirection#open-in-app-popup) instead of sending the user to the store, declare an `asset_statements` link back to your subdomain.
Add the statement to `android/app/src/main/res/values/strings.xml`:
```xml theme={null}
[{\"relation\": [\"delegate_permission/common.handle_all_urls\"],
\"target\": {\"namespace\": \"web\", \"site\": \"https://your.subdomain.com\"}}]
```
Then reference it from `AndroidManifest.xml`, inside the `` section:
```xml theme={null}
```
Replace `https://your.subdomain.com` with your Linkrunner subdomain. This is the app→site half of the Digital Asset Link. Linkrunner already hosts the site→app half at `/.well-known/assetlinks.json` on your subdomain.
1. Open your iOS project in Xcode
2. Go to **Signing & Capabilities**
3. Add the **Associated Domains** capability
4. Add your domains:
```
applinks:example.com
applinks:app.example.com
applinks:store.example.com
```
For more details, see the [official Apple documentation](https://developer.apple.com/documentation/xcode/supporting-associated-domains).
### Step 4: Configure Navigation
React Native uses [React Navigation](https://reactnavigation.org/) for handling deep links.
```javascript theme={null}
// App.js or your navigation configuration file
import { NavigationContainer } from "@react-navigation/native";
import { createStackNavigator } from "@react-navigation/stack";
import linkrunner from "rn-linkrunner";
const Stack = createStackNavigator();
function App() {
const linking = {
prefixes: [
"https://example.com",
"https://app.example.com",
"https://store.example.com",
],
config: {
screens: {
Home: "",
Profile: "profile/:id",
Store: {
path: "store/:category?",
parse: {
category: (category) => category || "all",
},
},
"app.example.com": {
screens: {
AppSpecificScreen: ":id",
},
},
"store.example.com": {
screens: {
StoreSpecificScreen: ":id",
},
},
},
},
};
return (
{/* Your screens */}
);
}
export default App;
```
Flutter uses [go\_router](https://pub.dev/packages/go_router) or its own navigation system to handle deep links.
```dart theme={null}
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:linkrunner/linkrunner.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
MyApp({Key? key}) : super(key: key);
final GoRouter _router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) => HomeScreen(),
),
GoRoute(
path: '/profile/:id',
builder: (context, state) {
final id = state.params['id']!;
return ProfileScreen(id: id);
},
),
GoRoute(
path: '/app/:id',
builder: (context, state) {
final id = state.params['id']!;
return AppSpecificScreen(id: id);
},
),
GoRoute(
path: '/store/:category',
builder: (context, state) {
final category = state.params['category'] ?? 'all';
return StoreScreen(category: category);
},
),
],
redirect: (context, state) {
final uri = Uri.parse(state.location);
if (uri.host == 'app.example.com') {
return '/app/${uri.pathSegments.isNotEmpty ? uri.pathSegments.first : ''}';
} else if (uri.host == 'store.example.com') {
return '/store/${uri.pathSegments.isNotEmpty ? uri.pathSegments.first : 'all'}';
}
return null;
},
);
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: _router,
title: 'My App',
);
}
}
```
***
## Custom URI Schemes
Custom URI schemes use a custom protocol like `myapp://` and don't require domain verification. They're useful for backward compatibility or simpler setups.
### Native Configuration
Open `android/app/src/main/AndroidManifest.xml` and add inside the `` section:
```xml theme={null}
```
1. Open your iOS project in Xcode
2. Go to the **Info** tab
3. Add a new entry to **URL Types** with:
* Identifier: Your app bundle identifier (e.g., `com.example.myapp`)
* URL Schemes: Your custom scheme (e.g., `myapp`)
In `Info.plist`:
```xml theme={null}
CFBundleURLTypes
CFBundleTypeRole
Editor
CFBundleURLName
com.example.myapp
CFBundleURLSchemes
myapp
```
***
## Testing Your Deep Links
Use `adb` to test:
```bash theme={null}
# Test HTTP/HTTPS deep links
adb shell am start -a android.intent.action.VIEW -d "https://app.example.com/profile/123" your.package.name
# Test custom URI scheme
adb shell am start -a android.intent.action.VIEW -d "myapp://profile/123" your.package.name
```
If the HTTPS link opens the browser instead of your app, see [Debugging Domain Verification](#debugging-domain-verification).
Use the iOS Simulator:
```bash theme={null}
# Test HTTP/HTTPS deep links
xcrun simctl openurl booted "https://app.example.com/profile/123"
# Test custom URI scheme
xcrun simctl openurl booted "myapp://profile/123"
```
If the link opens Safari instead of your app, see [Debugging Domain Verification](#debugging-domain-verification).
Create actual links on your website and test on real devices:
* For HTTP/HTTPS links: `https://app.example.com/profile/123`
* For custom URI schemes: `myapp://profile/123`
***
## Debugging Domain Verification
If an HTTPS link opens the browser instead of your app, domain verification is the usual cause. Work through these checks to find where it's failing.
**Prefer to let your AI coding agent diagnose it?** Run:
```bash theme={null}
npx @linkrunner/skills add deep-links
```
The skill runs the full gauntlet below automatically (hosted files, Apple CDN freshness, Android fingerprint + verification state, native wiring) and tells you exactly what is broken. See [Linkrunner Agent Skills](/ai-agent-skills).
Confirm the file Linkrunner hosts for you is live and correct:
```bash theme={null}
curl https://your-domain.io/.well-known/assetlinks.json
```
If it returns a 404, your config isn't saved. Re-save it in [Project Settings → Domain Verification](https://dashboard.linkrunner.io/settings?sort_by=activity-1\&s=store-verification). If it loads, check that `package_name` matches your app and `sha256_cert_fingerprints` includes the fingerprint of the keystore that signed the build you're testing.
You can also have Google's Digital Asset Links API validate it, which surfaces formatting errors:
```bash theme={null}
curl "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://your-domain.io&relation=delegate_permission/common.handle_all_urls"
```
On Android 12+, print the verification state of every domain in your manifest:
```bash theme={null}
adb shell pm get-app-links your.package.name
```
```text theme={null}
your.package.name:
ID: 01234567-89ab-cdef-0123-456789abcdef
Signatures: [F2:52:4D:82:...]
Domain verification state:
example.com: verified
app.example.com: 1024
```
| State | Meaning |
| ------------------------------------- | ---------------------------------------------------------------------------------- |
| `verified` | Verification passed. Links for this domain open your app. |
| `none` | Verification hasn't run yet. It runs automatically about 20 seconds after install. |
| `1024` (or higher) / `legacy_failure` | Verification failed. Check the fingerprint and hosted file. |
| `approved` | Manually approved, e.g. by the user in app settings. |
| `denied` | Manually disallowed by the user in app settings. |
On Android 11 and below, run `adb shell dumpsys package domain-preferred-apps` instead, and watch `adb logcat | grep IntentFilter` during install for verification messages.
Android caches verification results, so fixing `assetlinks.json` has no effect until verification runs again. Clear the cached state and re-run it:
```bash theme={null}
# Clear the cached verification state for all domains
adb shell pm set-app-links --package your.package.name 0 all
# Re-run verification against the hosted assetlinks.json
adb shell pm verify-app-links --re-verify your.package.name
# Wait a few seconds, then check the result
adb shell pm get-app-links your.package.name
```
Reinstalling the app also triggers a fresh verification pass.
To test your in-app navigation before verification passes, approve the domains manually. This is equivalent to the user enabling **Open supported links** in app settings:
```bash theme={null}
adb shell pm set-app-links-user-selection --user cur --package your.package.name true all
```
If links open your app after this but verification still fails, the problem is in the hosted file or fingerprint, not your app code.
```bash theme={null}
curl -v https://your-domain.io/.well-known/apple-app-site-association
```
If it returns a 404, re-save your config in [Project Settings → Domain Verification](https://dashboard.linkrunner.io/settings?sort_by=activity-1\&s=store-verification). The file must be valid JSON served over HTTPS without redirects, and `appID` must be exactly `TEAM_ID.BUNDLE_ID`.
Devices don't download the file from your domain. Apple's CDN fetches it from your domain, and devices download it from the CDN. Check what the CDN is serving:
```bash theme={null}
curl -v https://app-site-association.cdn-apple.com/a/v1/your-domain.io
```
If this is older than what your server returns, the CDN hasn't picked up your change yet. There is no command to purge it. Updates typically propagate within a few hours but can take up to a day. Use developer mode (next step) to bypass the CDN while testing.
1. In Xcode, change your Associated Domains entry to `applinks:your-domain.io?mode=developer`
2. On the device, enable **Settings → Developer → Associated Domains Development**
3. Delete the app and reinstall it
Development builds now fetch the file directly from your domain, skipping the CDN cache. App Store builds ignore developer mode, so test with the normal entitlement before release.
iOS caches the association file at install or update time and only refreshes it occasionally afterward. To force a re-fetch, delete the app and reinstall it.
Connect the device to a Mac, open **Console.app**, and filter for `swcd` (the daemon that downloads and verifies associated domains). Reinstall the app and watch for download failures or parse errors.
Typing a universal link into Safari's address bar never opens the app. Test by tapping a link from another app instead: paste it into Notes and long-press it. If **Open in "YourApp"** appears, verification succeeded.
***
## Troubleshooting
* Check that the intent filter includes `android:autoVerify="true"` and lists the exact host you're testing.
* Check the verification state and re-verify (see [Debugging Domain Verification](#debugging-domain-verification)).
* The SHA-256 fingerprint in Linkrunner must match the keystore that signed the build on your device. Debug and release builds have different fingerprints.
Release builds are signed with a different keystore, so they have a different SHA-256 fingerprint. If you use Google Play App Signing, the production fingerprint comes from **Play Console → Setup → App integrity**, not your local keystore. Add the release fingerprint to `sha256_cert_fingerprints` in Linkrunner alongside the debug one.
* Check that `appID` is `TEAM_ID.BUNDLE_ID` with the correct Team ID, and that the **Associated Domains** capability lists your domain.
* Check what Apple's CDN is serving (see [Debugging Domain Verification](#debugging-domain-verification)). If it's stale, use developer mode.
* Don't type the URL into Safari. Universal links only trigger when tapped from another app.
Both platforms cache verification results:
* **Android:** clear the cached state with `adb shell pm set-app-links --package your.package.name 0 all`, then re-verify with `adb shell pm verify-app-links --re-verify your.package.name`.
* **iOS:** wait for Apple's CDN to refresh (up to a day) or enable developer mode, then delete and reinstall the app.
Check that the scheme is registered (intent filter on Android, **URL Types** in Xcode on iOS) and that the app is installed on the device. Use lowercase schemes everywhere; Android matches them case-sensitively.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Deferred Deep Linking
Source: https://docs.linkrunner.io/features/deferred-deep-linking
Configure deferred deep links in campaigns to direct users to specific content after app installation
Direct users to specific screens after they install your app from a campaign link. Works on Android & iOS.
For Meta App Promotion campaigns, you can add the app link in Meta Ads Manager. Linkrunner syncs the Meta ad creative app link and returns it from `getAttributionData()`. Manual ad set and ad creative deep links saved in Linkrunner override synced Meta links.
## Quick Example
**Campaign Setup:** Enable deferred deep linking, set URL to `get.yourdomain.com/product/123`
**What Happens:**
1. User clicks campaign link (no app installed)
2. Redirects to App Store/Google Play
3. User installs and opens app
4. App calls `getAttributionData()`, receives deep link
5. Navigate user to product page
## Setup
### 1. Create Campaign with Deep Link
In the [Linkrunner Dashboard](https://dashboard.linkrunner.io/dashboard?m=create-campaign):
1. Click **"Create Campaign"**
2. Enable **"Deferred Deep Linking"** toggle
3. Enter deep link URL: `get.yourdomain.com/product/123`
**URL Format:**
```
get.yourdomain.com/product/sneakers-123
get.yourdomain.com/promo/SAVE20
get.yourdomain.com/onboarding?type=premium
```
### 2. Handle in Your App
Call `getAttributionData()` when you need attribution data, check for the `deeplink` field, parse the URL, and navigate:
**See:** [Getting Attribution Data - Android SDK Usage](/sdk/android/usage#getting-attribution-data)
**See:** [Getting Attribution Data - iOS SDK Usage](/sdk/ios/usage#getting-attribution-data)
**See:** [Getting Attribution Data - React Native SDK Usage](/sdk/react-native/usage#getting-attribution-data)
**See:** [Getting Attribution Data - Flutter SDK Usage](/sdk/flutter/usage#getting-attribution-data)
**See:** [Getting Attribution Data - React Native SDK Usage](/sdk/react-native/usage#getting-attribution-data)
## Common Use Cases
**Product Links:** `get.yourdomain.com/product/sneakers-123` → Opens product page
**Promo Codes:** `get.yourdomain.com/promo/SAVE20` → Opens promo with code applied
**Onboarding:** `get.yourdomain.com/onboarding?trial=30days` → Custom onboarding flow
**Referral tracking:** `get.yourdomain.com/product/123?referrer=jane` → Deep link + attribution ([learn more](/features/referral-codes))
## Testing
1. Create test campaign with deferred deep linking enabled
2. Uninstall app completely
3. Click campaign link on mobile device
4. Install and verify you land on the correct screen
Always test on real devices before launching campaigns.
## Troubleshooting
**No deep link returned?** Check that deferred deep linking is enabled in campaign settings and URL is formatted correctly.
**Navigation fails?** Add logging to your URL parsing logic, verify route names match your app's navigation structure, and test with simple paths first.
**Attribution data is null?** Ensure the app was installed via the campaign link (not directly from store) and `getAttributionData()` is called after SDK initialization.
***
Questions? Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Events
Source: https://docs.linkrunner.io/features/events-tab
Build conversion funnels, inspect any raw event your app sent, and audit which events are flowing.
An event is a single action someone takes in your app that your app reports to Linkrunner, such as opening the app, adding an item to a cart, or completing a purchase. Your app decides which actions to report, and each one gets a name like `add_to_cart` or `purchase`.
The **Events** tab is where you analyze those actions. It has three sub-tabs:
* **Events Funnel** shows how many people make it through a sequence of steps, and where the rest give up.
* **Events Log** lists every single action as it arrived, so you can inspect one in detail.
* **Your Events** lists which action names your app is sending at all.
Open the tab at [dashboard.linkrunner.io/dashboard/events](https://dashboard.linkrunner.io/dashboard/events). The dashboard adds `?p_id=YOUR_PROJECT_ID` to the address for the project you have selected.
Everything on this tab comes from what your app reports. If an action is missing here, the gap is in your app's
tracking code rather than in the dashboard. See [Tracking Custom
Events](/sdk/react-native#tracking-custom-events) and [Revenue Tracking](/sdk/react-native#revenue-tracking).
## Events Funnel
A funnel measures how many people get through a sequence of steps, and how many drop out along the way. For example: installed the app → signed up → made a purchase. **Events Funnel** is the sub-tab you land on first.
### Building a funnel
Open the editor with the pencil icon next to the funnel name.
Step 1 sets your starting group, the people every later step is measured against. Pick it with **Search
Event**. Step 1 takes **exactly one event**.
Three controls on step 1 apply to the whole funnel rather than only the first step. **During** sets the date
range, **Attribution** limits the funnel to **All**, **Organic**, or **Attributed** users, and **Filter by
campaign** narrows it to particular campaigns.
Click **Add step**, then use **Search Events** to pick one or more. A step holding several events lets through
anyone who triggered **any** of them.
Drag a step by its handle to reorder it, or use the delete icon to remove it.
Enter a **Funnel name**. A funnel needs **at least two steps** before you can save it.
### Reading the chart
Each step shows how many users reached it and what share of your starting group that represents.
Every percentage is measured against step 1, not against the step immediately before it.
In the funnel above, 2.4K users opened the app. 531 of them signed up, which is 22% of that original 2.4K. By step 3 only 261 are left, which is 11% of the same 2.4K.
**Drop-off** is counted the same way. It is everyone from the step 1 group who never reached that step, so it keeps growing as you move down the funnel rather than showing the loss from one step to the next. That is why drop-off reads 52% at step 2 and 89% at step 3.
A step holding more than one event gets a band per event. Step 2 above splits into **Sign up** and **Sign in**, and anyone who did either one gets through.
Any action your app reports can be a step, so a funnel can cover the whole path from install through to purchase.
### Managing saved funnels
* Switch between your saved funnels using the dropdown at the top.
* Change a step directly from the chart, or reopen the editor.
* Use **Save as new** to make a copy of a funnel and change the copy without losing the original.
While you are editing, the chart keeps showing the last set of results, so the funnel stays on screen instead of going blank between changes.
## Events Log
**Events Log** is the record of every action Linkrunner has received, newest first, shown a page at a time. Each row shows **Event Name**, **Timestamp**, **Event Type**, **User**, **SDK Version**, and **Platform**.
The **User** column shows **Unidentified** when no user record is attached to the action, and **Hashed** when there is one but the personal details are held back. See [Hashed and identified users](/features/users-tab#hashed-and-identified-users).
Filter by **All Channels**, **All Events**, platform, **Select dates**, and **Users**. There is also an **All / Organic / Attributed** toggle, which narrows the log to actions from users who arrived on their own versus users Linkrunner matched to an ad click.
### Inspecting a single event
Click a row to expand it and see everything recorded about that action.
| Field | What it tells you |
| ----------------------------- | ------------------------------------------------------------------------------------- |
| **Event Type** | Whether this is a custom action, a payment, or another type |
| **Event ID** | A unique reference for this one action, worth quoting in a support ticket |
| **Timestamp** | When Linkrunner received it |
| **User ID** | Who triggered it |
| **SDK Version** | Which version of the Linkrunner SDK sent it, useful for checking a release rolled out |
| **Platform**, **App Version** | The device type and the version of your app they were running |
| **Campaign** | The campaign that user came from |
| **Event Source** | Which integration delivered the event, such as `Mobile App` for your app's SDK |
| **Device Model** | The specific device that sent it |
| **Referral URL** | The link that originally brought the user in |
| **User Agent** | Raw technical details about the device and app, as sent |
### Payment events
Payments carry four extra fields: **Amount**, **Currency**, **Payment ID**, and **Payment Status**. These show the transaction exactly as Linkrunner received it, so check them first if your revenue numbers look wrong somewhere else.
## Your Events
**Your Events** lists the action names Linkrunner has received for this project. Each row shows the **Event Name**, its **Event Type**, and the **Channels** that have sent it.
There are two ways to read it:
* **Down the event column**: is everything your app is meant to be sending actually arriving? A name that is missing means the action is not being reported, not that the dashboard is behind.
* **Across the channels column**: which ad channels are driving which actions.
If this sub-tab is empty, nothing has arrived yet. The empty state links to the SDK Console, and [SDK Integration Testing](/testing/integration-testing) walks through checking your setup from start to finish.
## What to use this for
**Find where people give up.** A funnel from install to purchase tells you which single step loses the most users, so you can fix that step instead of reworking the whole flow.
**Check a release without asking for logs.** After shipping, filter the Events Log to the action you care about and look at **SDK Version** on the newest rows. This replaces asking an engineer to pull logs for you.
**Confirm an action exists before you rely on it.** Check **Your Events** for the name and its channels before you build a cohort, an ad audience, or a conversion around it.
**Look into a single payment.** Filter to the user, expand the payment, and read the amount, currency, and status.
**Compare the same flow across campaigns.** Set the campaign filter on step 1 and read the same funnel one campaign at a time.
## Troubleshooting
Linkrunner only keeps data for users it has attributed, meaning it has matched their install back to the click
or link that led to it. If the device never completed a click and install that Linkrunner could match, its
actions are not recorded. Check the flow with [SDK Integration Testing](/testing/integration-testing).
A funnel needs at least two steps, and step 1 must have exactly one event. Add a second step, or cut step 1 down
to a single event.
Check the date range and campaign filters on step 1. They apply to the whole funnel, so a narrow window there
empties every step below it.
The amount, currency, and payment ID come from the `capturePayment` call in your app. Confirm your app is
passing them. See [Revenue Tracking](/sdk/react-native#revenue-tracking).
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Google Integrated Conversion Measurement
Source: https://docs.linkrunner.io/features/google-icm
Recover Google App Campaign attribution on iOS when no click identifier or advertising ID is available
**Using an AI coding agent?** The Linkrunner skill for your platform already includes ICM setup — adding Google's ODM SDK where needed and wiring `setConsent`:
```bash theme={null}
npx @linkrunner/skills add ios # or react-native, flutter, android
```
See [Linkrunner Agent Skills](/ai-agent-skills).
## What it is
Google App Campaigns lose iOS installs when there is no click identifier and no IDFA to match on, which is the normal state once a user declines App Tracking Transparency. Integrated Conversion Measurement (ICM) closes that gap.
Google's [On-Device Measurement (ODM) SDK](https://github.com/googleads/google-ads-on-device-conversion-ios-sdk) keeps the ad click context on the device and turns it into an encrypted value, `odm_info`. Linkrunner reads that value when the SDK initializes and sends it with the install, so Google can match the conversion without an identifier ever leaving the device.
Set this up if you run Google App Campaigns for your iOS app. Google's SDK requires iOS 12 or later, and Google reports the improved coverage for users on iOS 14 and later.
Google keeps ODM inactive for users located in the European Economic Area, the United Kingdom, and Switzerland. For those users the SDK returns nothing and the field is left out, so ICM recovers no installs there.
On Android there is nothing to install. Google applies the same ICM improvements automatically through the App Conversion API, mainly for EEA users and users who opt out of device-level permissions.
ICM complements SKAdNetwork, it does not replace it. Keep your [SKAdNetwork integration](/features/skadnetwork-integration) in place.
## Prerequisites
ICM requires a supported Linkrunner SDK version and three setup steps. It stays inactive until everything is in place, and it fails quietly rather than loudly.
### Minimum SDK versions
Older versions do not collect `odm_info` or provide the consent API.
| Platform | Minimum supported version |
| ----------------------------------------------------------------------------------- | --------------------------------------------------------- |
| [Android](/sdk/android#google-ads-consent) | `io.linkrunner:android-sdk:4.1.0` |
| [iOS](/sdk/ios#google-integrated-conversion-measurement-optional) | LinkrunnerKit 4.1.0 |
| [React Native](/sdk/react-native#google-integrated-conversion-measurement-optional) | `rn-linkrunner` 3.1.0 |
| [Flutter](/sdk/flutter#google-integrated-conversion-measurement-optional) | `linkrunner` 4.1.1 |
| [Expo](/sdk/expo#google-integrated-conversion-measurement-optional) | `rn-linkrunner` 3.1.0 |
| [Unity](/sdk/unity#google-integrated-conversion-measurement-optional) | LinkrunnerKit 4.1.0 and `io.linkrunner:android-sdk:4.1.0` |
Link IDs are per platform. An Android link ID does not cover your iOS app, and without an iOS one Google has nowhere to send the conversion, so ICM produces nothing no matter what the app does.
In Google Ads, go to **Tools** → **Data Manager** → **Third-party app analytics**, create a link ID with provider ID `9936233049`, and select the **iOS** platform and your app.
Then in Linkrunner, go to [Integrations → Google Ads](https://dashboard.linkrunner.io/dashboard/integrations/google-ads), open the three-dot menu on the Google Ads row, choose **Setup Link IDs**, and paste it into **iOS Link ID**. Click **Save Link IDs**.
Full walkthrough with screenshots: [Create Link ID for Android/iOS App](/ad-networks/google-ads#2-create-link-id-for-androidios-app).
Linkrunner does not bundle it. The SDK is detected at runtime, so apps that skip ICM carry no extra dependency and no size increase.
Your SDK's installation section has the exact pod or package to add, along with any linker flags: [iOS](/sdk/ios#google-integrated-conversion-measurement-optional), [React Native](/sdk/react-native#google-integrated-conversion-measurement-optional), [Flutter](/sdk/flutter#google-integrated-conversion-measurement-optional), [Expo](/sdk/expo#google-integrated-conversion-measurement-optional), [Unity](/sdk/unity#google-integrated-conversion-measurement-optional).
If your app already uses the Firebase iOS SDK 11.14.0 or later, the `FirebaseAnalytics` pod brings it in and there is nothing to add.
Google's App Conversion API treats the consent parameters as required whenever their value is known, so an install that arrives with no consent state is matched with less information. Call `setConsent` with your consent values before you initialize the SDK, and again whenever the user changes their choice.
This applies on Android too, even though there is no ODM SDK to add there. See [Consent signals](#consent-signals) below for what each parameter means, and [Send Consent](/features/send-consent) for what to report and how to collect it.
Everything else is automatic. There is no ICM API to call and attribution still comes back through `getAttributionData`.
## Platform support
| Platform | Collects `odm_info` | Consent API |
| ----------------------------------------------------------------------------------- | ------------------- | ------------------------------ |
| [Android](/sdk/android#google-ads-consent) | Not applicable | `setConsent` |
| [iOS](/sdk/ios#google-integrated-conversion-measurement-optional) | Yes | `setConsent` |
| [React Native](/sdk/react-native#google-integrated-conversion-measurement-optional) | Yes (iOS) | `setConsent` |
| [Flutter](/sdk/flutter#google-integrated-conversion-measurement-optional) | Yes (iOS) | `setConsent` |
| [Expo](/sdk/expo#google-integrated-conversion-measurement-optional) | Yes (iOS) | `setConsent` |
| [Unity](/sdk/unity#google-integrated-conversion-measurement-optional) | Yes (iOS) | `setConsent` on both platforms |
| Capacitor, Cordova | Not yet supported | No |
ODM is an iOS-only SDK. There is no Android equivalent and none is needed, because Android installs reach Google through the App Conversion API. That API reads the same consent signals, so `setConsent` still matters on Android.
## Consent signals
`setConsent` takes three signals, each of which is `granted`, `denied`, or `unknown`. Linkrunner forwards them to Google with your installs and events.
| Parameter | Sent to Google as | What it means |
| --------------------------------- | -------------------- | ------------------------------------------------------------------------- |
| `isEEA` | `eea` | European regulations apply to this user (the EEA, the UK, or Switzerland) |
| `hasConsentForDataUsage` | `ad_user_data` | The user agreed to their data being sent to Google for advertising |
| `hasConsentForAdsPersonalization` | `ad_personalization` | The user agreed to their data being used to personalize ads |
A signal left `unknown` is dropped from the request rather than sent as a denial, so Linkrunner never reports a choice your user did not make. Never map `unknown` to `granted`.
App Tracking Transparency is not a substitute: it governs IDFA access, not Google's use of ad user data or personalization, and Linkrunner reports ATT status separately.
See [Send Consent](/features/send-consent) for what to report for users outside the EEA, and how to collect consent manually or from a Consent Management Platform.
## Set it up
Report consent for the App Conversion API
Add the ODM SDK and report consent
Add the pod and report consent
Add the pod and report consent
## Upgrade from a pre-ICM SDK version
Update the Linkrunner SDK before adding Google's ODM SDK or calling `setConsent`.
Update the dependency in your app's `build.gradle`:
```gradle theme={null}
dependencies {
implementation 'io.linkrunner:android-sdk:4.1.0'
}
```
Sync Gradle, then follow the [Google Ads consent setup](/sdk/android#google-ads-consent). Android does not need the ODM SDK.
In Xcode, update the `linkrunner-ios` package to LinkrunnerKit 4.1.0 or later. If you use `Package.swift`, set the minimum version:
```swift theme={null}
.package(
url: "https://github.com/linkrunner-labs/linkrunner-ios.git",
from: "4.1.0"
)
```
Keep `LinkrunnerKitStatic` linked to your app target, then complete the [iOS ICM setup](/sdk/ios#google-integrated-conversion-measurement-optional).
Upgrade `rn-linkrunner`, then reinstall the iOS pods:
```bash theme={null}
npm install rn-linkrunner@latest
cd ios && pod install
```
Confirm the installed version is 3.1.0 or later, then complete the [React Native ICM setup](/sdk/react-native#google-integrated-conversion-measurement-optional).
Upgrade the package to 4.1.1 or later:
```bash theme={null}
flutter pub add linkrunner:^4.1.1
```
Then complete the [Flutter ICM setup](/sdk/flutter#google-integrated-conversion-measurement-optional).
Upgrade both packages and regenerate the native projects:
```bash theme={null}
npm install rn-linkrunner@latest
npx expo install expo-linkrunner
npx expo prebuild --clean
```
Confirm `rn-linkrunner` is 3.1.0 or later, then complete the [Expo ICM setup](/sdk/expo#google-integrated-conversion-measurement-optional). If you are upgrading from Expo SDK 1.x or 2.x, follow the [Expo migration guide](/sdk/expo#migration-guide) first.
Update both native dependencies:
* Set `io.linkrunner:android-sdk` to 4.1.0 or later in `Assets/Plugins/Android/mainTemplate.gradle`.
* Update the `linkrunner-ios` Swift package to LinkrunnerKit 4.1.0 or later in the generated Xcode project.
Then complete the [Unity ICM setup](/sdk/unity#google-integrated-conversion-measurement-optional).
Capacitor and Cordova do not support ICM yet.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Google Ads SKAN Setup
Source: https://docs.linkrunner.io/features/google-skan-setup
Set up SKAdNetwork attribution for Google Ads iOS App Campaigns with Linkrunner
SKAdNetwork (SKAN) is Apple's privacy-preserving framework for iOS attribution. Google Ads uses SKAN postbacks to measure and optimize iOS App Campaigns. This guide walks you through the complete Google Ads SKAN setup with Linkrunner.
Track your progress with the **Google Ads SKAN Integration Checklist**: [Integrations → Google Ads → SKAN Integration](https://dashboard.linkrunner.io/dashboard/integrations/google-ads?tab=skan_integration).
This guide covers the Google-specific SKAN setup. If you haven't connected Google Ads to Linkrunner yet, start with
the [Google Ads integration guide](/ad-networks/google-ads).
## Prerequisites
Before you begin, make sure you have:
* A [connected Google Ads integration](/ad-networks/google-ads)
* Your app live with a SKAN-ready Linkrunner SDK version (see [minimum versions](/features/skadnetwork-integration#minimum-sdk-versions))
* Admin access to the Google Ads account that runs your iOS campaigns
## 1. Prepare Your App
### Update the SDK
Your app must run a SKAN-ready SDK version. The SDK updates conversion values automatically as users perform events, so no SKAN code is needed on your side. Check the [minimum SDK versions](/features/skadnetwork-integration#minimum-sdk-versions).
### Add Google's Ad Network Identifier
Apple only generates SKAN postbacks for ad networks listed in your app's `Info.plist`. Add Google's identifier under `SKAdNetworkItems`:
```xml theme={null}
SKAdNetworkItems
SKAdNetworkIdentifier
cstr6suwn9.skadnetwork
```
On Expo, add these under `ios.infoPlist.SKAdNetworkItems` in your `app.json`.
### Add the Postback Copy Endpoints
Add these keys to your `Info.plist` so Apple sends copies of SKAN postbacks to Linkrunner:
```xml theme={null}
NSAdvertisingAttributionReportEndpoint
https://linkrunner-skan.com
AttributionCopyEndpoint
https://linkrunner-skan.com
```
The Linkrunner Expo plugin adds these keys automatically.
## 2. Create an iOS Link ID
A Google Ads iOS link ID enables data sharing between Linkrunner and Google Ads for your iOS app.
In Google Ads, go to **Tools → Data Manager → Third-party app Analytics**, or visit
[ads.google.com/aw/productlinks/thirdpartyappanalytics](https://ads.google.com/aw/productlinks/thirdpartyappanalytics).
Click **+ Create link ID**, select **Other provider**, and enter provider ID `9936233049`.
Choose the **iOS** platform, select your app, then click **Create link ID** and copy the generated ID.
In the Linkrunner Dashboard, open [Integrations → Google
Ads](https://dashboard.linkrunner.io/dashboard/integrations/google-ads) and paste the iOS link ID.
See the full walkthrough in [Google Ads → Create Link ID](/ad-networks/google-ads#2-create-link-id-for-android%2Fios-app).
## 3. Connect Your Google Ads Account
Linkrunner needs OAuth access to your Google Ads account to deliver SKAN postbacks and conversion values.
In the Linkrunner Dashboard, open [Integrations → Google
Ads](https://dashboard.linkrunner.io/dashboard/integrations/google-ads).
Click **Connect Google Ads Account** and authenticate with the Google account that manages your ads.
Select the customer account(s) you want to connect.
## 4. Configure the SKAN Schema in Linkrunner
Linkrunner converts your event list into SKAN conversion values (0-63) and sends the schema to Google automatically.
Go to **Settings → SKAN Setup → Events Setup** in the [Linkrunner
Dashboard](https://dashboard.linkrunner.io/dashboard/settings/skan-setup?tab=event-setup). Add the events that
matter for optimization and drag rows to set priority. The topmost event has the highest priority.
For revenue events, set a **min** and **max** range. For occurrence events, set an **average**.
Click **Generate SKAN Mapping**. The dashboard shows the combination count in real time and warns if your
configuration would exceed Apple's limit of 64 conversion values.
If you already configured the schema for Meta Ads, no extra work is needed. Linkrunner reuses the same schema for
Google.
For background on how this maps to SKAN 4.0, see [Conversion Values in SKAdNetwork](/features/skadnetwork-conversion-values).
## 5. Map Events to Google Ads
Map each Linkrunner event to the corresponding Google Ads conversion action so postbacks land against the right conversion in your Google Ads account.
Go to [Integrations → Google Ads → Event
Mapping](https://dashboard.linkrunner.io/dashboard/integrations/google-ads?tab=event_mapping).
For each event in your SKAN schema, select the Google Ads conversion action it should map to.
Save the mapping.
Linkrunner automatically sends the SKAN schema configuration to Google on your behalf. You don't need to upload
conversion values manually in Google Ads.
If an event is not available as a conversion action in Google Ads, fire a test event from Linkrunner, then import it
from **Goals → Conversions → Summary** after Google finishes processing it.
## 6. Verify the Setup
Confirm every item is complete in the [Google Ads SKAN Integration
Checklist](https://dashboard.linkrunner.io/dashboard/integrations/google-ads?tab=skan_integration).
SKAN postbacks only flow once a Google Ads iOS App Campaign is live and driving installs.
Postbacks appear in the [SKAN dashboard](/features/skan-dashboard/overview) as Apple delivers them. Apple delays
postbacks by design, so expect data 24 to 48 hours after installs begin.
## Troubleshooting
### No postbacks in the SKAN dashboard
* Confirm the `Info.plist` keys from [section 1](#1-prepare-your-app) are in the live app build
* Confirm the SKAN-ready SDK version is live and rolled out to 100% of users
* Wait 24 to 48 hours. Apple delays SKAN postbacks by design
### Conversions not showing in Google Ads
* Confirm the iOS link ID is pasted correctly in Linkrunner
* Confirm your Google Ads account is connected via OAuth
* Confirm the event is mapped to a conversion action in [Event Mapping](https://dashboard.linkrunner.io/dashboard/integrations/google-ads?tab=event_mapping)
* Wait up to 12 hours. Google processes third-party app analytics events with a delay
### iOS campaign numbers differ from Linkrunner
This is expected with SKAN. See [Google Ads FAQ and Discrepancies](/ad-networks/google-ads-discrepancies) and [iOS Campaign Data Limitations](/features/ios-campaign-data-limitations).
***
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# iOS Campaign Data Limitations
Source: https://docs.linkrunner.io/features/ios-campaign-data-limitations
Why some campaign metrics show as unavailable for iOS campaigns and where to find iOS data instead
You may notice an Apple icon next to certain metrics in your campaign dashboard — this means the data isn't measurable for iOS campaigns due to Apple's privacy changes. Here's what changed and where to find iOS data instead.
## What changed with iOS privacy
Apple introduced a series of privacy changes that fundamentally altered how iOS app attribution works:
| Timeline | Change | Impact |
| -------------------- | ------------------------------------------------------- | --------------------------------------------------------------- |
| **Before 2021** | Apps could freely access device identifiers (IDFA) | Full user-level attribution and tracking |
| **iOS 14.5 (2021)** | App Tracking Transparency (ATT) required opt-in consent | \~75% of users opted out, breaking deterministic attribution |
| **iOS 16.1+ (2023)** | SKAdNetwork 4.0 became the primary attribution method | Attribution is now aggregated, delayed, and privacy-thresholded |
These limitations affect **all** mobile attribution tools, not just Linkrunner. Any platform reporting user-level iOS campaign data post-ATT is either using modeled estimates or limited to the small percentage of users who opted in.
## Affected metrics
The following metrics cannot be reliably measured at the campaign level for iOS-only campaigns. When unavailable, they appear as **"-"** with the Apple icon tooltip.
| Metric | Why it's affected |
| ----------- | ----------------------------------------------------------------------------------------------------- |
| **Revenue** | Cannot attribute revenue back to a specific iOS campaign without user-level identity |
| **ROAS** | Requires revenue attribution, which depends on user-level tracking |
| **CPI** | Install counts from SKAN are aggregated and delayed, making per-campaign cost calculations unreliable |
| **CPS** | Sign-up attribution is unavailable, so cost-per-signup cannot be calculated |
| **Spend** | Ad network spend data may still be available, but cannot be matched to Linkrunner attribution data |
| Metric | Why it's affected |
| ----------------------- | ------------------------------------------------------------------------------------------ |
| **Sign-ups** | Post-install events cannot be attributed to specific campaigns without user-level identity |
| **Uninstalls** | Uninstall tracking relies on device tokens that cannot be tied back to campaigns |
| **Suspicious Installs** | Fraud detection signals require user-level data that is no longer available |
| Metric | Why it's affected |
| --------------------- | ------------------------------------------------------------------------------------ |
| **Rolling Retention** | Requires tracking individual users over time, which needs user-level attribution |
| **Classic Retention** | Same as rolling retention — day-N return rates need user-level identity |
| **Active Users** | Cannot segment active users by acquisition campaign without device-level attribution |
Any custom events you track (e.g. `purchase`, `level_complete`, `add_to_cart`) cannot be attributed back to the originating iOS campaign. The events are still recorded — they just can't be tied to a specific campaign source.
## What you CAN still measure
Even with these privacy limitations, iOS campaign measurement isn't completely gone — it has shifted to Apple's privacy-preserving framework:
* **SKAN postbacks** — aggregated install and conversion data sent by Apple
* **Conversion values** — encode post-install behavior (revenue tiers, engagement signals) within Apple's privacy thresholds
* **Privacy tier distribution** — understand how much data Apple is sharing for your campaigns
* **Campaign-level install trends** — track install volume patterns over time via SKAN
View aggregated iOS attribution data including installs, conversion values, and privacy tiers.
Configure SKAdNetwork integration to start receiving iOS attribution data.
## Tips for better iOS measurement
* **Consolidate iOS campaigns** — fewer campaigns means more data per campaign, which helps meet Apple's privacy thresholds and reduces null conversion values.
* **Set up SKAN conversion values** — define a [conversion value schema](/features/skadnetwork-conversion-values) to capture the post-install signals that matter most (revenue tiers, key events, retention).
* **Use mixed-platform campaigns** — if your campaign targets both iOS and Android, the Android portion will still provide full user-level metrics alongside the SKAN data for iOS.
* **Check your SKAN Dashboard regularly** — the [SKAN Dashboard](/features/skan-dashboard/overview) is purpose-built for iOS measurement and shows data that the standard campaign view cannot.
# Link redirection
Source: https://docs.linkrunner.io/features/link-redirection
How Linkrunner routes users to the right destination when they click a campaign link
When someone clicks a Linkrunner campaign link, the system detects their device, records the click for attribution, and redirects them to the best destination — whether that's your app, the app store, or your website.
## How it works
Linkrunner extracts the campaign identifier from the link. This can be a `?c=` query parameter or a single path segment (e.g., `get.yourapp.com/AbCdEf`).
The system reads the user agent to determine the device type (mobile, tablet, or desktop), the operating system (iOS or Android), and whether the click came from an in-app browser like Instagram, Facebook, TikTok, Snapchat, LinkedIn, or Twitter.
Linkrunner records the click with device info, operating system, browser details, referrer URL, advertising IDs (`gaid` / `idfa`), ad network click IDs, and a unique attribution identifier (`lr_ia_id`) used to match the click to a future install.
Based on the device type and browser context, Linkrunner routes the user to the appropriate destination using the fallback chains described below.
## Routing by platform
Social media apps like Instagram and TikTok open links in their own built-in browser, which blocks normal redirects to the app store or your installed app. Linkrunner handles this by rendering an intermediary page.
### What happens
1. The intermediary page loads and immediately attempts to open your app via a deep link (custom URI scheme).
2. If the app is installed and opens, the page detects this and stops.
3. If the app does not open within the timeout, the page redirects to the App Store (iOS) or Play Store (Android).
4. The user can also tap the **"Get the App"** button to go to the store immediately.
### Timeout behavior
* **iOS**: 300ms timeout before falling back to the App Store.
* **Android**: 2-second timeout with a visible countdown before falling back to the Play Store.
The intermediary page can be customized to match your brand. See [Social media intermediary page](/features/social-media-intermediary-page) for details.
This flow also applies when the **"Open in app"** option is enabled on a campaign, regardless of which browser the user is in.
On mobile and tablet devices using a regular browser (Safari, Chrome, etc.), Linkrunner redirects through a fallback chain without showing an intermediary page.
### iOS
1. **iOS redirect URL** — A custom redirect URL configured on the campaign
2. **App Store link** — Your app's App Store page (with custom store listing if configured)
3. **Campaign website** — The website URL set on the campaign
4. **Project website** — Your project's default website (with the deep link path appended if a deferred deep link is configured)
### Android
1. **Android redirect URL** — A custom redirect URL configured on the campaign
2. **Play Store link** — Your app's Play Store page (with UTM parameters and custom store listing if configured)
3. **Campaign website** — The website URL set on the campaign
4. **Project website** — Your project's default website (with the deep link path appended if a deferred deep link is configured)
Linkrunner uses the first available URL in the chain. If a platform-specific redirect URL is set on the campaign, it takes priority over the store link.
Desktop visitors are redirected through a different fallback chain since they cannot install a mobile app directly.
1. **Campaign website** — The website URL set on the campaign
2. **Project website** — Your project's default website (with the deep link path appended if a deferred deep link is configured)
3. **Store link** — Falls back to the appropriate store page if no website is configured
## Open in app popup
When **Open in app** is enabled on a campaign and Linkrunner detects that your app is already installed, the redirect page shows a **Continue to your app** prompt that opens the app directly, instead of auto-redirecting to the store.
On Android, detection uses Chrome's `navigator.getInstalledRelatedApps()`. This only works when your app declares an `asset_statements` link back to your Linkrunner subdomain. Linkrunner already hosts the matching `/.well-known/assetlinks.json` on your subdomain; you add the app→site half in your app. See [Enable install detection in Deep linking setup](/features/deep-linking-setup#enable-install-detection-continue-to-app-popup) for the `asset_statements` snippet.
If the app is not installed, or detection is unavailable, the page falls back to the normal store redirect chain.
## Redirect priority
| Scenario | Priority 1 | Priority 2 | Priority 3 | Priority 4 |
| ------------------------- | -------------------- | ---------------------- | ---------------- | --------------- |
| In-app browser | Deep link into app | App Store / Play Store | Campaign website | Project website |
| iOS (regular browser) | iOS redirect URL | App Store | Campaign website | Project website |
| Android (regular browser) | Android redirect URL | Play Store | Campaign website | Project website |
| Desktop | Campaign website | Project website | Store link | — |
## Troubleshooting
**Store redirect not working?** Verify that your App Store and Play Store links are configured correctly in [project settings](https://dashboard.linkrunner.io/settings). The Play Store link must include `google.com` in the URL.
**Deep link not opening the app?** Ensure your app has a custom URI scheme configured and that the Linkrunner SDK is initialized. See [Deep linking setup](/features/deep-linking-setup) for configuration steps.
**Blank intermediary page?** This usually means the app link or store link failed to load. Check that your custom URI scheme and store links are set correctly in project settings.
**Attribution not matching?** Make sure the Linkrunner SDK is initialized before calling `getAttributionData()`. The app must be installed through the campaign link — direct store installs cannot be attributed.
**Desktop users seeing the store instead of your website?** Add a website URL to your campaign or project settings. Without a website configured, Linkrunner falls back to the store link on all platforms.
***
Customize the intermediary page for in-app browsers
Configure deep links to open specific screens in your app
Route users to content after they install your app
Show tailored store pages for different campaigns
For any help please reach out to [support@linkrunner.io](mailto:support@linkrunner.io)
# Connect Linkrunner to AI assistants
Source: https://docs.linkrunner.io/features/mcp
Connect Linkrunner to Claude, ChatGPT, Claude Code, or Codex with MCP.
## Overview
Linkrunner ships a remote Model Context Protocol (MCP) server. Connect it to a
supported AI assistant, then ask questions about your project's data directly
from chat:
* "How did my campaigns perform last week?"
* "Compare my Meta vs Google performance for the last 7 days."
* "Which campaigns should I cut based on ROAS?"
* "Why did installs drop yesterday?"
* "How much of my growth is organic?"
* "Where do users drop off between install and checkout?"
With the ad-accounts capability, the assistant can also read from the Meta and
Google accounts you have connected:
* "How much did we spend on Meta last week, by campaign?"
* "Which Google ad groups are paused right now?"
* "What targeting does our retargeting ad set use?"
* "Compare Meta's reported spend with Linkrunner's installs for yesterday."
The assistant can also make a defined set of changes for you:
* "Create a Meta campaign for the Diwali sale pointing at our sneakers page."
* "Pause every campaign with no installs in the last 30 days."
* "Save a funnel for install, sign up, then purchase."
Editing tools act on your live project, not a sandbox. The connector can create
and edit campaigns, funnels, domains, store listings, webhooks, and postback
event mappings. A few of these send real traffic to third parties or cannot be
undone. Read [What the connector can change](#what-the-connector-can-change)
before you share the connector with a team.
## Prerequisites
* A project on Linkrunner that you have access to.
* The **Admin** or **Member** role on that project. **Influencers** cannot mint
MCP tokens.
* A supported client: Claude Code, Claude on Desktop or web, Codex CLI, or
ChatGPT web.
* Editing tools run as the person who created the token, using that person's
project permissions. A role that cannot edit in the dashboard cannot edit
through the connector either.
## Setup
### 1. Generate a token
1. Open the [Linkrunner dashboard](https://dashboard.linkrunner.io) and switch
to the project you want to connect.
2. Click **MCP** in the sidebar.
3. Choose what the token may do:
* **Read Linkrunner data** is always on.
* **Make changes in Linkrunner** lets the assistant use the editing tools
listed under
[What the connector can change](#what-the-connector-can-change). Turn it
off for a read-only token.
* **Read connected ad accounts** lets the assistant read your connected
Meta and Google accounts. See
[What the connector can read from your ad accounts](#what-the-connector-can-read-from-your-ad-accounts).
4. Click **Generate Token**.
5. The dashboard shows a one-time **Server URL** with the token already
embedded as a query parameter. Copy it now. The full URL will not be shown
again.
Capabilities are fixed once a token exists. To change them, generate a new
token and revoke the old one. Tokens created before capabilities existed keep
read and change access, and no ad-account access.
The URL contains the token as a query parameter. Treat it like a password:
don't paste it into shared documents, screenshots, or chat threads. Revoke it
from the dashboard if it leaks, and generate a new one.
### 2. Connect your client
Choose your client in the dashboard to see its setup instructions. The
token is part of the Server URL, so no separate authentication step is needed.
Copy the command under **Register the Linkrunner MCP server** and run it in
a terminal:
```bash theme={null}
claude mcp add linkrunner --scope user --transport http ""
```
Start a new Claude Code session and run `/mcp` to verify the connection.
These steps work in Claude Desktop and at [claude.ai](https://claude.ai).
On Free, Pro, or Max:
1. Open **Customize → Connectors**.
2. Select **+ → Add custom connector** and name it `Linkrunner`.
3. Paste the Server URL from the dashboard.
On Team or Enterprise, an Owner or Primary Owner first adds the Server URL
under **Organization settings → Connectors**. Members then open
**Customize → Connectors** and connect Linkrunner.
In a chat, select **+ → Connectors** and enable Linkrunner for that
conversation.
See [Anthropic's custom connector guide](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp)
for current plan and organization details.
Copy the command under **Register the Linkrunner MCP server** and run it in
a terminal:
```bash theme={null}
codex mcp add linkrunner --url ""
```
Start a new Codex session and run `/mcp` to verify the connection.
Custom MCP apps currently work in ChatGPT on the web. ChatGPT Desktop is
not supported. Pro apps can use read and fetch tools only. Turn off
**Make changes in Linkrunner** before generating a token on Pro. Full MCP
support, including write tools, is rolling out in beta to Business and
Enterprise/Edu workspaces.
1. Enable Developer mode:
* On Pro, open **Settings → Apps → Advanced settings**.
* On Business, an admin or owner enables Developer mode from
**Workspace settings → Apps → Create**.
* On Enterprise/Edu, an admin grants access under **Permissions & Roles
→ Connected Data**. Enabled users then turn on Developer mode under
**Settings → Apps → Advanced settings**.
2. Open **Settings → Apps → Create**. On Business, an admin or owner uses
**Workspace settings → Apps → Create** instead.
3. Name the app `Linkrunner` and paste the Server URL as the MCP endpoint.
4. Choose no authentication, then select **Scan tools** and **Create**.
5. Test the draft in a new chat. To make it available to workspace members,
an admin or owner opens **Workspace settings → Apps → Drafts** and
selects **Publish**.
6. Start a chat, select **Apps**, and choose Linkrunner.
See [OpenAI's MCP apps guide](https://help.openai.com/en/articles/12584461)
for current plan and role requirements.
You only need one connection per client and project. If you manage several
Linkrunner projects, generate a token for each project and add a separate
connection with a clear name. Tokens are scoped to a single project.
### 3. Try it
Open a new chat or CLI session, make sure Linkrunner is enabled, and ask one of
the starter prompts above. The assistant fetches data through the connector
and answers in plain language.
## What you can ask
The connector exposes the following tools. Your assistant picks them
automatically based on your question.
| Tool | Use it for |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `get_project_info` | Project name, timezone, display currency. |
| `get_project_stats` | Headline KPIs (clicks, installs, sign-ups, revenue) as lifetime and current-month totals. |
| `get_metrics_trend` | Day-by-day trend for clicks, organic installs, sign-ups, and revenue. Current calendar month only. |
| `list_campaigns` | One row per campaign, with filters (date range, network, platform, search) and selectable metrics. |
| `find_campaign` | Resolve a campaign name to its `display_id`, with no metrics computed. |
| `get_campaign_details` | One named campaign day by day, over any date range. |
| `get_channel_performance` | One row per acquisition channel (Meta, Google, TikTok, Snapchat, Apple Search Ads, Linkrunner Links, Organic). |
| `get_organic_performance` | Installs, sign-ups, and revenue carrying no campaign, as a single organic total. |
| `list_event_names` | Custom and revenue event names you've defined. |
| `search_event_log` | Individual event rows with timestamp, user, campaign, platform, and device. |
| `list_funnels` | Names and step definitions of the funnels saved in your dashboard. |
| `run_funnel` | Step-to-step conversion and drop-off, for a saved funnel or an ad-hoc one you describe in chat. |
| `lookup_attribution` | Attribution result for a single user or device: campaign, ad network, ad set, creative, keyword (Apple Search Ads), organic vs inorganic flag, device IDs (gaid/idfa), and click/install timestamps. |
| `get_billing_usage` | Free-install allowance and current-month usage. Only fetched when you explicitly ask about billing. |
| `list_domains` | Tracking domains on the project, and which one is primary. |
| `list_store_listings` | Custom App Store and Play Store listings you can attach to a campaign. |
| `list_ad_accounts` | Ad-network accounts connected to the project. |
| `list_postback_event_maps` | Event mappings sent to each ad network, and which are network defaults. |
| `get_webhook` | Whether a webhook is configured, its host, and its organic setting. |
Your assistant reads these tools' descriptions and decides which to call. You
don't need to invoke them manually.
`list_campaigns`, `get_channel_performance`, and `get_organic_performance`
return only the metric groups your assistant asks for (installs, spend,
retention, custom events, and so on). A group that wasn't requested is absent
from the response rather than reported as zero, so the assistant never
mistakes "not fetched" for "measured zero". Ask for a metric by name and it
will fetch it.
## What the connector can read from your ad accounts
Tokens created with **Read connected ad accounts** get six more tools. They
read from the Meta Marketing API and the Google Ads API using the ad accounts
already connected to the project under Settings, and nothing else. They cannot
change anything on Meta or Google.
| Tool | Use it for |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enrich_campaigns_from_ad_network` | Adds the network's delivery figures (spend, impressions, clicks, reach, frequency, CPM) and its own reported outcomes beside your Linkrunner campaigns, with a note explaining any gap between the two. |
| `get_ad_insights` | Spend, impressions, clicks, CTR, CPC, CPM, reach and frequency as the ad network reports them, at account, campaign, ad set or ad group, or ad level, with the network's own conversions under `network_reported`. Optional breakdown by date, device, platform, country, age or gender. |
| `list_ad_entities` | Campaigns, ad sets or ad groups, ads, creatives, audiences, conversion actions and keywords, with status, budget and objective. |
| `get_ad_entity` | Every readable field of one object, such as an ad set's targeting or a campaign's bidding strategy. |
| `query_ad_network` | Anything the three tools above cannot express, using the network's own field names: video metrics, impression share, quality rankings, placements, hourly and search-term reports, country, age and gender on Google, attribution-window comparisons on Meta, filters on any field, sorting, and object types such as pixels, labels, saved audiences and asset groups. |
| `ad_network_reference` | Searches the field catalogs of both networks. Your assistant uses it to find exact names before a `query_ad_network` call, and to explain what a metric means. |
The catalogs behind these tools come from the networks themselves: Google's
own field schema and Meta's official Marketing API field lists. Every name the
assistant passes is checked against them before a request is made, so it cannot
ask for a field that does not exist. A wrong name comes back with suggestions
instead of a failed call. Almost everything the two reporting APIs expose for
reading is reachable this way.
### Which source answers which question
Linkrunner stays the source of truth, and connecting an ad account does not
change any answer you already get. It adds to them.
| Question | Source | Why |
| ---------------------------------------------------------------------------- | --------------- | -------------------------------------------------------------------------------------------------------- |
| Installs, reinstalls, sign-ups, events, purchases, revenue | Linkrunner | Attributed per device and per user, deduplicated across networks, and the same numbers as the dashboard. |
| Retention, active users, funnels, attribution detail | Linkrunner only | Only Linkrunner has the user-level history. |
| Cost per install, cost per sign-up, ROAS | Linkrunner | Synced ad spend divided by Linkrunner outcomes, the same as the dashboard. |
| Spend, impressions, clicks, reach, frequency, CTR, CPC, CPM | Ad network | The network bills it, and its live figure is the latest. |
| Status, budgets, bids, targeting, placements, creatives, audiences, keywords | Ad network only | Only the network knows. |
The network's own conversion counts are still available, but always under a
`network_reported` label. Your assistant quotes them as the network's claim
("Meta reports 12 purchases") rather than as the count. When the two sides
disagree, it explains why (attribution window, view-through, SKAN delay,
cross-network deduplication, or a campaign that is not linked) instead of
picking one.
A few things to know when you read network numbers:
* **They are the ad network's numbers.** Meta and Google count conversions
with their own attribution windows and view-through rules. Linkrunner's
installs and sign-ups are measured differently. Your assistant reports the
two side by side and will not merge them.
* **Currency and timezone come from the ad account**, which may differ from
your project's display currency.
* **The last three days are provisional.** Networks keep adjusting spend and
conversions for a few days, so recent numbers can move.
* **Meta has no single "conversions" number.** Your assistant reads the actions
Meta reports (app installs, purchases, leads and so on) and names the one it
is quoting.
* **Google's "Conversions" column is not installs.** It adds up every
conversion action the account bids on (installs, trials, purchases). When
comparing installs, the assistant uses only Google's install conversion
actions (the DOWNLOAD category, such as Android first open) and names the
action it used.
* **Answers may come from a short cache.** Closed date ranges are cached for a
day, open ranges for ten minutes, and object lists for five minutes. The
assistant mentions the age when it matters.
* **Reads are budgeted.** Each token gets a daily allowance of live reads, and
Linkrunner reserves part of its Google Ads quota for attribution. When a
limit is hit, the assistant gets a wait time and a cached copy if one exists,
instead of an error loop.
* **Meta connections expire** roughly every 60 days. If your assistant reports that
the connection has expired, reconnect Meta under Settings.
Only Meta and Google are covered. Accounts on other networks still appear in
`list_ad_accounts` for campaign setup but cannot be queried.
A few Google reports need a specific resource rather than a breakdown. Country
uses `geographic_view`, age and gender use `age_range_view` and `gender_view`,
and search queries use `search_term_view`. The assistant picks these through
`query_ad_network` when you ask.
## What the connector can change
Ask in plain language ("create a campaign for the Diwali sale") and your
assistant picks the right tool. Describe what you want changed, and confirm the
summary it gives you before it writes.
Every tool below writes to your live project. Your assistant asks before making
an ambiguous change, but it is worth being specific about what you want,
especially for the tools marked as sending live traffic or being irreversible.
### Campaigns
| Tool | Use it for |
| ----------------- | -------------------------------------------------------------------------------------------------- |
| `create_campaign` | A new tracking link for an ad creative, an influencer, an organic channel, or a web-to-app flow. |
| `edit_campaign` | Rename, pause or resume, change the deep link, swap the store listing, or reassign the ad account. |
| `create_deeplink` | A per-ad-set or per-creative deep link under a Meta or Google campaign. |
A deep link has to be a full URL on one of your own tracking domains, for
example `https://links.yourbrand.com/product/sneakers`. An external URL is
rejected. Ask your assistant to list your domains if you are not sure which to
use.
Campaigns cannot be deleted through the connector. Pause them with
`edit_campaign` instead.
### Funnels
| Tool | Use it for |
| --------------- | -------------------------------------------------------------------- |
| `create_funnel` | Save a funnel so it appears in your dashboard and in `list_funnels`. |
| `update_funnel` | Rename a saved funnel or change its steps. |
### Domains and store listings
| Tool | Use it for |
| ---------------------- | --------------------------------------------------- |
| `create_domain` | Register a tracking domain or subdomain. |
| `create_store_listing` | Add a custom Play Store or App Store listing. |
| `update_store_listing` | Change a listing's name or its platform listing id. |
| `delete_store_listing` | Remove a listing no campaign is using. |
`create_domain` does not check DNS, so it will save a domain that is not pointed
at Linkrunner yet, and there is no way to delete a domain through the connector.
Check the spelling before you confirm. Adding a domain also creates a permanent
`MAIN_DOMAIN` campaign that backs root-domain links and counts toward your
campaign limit, and making the new domain primary changes which domain later
campaigns use by default.
`delete_store_listing` is refused while any campaign still points at the
listing, and the error names the campaigns. Remove it from those campaigns
first.
### Webhooks
| Tool | Use it for |
| ---------------- | ----------------------------------------------------------------------------------------- |
| `update_webhook` | Set the endpoint that receives install and event payloads, or change the organic setting. |
| `test_webhook` | Send a sample payload to the configured endpoint. |
Both tools deliver a real payload to your endpoint. Saving a webhook sends one
by itself, because Linkrunner only stores an endpoint that answers `200` or
`201`, so configuring a Slack webhook posts a real message to that channel. Ask
your assistant to test afterwards only if you want a second message.
Replacing the URL cannot be undone. A project has one webhook, the new URL
overwrites the old one, and the assistant cannot read the old value back to
restore it (`get_webhook` hides the path, because a Slack webhook path is
itself a credential). Keep a copy of the current URL before you replace it.
### Postback event mappings
| Tool | Use it for |
| --------------------------- | --------------------------------------------------------- |
| `create_postback_event_map` | Map one of your events to the name an ad network expects. |
| `update_postback_event_map` | Change an existing mapping. |
| `delete_postback_event_map` | Remove a mapping you added. |
Creating or updating a mapping immediately sends a test postback to every
connected account on that network (Meta, Google, TikTok, Snapchat, LinkedIn,
Reddit). That puts a synthetic conversion in your real ad account, so avoid
repeated edits.
An ad network's built-in defaults also appear in `list_postback_event_maps`.
They are always active and cannot be changed or removed. To override one, add
your own mapping for the same event.
## Built-in prompts
The connector includes three starter prompts. Clients that support MCP prompts
show them in their prompt picker:
* **Daily briefing:** yesterday's performance summary in 1-2 paragraphs.
* **Weekly review:** this week vs last week, with anomalies and a
keep/cut/scale recommendation list.
* **Cut list:** the worst-performing active campaigns, with reasoning.
These are shortcuts. You can always ask the same questions in your own words.
## Managing tokens
Click **MCP** in the sidebar to see all active tokens for the
project. For each token you can see:
* A masked preview of the token.
* The date it was created.
* The user who created it.
* The last time it was used (helps spot stale tokens).
Each token also shows its capabilities: **Read**, **Changes**, and **Ad
accounts**.
Click the trash icon next to a token to revoke it. Anyone using that token will
be disconnected immediately. Revocation is not reversible. Mint a new token if
you need to reconnect.
A project can have up to 10 active tokens at any time. If you hit the cap,
revoke an unused token before creating a new one.
## Security
* Tokens are scoped to a single project. They cannot read or change other
projects you are a member of, even when the same person created the token.
* Editing tools run as the person who created the token and apply that person's
project role, so the connector cannot do anything that person could not do in
the dashboard.
* Every change made through the connector is recorded with the action, the
token, the person who created it, and what was changed. Every ad-account
read is recorded the same way, with the account, the query type and the
date range, but never the returned data.
* Ad-account access is read-only and opt-in per token. The Meta and Google
credentials stay on Linkrunner's servers; the connector never receives them,
and they are removed from every response and log.
* An ad-account token can only reach accounts connected to its own project.
Passing another project's account is refused.
* The connector cannot delete campaigns, funnels, domains, or projects, cannot manage
members, and cannot disconnect an ad-network account. Those stay in the
dashboard.
* The webhook URL is never fully readable. `get_webhook` returns the host but
hides the path, because a Slack webhook path is a credential in itself.
* SDK private keys, billing settings, and admin actions are not exposed.
* Tokens are revocable from the dashboard at any time.
* Per-project token cap of 10 limits the blast radius if one leaks.
If you only want someone to read, turn off **Make changes in Linkrunner** when
you generate their token. A read-only token does not even list the editing
tools, so the assistant cannot attempt a change with it.
## Troubleshooting
**The client says "Couldn't reach the MCP server."**
The token may have been revoked, or the URL may be malformed. Generate a new
token, copy the full URL exactly, and reconnect Linkrunner.
**The client does not show Linkrunner tools.**
Make sure Linkrunner is enabled in the current chat or session. Open the
client's app, tool, or connector menu and turn it on. New chats do not always
inherit connector state from previous chats.
**Numbers don't match the dashboard.**
The connector reads the same data the dashboard does. If you see a mismatch,
check that you and the assistant are using the same date range and timezone,
and that any filters (network, platform) match. Linkrunner's ingestion is
real-time, so there's no lag to account for.
**Generating a token returns "Maximum of 10 active tokens reached."**
You're at the per-project cap. Revoke an unused token from the same settings
page before creating a new one.
**The assistant says your deep link was rejected.**
A deep link has to be a full URL on one of your own tracking domains. An
external address such as `https://example.com/promo` is refused, and so is a
bare path such as `/promo`. Ask the assistant to list your domains and rebuild
the URL on one of them.
**Saving a webhook fails with "Endpoint needs to return a status code of 200".**
Linkrunner only stores an endpoint that answers `200` or `201` to the sample
payload, and it must answer directly rather than redirecting. An endpoint that
returns `202` or `204` cannot be saved.
**The assistant says the token does not have the ad-accounts capability.**
The token was generated without **Read connected ad accounts**. Generate a new
token with it turned on and re-add the connector. Capabilities cannot be added
to an existing token.
**The assistant says the Meta or Google connection has expired.**
The stored ad-network credential is no longer valid. Open Settings, reconnect
the account, and ask again. Meta connections expire on their own roughly every
60 days.
**The assistant says it has to wait before reading the ad account again.**
A rate limit or daily allowance was reached, either on the ad network or on the
token. The assistant is given a wait time and uses a cached answer when one
exists. Ask again after the wait, or narrow the question so fewer live reads
are needed.
**Ad-network spend does not match the spend Linkrunner shows.**
The two are expected to differ. Ad-account tools return what Meta or Google
report for the ad account, in the account's currency and timezone. Linkrunner's
campaign spend is synced periodically and shown in your display currency. Ask
your assistant for both and it will label which is which.
**The assistant cannot rename a custom channel.**
Renaming needs the channel's internal id, which the assistant only sees right
after it creates or edits a campaign. In a later chat it has no way to look the
id up. Rename the channel in the dashboard instead.
Asking the assistant to rename a channel by editing the campaign does something
different. It points the campaign at a new channel and leaves the old one
behind, which changes the channel's reporting name and splits its history.
## What this is not
* Not a full write API. The connector can make the changes listed above, and
nothing else. Use the [Linkrunner data API](/api-reference/data-apis) for
programmatic writes.
* Not a way to delete things. Campaigns, funnels, domains, and projects can only
be deleted from the dashboard.
* Not a way to edit ads. The ad-account tools read from Meta and Google; they
cannot pause a campaign, change a budget, or edit targeting there.
* Not a generic SQL/Postgres bridge. It calls the same endpoints the
dashboard uses, with the same access controls.
* Not a substitute for webhooks. Use [webhooks](/features/webhooks) for
real-time install and signup notifications.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Meta Install Referrer
Source: https://docs.linkrunner.io/features/meta-install-referrer
Receive ad campaign metadata from device storage for improved attribution
**Using an AI coding agent?** Have it set this up for you:
```bash theme={null}
npx @linkrunner/skills add meta-install-referrer
```
The skill adds the Facebook App ID to your Android manifest and verifies the wiring. See [Linkrunner Agent Skills](/ai-agent-skills).
## Overview
Meta install referrer allows Linkrunner to receive ad campaign metadata from a device's local storage.
## Meta Install Referrer Basic Flow
The basic flow of the Meta install referrer mechanism is as follows:
1. Once the SDK initializes, it uses the app's Facebook App ID to make a request to the Meta Content Provider API, retrieving the stored metadata from the Facebook app.
2. Linkrunner SDK sends the install event, along with the attribution data, to the Linkrunner servers.
Use the [Meta Android attribution testing guide](/testing/meta-android-attribution-testing) to verify the complete Ads Manager preview, install, and `getAttributionData()` flow.
## Prerequisites
To support the Meta install referrer, the following is required:
* **Android SDK**: Integrate with version 3.5.2 or higher.
* **Flutter SDK**: Integrate with version 3.6.2 or higher.
* **React Native SDK**: Integrate with version 2.6.2 or higher.
* **Facebook App Version**: Users must have version 428.x.x or above installed on their device.
* **Instagram App Version**: Users must have version 296.x.x or above installed on their device.
## Configure Meta Install Referrer Support
To enable Meta install referrer support make the Facebook App ID available to the SDK by adding it to the AndroidManifest.xml. This can be done either when integrating the Facebook SDK with the app or when integrating the Linkrunner SDK with the app.
### With Facebook SDK Integrated
Refer to [Facebook's official guide](https://developers.facebook.com/docs/android/getting-started) to learn how to add the Facebook App ID to your AndroidManifest.xml. The SDK will read the Facebook App ID from the meta-data tag.
### Without Facebook SDK Integration
Include the following tag in AndroidManifest.xml:
```xml theme={null}
```
Include in your strings.xml file:
```xml theme={null}
```
**Example:**
```xml theme={null}
1234567890123456
```
# Meta SKAN Setup
Source: https://docs.linkrunner.io/features/meta-skan-setup
Set up SKAdNetwork attribution for Meta Ads iOS campaigns with Linkrunner
SKAdNetwork (SKAN) is Apple's privacy-preserving framework for iOS attribution. Meta uses SKAN postbacks to measure and optimize iOS App Promotion campaigns. This guide walks you through the complete Meta SKAN setup with Linkrunner.
Track your progress with the **Meta SKAN Integration Checklist**: [Integrations → Meta Ads → SKAN Integration](https://dashboard.linkrunner.io/dashboard/integrations/meta-ads?tab=skan_integration).
This guide covers the Meta-specific SKAN setup. If you haven't connected Meta Ads to Linkrunner yet, start with the
[Meta Ads integration guide](/ad-networks/meta-ads).
## Prerequisites
Before you begin, make sure you have:
* A [connected Meta Ads integration](/ad-networks/meta-ads) (App ID, ad account, and CAPI configured)
* Your app live with a SKAN-ready Linkrunner SDK version (see [minimum versions](/features/skadnetwork-integration#minimum-sdk-versions))
* Admin access to your Meta Business Manager and Meta Events Manager
## 1. Prepare Your App
### Update the SDK
Your app must run a SKAN-ready SDK version. The SDK updates conversion values automatically as users perform events, so no SKAN code is needed on your side. Check the [minimum SDK versions](/features/skadnetwork-integration#minimum-sdk-versions).
### Add Meta's Ad Network Identifiers
Apple only generates SKAN postbacks for ad networks listed in your app's `Info.plist`. Add Meta's identifiers under `SKAdNetworkItems`:
```xml theme={null}
SKAdNetworkItems
SKAdNetworkIdentifier
v9wttpbfk9.skadnetwork
SKAdNetworkIdentifier
n38lu8286q.skadnetwork
```
On Expo, add these under `ios.infoPlist.SKAdNetworkItems` in your `app.json`.
### Add the Postback Copy Endpoints
Add these keys to your `Info.plist` so Apple sends copies of SKAN postbacks to Linkrunner:
```xml theme={null}
NSAdvertisingAttributionReportEndpoint
https://linkrunner-skan.com
AttributionCopyEndpoint
https://linkrunner-skan.com
```
The Linkrunner Expo plugin adds these keys automatically.
## 2. Configure the SKAN Schema in Linkrunner
Linkrunner converts your event list into SKAN conversion values (0-63) and generates the fine and coarse values Meta needs.
Go to **Settings → SKAN Setup → Events Setup** in the [Linkrunner
Dashboard](https://dashboard.linkrunner.io/dashboard/settings/skan-setup?tab=event-setup). Add the events that
matter for optimization and drag rows to set priority. The topmost event has the highest priority.
For revenue events, set a **min** and **max** range. For occurrence events, set an **average**.
Click **Generate SKAN Mapping**. The dashboard shows the combination count in real time and warns if your
configuration would exceed Apple's limit of 64 conversion values.
If you already configured the schema for Google Ads, no extra work is needed. Linkrunner reuses the same schema for
Meta.
For background on how this maps to SKAN 4.0, see [Conversion Values in SKAdNetwork](/features/skadnetwork-conversion-values).
## 3. Map Events to Meta
Map each Linkrunner event to the corresponding Meta event (for example, `Purchase`, `AddToCart`, `ViewContent`) so postbacks are tagged correctly in Meta Events Manager.
Go to [Integrations → Meta Ads → Event
Mapping](https://dashboard.linkrunner.io/dashboard/integrations/meta-ads?tab=event_mapping).
For each event in your SKAN schema, select the Meta event it should map to.
Turn on **Send Revenue** for revenue events and select the currency.
Click **Save**.
See the full walkthrough in [Meta Ads → Event Mapping](/ad-networks/meta-ads#2-set-up-event-mapping).
## 4. Configure SKAN in Meta Events Manager
Complete this section only after the SKAN-ready SDK version is live in your app. Configuring Meta before the SDK
update ships leads to mismatched conversion values.
### Copy the Conversion Values into Meta
Open **Settings → SKAN Setup → Meta SKAN Configuration** in the [Linkrunner
Dashboard](https://dashboard.linkrunner.io/dashboard/settings/skan-setup?tab=meta-skan-configuration) to view
the generated fine and coarse conversion values.
In **Meta Events Manager**, select your app, then go to **Settings → iOS SKAdNetwork** and click **Configure
Conversion Values**.
Copy the fine and coarse conversion values from the Linkrunner Dashboard into Meta's configuration and save.
### Turn Off SKAdNetwork for the Facebook SDK
Turn **OFF** the **SKAdNetwork for the Facebook SDK** toggle so Meta routes SKAN postbacks through Linkrunner instead of the Facebook SDK:
Go to **Events Manager → Datasets** and select your dataset.
Open the **Apple's SKAdNetwork** section, then **Extra Settings → SKAdNetwork for the Facebook SDK**.
Turn the toggle **OFF**.
When this toggle is ON, Meta receives app activity via SKAdNetwork directly from the Facebook SDK and Linkrunner
cannot deliver accurate postbacks.
### Grant Access to Linkrunner
For Linkrunner to configure the SKAN schema on Meta on your behalf, grant access to **[access@linkrunner.io](mailto:access@linkrunner.io)** in your Meta Business Manager.
## 5. Verify the Setup
Confirm every item is complete in the [Meta SKAN Integration
Checklist](https://dashboard.linkrunner.io/dashboard/integrations/meta-ads?tab=skan_integration).
SKAN postbacks only flow once a Meta iOS App Promotion campaign is live and driving installs.
Postbacks appear in the [SKAN dashboard](/features/skan-dashboard/overview) as Apple delivers them. Apple delays
postbacks by design, so expect data 24 to 48 hours after installs begin.
## Troubleshooting
### No postbacks in the SKAN dashboard
* Confirm the `Info.plist` keys from [section 1](#1-prepare-your-app) are in the live app build
* Confirm the SKAN-ready SDK version is live and rolled out to 100% of users
* Wait 24 to 48 hours. Apple delays SKAN postbacks by design
### Conversion values look wrong in Meta
* Confirm the values in Meta Events Manager match **Settings → SKAN Setup → Meta SKAN Configuration** exactly
* Confirm you regenerated and re-copied the values after any schema change in Linkrunner
### Meta still attributes via the Facebook SDK
* Confirm the **SKAdNetwork for the Facebook SDK** toggle is **OFF** for your dataset
***
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Push Notification Providers
Source: https://docs.linkrunner.io/features/push-notification-providers
Configure CleverTap, WebEngage, or MoEngage to send Linkrunner campaign links in push notifications
If you send push notifications through CleverTap, WebEngage, or MoEngage, you can track notification opens in Linkrunner without writing custom push-handling code. You set the campaign link as the notification's click action in the provider's dashboard, and your existing `handleDeeplink` setup does the rest.
Start with [Push Notification Tracking](/features/push-notification-tracking) if you haven't yet. You need a Linkrunner campaign link and `handleDeeplink` implemented in your app before configuring a provider.
## How it works with a provider
1. Create a campaign in the [Linkrunner Dashboard](https://dashboard.linkrunner.io/dashboard?m=create-campaign) and copy its link
2. In the provider's campaign composer, set the campaign link as the notification's click action (each provider calls this something different, see below)
3. On tap, the OS opens your app with the link and your `handleDeeplink` listeners pass it to Linkrunner
4. The open shows up in your campaign analytics
This flow relies on [deep linking](/features/deep-linking-setup) being set up for your campaign link domain. Without it, tapping the notification opens the browser instead of your app.
## Provider setup
When creating a push campaign in CleverTap:
1. In the **What** step of the campaign, find the **On click, navigate to** setting
2. Select **Deep Link/URL** and paste your Linkrunner campaign link
3. Send the campaign as usual
On tap, CleverTap opens the link, your app receives it, and `handleDeeplink` attributes the open.
**Using key-value pairs instead:** add a custom key-value pair (for example, `link` = your campaign link) under the campaign's advanced settings. Then implement CleverTap's [`CTPushNotificationListener`](https://developer.clevertap.com/docs/android-push), read the value from the payload in `onNotificationClickedPayloadReceived`, and pass it to `handleDeeplink`:
```kotlin theme={null}
override fun onNotificationClickedPayloadReceived(payload: HashMap?) {
val link = payload?.get("link") as? String
if (link != null) LinkRunner.getInstance().handleDeeplink(link)
}
```
When creating a push campaign in WebEngage:
1. In the campaign's **Message** step, set the **Click Action** (primary CTA) to your Linkrunner campaign link
2. Send the campaign as usual
On tap, WebEngage opens the link, your app receives it, and `handleDeeplink` attributes the open.
**Using key-value pairs instead:** add a custom key-value pair (for example, `link` = your campaign link) in the campaign body. Then register a [push notification callback](https://docs.webengage.com/docs/android-callbacks), read the value from `getCustomData()` in the click callback, and pass it to `handleDeeplink`:
```kotlin theme={null}
WebEngage.registerPushNotificationCallback(object : PushNotificationCallbacks {
override fun onPushNotificationClicked(
context: Context,
data: PushNotificationData
): Boolean {
val link = data.customData?.getString("link")
if (link != null) LinkRunner.getInstance().handleDeeplink(link)
return false // let WebEngage handle the default click action
}
// implement the remaining callbacks as no-ops
})
```
When creating a push campaign in MoEngage:
1. In **Step 2: Messaging**, open the [**Actions**](https://help.moengage.com/hc/en-us/articles/208703456-Notification-Actions) tab
2. Set the default click action to **Deep-link to URI** and paste your Linkrunner campaign link
3. Send the campaign as usual
On tap, MoEngage opens the link, your app receives it, and `handleDeeplink` attributes the open.
**Using key-value pairs instead:** add custom key-value pairs (for example, `link` = your campaign link) in the campaign. Then extend MoEngage's [`PushMessageListener`](https://moengage.com/docs/developer-guide/android-sdk/push/advanced/callbacks-and-customisation), read the value from the payload in `onNotificationClick`, and pass it to `handleDeeplink`:
```kotlin theme={null}
class CustomPushListener : PushMessageListener() {
override fun onNotificationClick(activity: Activity, payload: Bundle): Boolean {
val link = payload.getString("link")
if (link != null) LinkRunner.getInstance().handleDeeplink(link)
return false // let MoEngage handle redirection
}
}
```
Register the listener during SDK initialization as described in the [callbacks guide](https://moengage.com/docs/developer-guide/android-sdk/push/advanced/callbacks-and-customisation).
Use one Linkrunner campaign per push campaign so each provider campaign's opens roll up separately in your analytics.
## Other providers
Any provider that supports a click action URL, launch URL, or custom key-value pairs (Firebase, OneSignal, Braze, Netcore, etc.) works the same way. Follow the generic setup in [Push Notification Tracking](/features/push-notification-tracking).
## Troubleshooting
**Tapping the notification opens the browser?** Your campaign link domain is not set up for [deep linking](/features/deep-linking-setup), so the OS treats it as a web URL. Set up deep linking, or switch to the key-value pair approach.
**Opens are not showing up in the campaign?** Log the value reaching `handleDeeplink` and confirm it is the exact campaign link. If you use key-value pairs, check the key name matches what your code reads.
***
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Push Notification Tracking
Source: https://docs.linkrunner.io/features/push-notification-tracking
Track push notification opens and attribute them to campaigns using handleDeeplink
Measure how well your push notifications perform by attributing app opens to a Linkrunner campaign. You put a campaign link inside the notification, and when the user taps it, your app passes the link to the SDK's `handleDeeplink` function.
## How it works
1. You create a campaign in Linkrunner and copy its link
2. You send a push notification that carries the link
3. The user taps the notification and your app opens
4. Your app passes the link to `handleDeeplink`
5. Linkrunner attributes the app open to the campaign, and it shows up in your campaign analytics
## Prerequisites
* Linkrunner SDK installed and initialized in your app. See your platform's guide: [Android](/sdk/android), [iOS](/sdk/ios), [React Native](/sdk/react-native), [Flutter](/sdk/flutter), [Expo](/sdk/expo).
* `handleDeeplink` implemented in your app. Refer to your SDK guide ([Android](/sdk/android#handle-deeplink), [iOS](/sdk/ios#handle-deeplink), [React Native](/sdk/react-native#handle-deeplink), [Flutter](/sdk/flutter#handle-deeplink)) for the base setup.
* A push notification provider (Firebase Cloud Messaging, OneSignal, Braze, CleverTap, etc.) already sending notifications to your app.
## Setup
In the [Linkrunner Dashboard](https://dashboard.linkrunner.io/dashboard?m=create-campaign):
1. Click **"Create Campaign"**
2. Name it after the notification or push campaign (for example, `Diwali Sale Push`)
3. Copy the campaign link
Create one campaign per push campaign, not one per notification. All opens from that push campaign then roll up under a single campaign in your analytics.
Include the campaign link in the notification's data payload under a key your app reads on tap (for example, `link`).
```json theme={null}
{
"notification": {
"title": "Diwali Sale is live!",
"body": "Up to 50% off, today only."
},
"data": {
"link": "https://get.yourdomain.com/diwali-sale"
}
}
```
If your provider supports a "launch URL" or "deep link" field (OneSignal, Braze, CleverTap all do), you can put the campaign link there instead. The OS then opens your app with the link, and your existing deep link listeners receive it. Using CleverTap, WebEngage, or MoEngage? See [Push Notification Providers](/features/push-notification-providers) for exact steps.
When the user taps the notification, read the link from the payload and pass it to `handleDeeplink`.
With Firebase Cloud Messaging, data payload keys arrive as intent extras in your launcher activity:
```kotlin theme={null}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
trackPushOpen(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
trackPushOpen(intent)
}
private fun trackPushOpen(intent: Intent) {
intent.extras?.getString("link")?.let { link ->
LinkRunner.getInstance().handleDeeplink(link)
}
}
```
Read the link from the notification's `userInfo` in your `UNUserNotificationCenterDelegate`:
```swift theme={null}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let userInfo = response.notification.request.content.userInfo
if let link = userInfo["link"] as? String {
await LinkrunnerSDK.shared.handleDeeplink(url: link)
}
}
```
With `@react-native-firebase/messaging`:
```javascript theme={null}
import messaging from '@react-native-firebase/messaging';
import linkrunner from 'rn-linkrunner';
// App opened from the background by a notification tap
messaging().onNotificationOpenedApp((message) => {
const link = message?.data?.link;
if (link) linkrunner.handleDeeplink(link);
});
// App opened from a killed state by a notification tap
messaging()
.getInitialNotification()
.then((message) => {
const link = message?.data?.link;
if (link) linkrunner.handleDeeplink(link);
});
```
With `firebase_messaging`:
```dart theme={null}
// App opened from the background by a notification tap
FirebaseMessaging.onMessageOpenedApp.listen((message) {
final link = message.data['link'];
if (link != null) LinkRunner().handleDeeplink(link);
});
// App opened from a killed state by a notification tap
final message = await FirebaseMessaging.instance.getInitialMessage();
final initialLink = message?.data['link'];
if (initialLink != null) LinkRunner().handleDeeplink(initialLink);
```
If your provider opens the campaign link as a launch URL instead, you don't need the code above. Your existing `handleDeeplink` deep link listeners already receive the link.
1. Send a test notification with the campaign link in the payload to your own device
2. Tap the notification
3. Open the campaign in the [dashboard](https://dashboard.linkrunner.io/dashboard/campaigns) and verify the app open appears in its analytics
## Troubleshooting
**Opens are not showing up in the campaign?** Log the value you pass to `handleDeeplink` and confirm it is the exact campaign link. A missing or misspelled payload key (`link`) is the most common cause.
**Works when the app is in the background but not from a killed state?** Cold starts need their own handling (`getInitialNotification` on React Native, `getInitialMessage` on Flutter, `onCreate` extras on Android). Make sure `handleDeeplink` is called after the SDK is initialized.
**The notification opens the browser instead of the app?** Your provider is treating the link as a web URL. Use the data payload approach, or set up [deep linking](/features/deep-linking-setup) so the campaign link domain opens your app directly.
***
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Set Up Referral Tracking
Source: https://docs.linkrunner.io/features/referral-codes
Pass a referral code through install, read it in your app, and record the referral
Linkrunner carries a referral code from a clicked link through install. Your app reads the code from the `deeplink` returned by `getAttributionData()` and sends it to your backend.
Your app is responsible for creating referral codes, validating them, and applying rewards.
## Before you start
* [Configure a Linkrunner subdomain](/features/subdomain-setup), such as `get.example.com`.
* [Set up deep linking](/features/deep-linking-setup) for your app.
* Choose one query parameter name, such as `referrer`, and use it consistently.
## 1. Create a referral link
Add the referring user's code or ID to your Linkrunner subdomain:
```text theme={null}
https://get.example.com?referrer=user_123
```
You can include an app destination in the same link:
```text theme={null}
https://get.example.com/product/987?referrer=user_123
```
Generate the link in your app or backend when a user opens your referral or invite screen. URL-encode the value before adding it to the link.
You do not need to create a campaign to pass a referral code. Direct subdomain links do not appear in campaign reporting.
## 2. Share the link
Let the referring user share the complete link. Keep the `referrer` parameter intact through any redirect or URL shortener.
When the recipient opens the link, Linkrunner sends them to the app or store. After install, the original URL is available in `getAttributionData()`:
```json theme={null}
{
"deeplink": "https://get.example.com?referrer=user_123"
}
```
## 3. Read the referral code
Call `getAttributionData()` after the Linkrunner SDK is initialized. Parse the returned `deeplink` and read the `referrer` query parameter.
```kotlin theme={null}
val referralCode = attributionData.deeplink
?.let { Uri.parse(it).getQueryParameter("referrer") }
```
[Get attribution data on Android](/sdk/android#getting-attribution-data)
```swift theme={null}
let referralCode = URLComponents(string: deeplink)?
.queryItems?
.first(where: { $0.name == "referrer" })?
.value
```
[Get attribution data on iOS](/sdk/ios#getting-attribution-data)
```javascript theme={null}
const referralCode = new URL(attributionData.deeplink)
.searchParams.get("referrer");
```
[Get attribution data in React Native](/sdk/react-native#getting-attribution-data)
```dart theme={null}
final referralCode = Uri.parse(attributionData.deeplink!)
.queryParameters['referrer'];
```
[Get attribution data in Flutter](/sdk/flutter#getting-attribution-data)
Store the code until the recipient signs up or completes the event that qualifies the referral.
## 4. Record the referral
Send the referral code and the recipient's user ID to your backend. Validate the relationship and record it before applying a reward.
At a minimum, reject:
* Invalid or expired codes
* Self-referrals
* A recipient using more than one referral code
* The same qualifying event being rewarded twice
Validate referrals and grant rewards on your backend. Do not trust a referral code submitted only by the client.
## Add campaign reporting
If you also need click and install reporting in Linkrunner, append the referral parameter to a generated campaign link instead of using the subdomain directly.
```text theme={null}
https://get.example.com/AbCdEf?referrer=user_123
```
See [Deep Link Management](/features/deep-link-management) for the campaign setup and parameter precedence rules.
## Testing
1. Create a referral link with a test code.
2. Uninstall the app from a real device.
3. Open the referral link, then install and open the app.
4. Confirm that `getAttributionData()` returns the link in `deeplink`.
5. Confirm that your app extracts the referral code.
6. Sign up and confirm that your backend records the correct referrer and recipient.
Deferred attribution is stored from the first install. Uninstall the app before repeating a test with a different referral code.
## Troubleshooting
**`deeplink` is null.** Confirm that the SDK was initialized before calling `getAttributionData()` and that the app was installed through the referral link.
**The referral parameter is missing.** Open the final URL before the store redirect and confirm that no redirect or URL shortener removed it.
**The previous referral code is returned.** Uninstall the app before testing another link. Attribution data is cached from the first install.
**The referral is not visible in campaign reporting.** Direct subdomain links are not associated with a campaign. Use a generated campaign link when you need dashboard reporting.
**The code is present but no referral is recorded.** Check the app-to-backend request and your referral validation logic. Linkrunner forwards the code but does not apply referral rules or rewards.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Remarketing & Reattribution
Source: https://docs.linkrunner.io/features/remarketing
Remarketing lets you measure when existing users return to your app through your campaigns. Linkrunner tracks these returning users through **reattribution** — attributing a returning user back to the campaign that brought them back.
## How Reattribution Works
When a user who has already installed your app returns after a period of inactivity, Linkrunner checks whether that return can be attributed to a marketing campaign. This is called **reattribution**.
For a user to be reattributed, two conditions must be met:
1. **The user must have been inactive** for at least the configured **inactivity window** (default: 0 days).
2. **The reinstall must occur within the reattribution eligibility window** — the maximum time between an uninstall and reinstall for the return to count as a reattribution (default: 90 days).
If both conditions are satisfied, the user's return is attributed to the campaign associated with the ad interaction, and a **reengagement** record is created.
## Reattribution Triggers
A reactivation event is the event a user completes to come back to the app. Reactivation events trigger reattribution and signal a user's return.
Linkrunner supports two reactivation events:
* **App Install (Reinstall)** — The user reinstalls the app after previously uninstalling it. Linkrunner detects reinstalls using device identifiers (GAID on Android, IDFA on iOS).
* **Deep Link Open** — The user clicks a deep link and is redirected into the app. For reattribution to work, deep linking must be configured in your app and the `handleDeepLink` function from the Linkrunner SDK must be called when the app opens via a deep link. See the [deep linking setup guide](/features/deep-linking-setup) for configuration details.
**Prerequisites for reattribution tracking:**
* [Deep linking](/features/deep-linking-setup) must be set up in your app.
* The `handleDeepLink` function from the Linkrunner SDK must be implemented. Refer to your SDK guide ([React Native](/sdk/react-native#handle-deeplink), [Flutter](/sdk/flutter#handle-deeplink), [Android](/sdk/android#handle-deeplink), [iOS](/sdk/ios#handle-deeplink), [Unity](/sdk/unity#handle-deeplinks)) for details.
* Android backup configuration must be set up for reinstall detection on Android. Refer to your SDK guide ([React Native](/sdk/react-native#step-4%3A-android-backup-configuration), [Flutter](/sdk/flutter#backup-configuration), [Android](/sdk/android#step-3%3A-backup-configuration)) for details.
On iOS, ATT (App Tracking Transparency) permission must be granted by the user for reattribution to work. Without ATT consent, Linkrunner cannot access the IDFA needed to identify returning users.
## Push Notification Campaigns
Remarketing campaigns also include push notification campaigns run through engagement platforms like **MoEngage** and **WebEngage**. These campaigns are tracked as remarketing campaigns within Linkrunner.
The flow is similar to a user opening the app via a Linkrunner link — the marketer creates a campaign on Linkrunner and uses the generated link in a push notification campaign on engagement platforms. When a user opens the app through the push notification link, Linkrunner attributes the return to that campaign.
## Understanding the Windows
### Inactivity Window
The minimum number of days a user must be inactive before they can qualify for reattribution. Linkrunner determines inactivity based on the **last app open** — the time since the user last opened the app is compared against the configured inactivity window.
| | |
| ----------- | ------ |
| **Default** | 0 days |
**Example:** With a 7-day inactivity window, a user whose last app open was 3 days ago would **not** qualify for reattribution. A user whose last app open was 10 days ago **would** qualify.
### Reattribution Window
The maximum time between an uninstall and reinstall for the reinstall to count as a reattribution.
| | |
| --------- | ------- |
| **Value** | 90 days |
# Audience from Tracked Events
Source: https://docs.linkrunner.io/features/remarketing-audience-events
Build audience segments from events that are already being sent to the ad platform.
## Meta
### Prerequisites
* The event source (Pixel, App, or Offline) is already set up in Meta.
* Relevant events are already being sent through Pixel, SDK, or Conversions API. You can send conversion events via [Linkrunner](/ad-networks/meta-ads) or directly.
* You know which event you want to build the audience from, such as `Purchase`, `AddToCart`, `ViewContent`, `Lead`, or a custom event.
Meta allows audiences to be built from website events, app activity, and other data sources. Custom events set up using Pixel code or Conversions API can also be used to build audiences.
### Steps
1. Go to **Audiences** in Meta Ads Manager.
2. Click **Create Audience** and select **Custom Audience**.
3. Choose the relevant source:
* **Website** for web events
* **App activity** for app events
* **Offline activity** if you are using offline event sets
4. Select the correct data source or app.
5. Choose the event rule for the audience:
* People who triggered specific events
* People who triggered a custom event already being sent via Pixel or Conversions API
6. Set the retention window (e.g. 7, 30, 60, or 180 days). Users are removed from the audience after this period unless they meet the criteria again. The maximum retention window is 180 days.
7. Add inclusion or exclusion rules if needed.
8. Name the audience clearly.
9. Save the audience and wait for it to populate.
## Google
### Prerequisites
* Your app or website is linked to your Google Ads account.
* Relevant conversion events are being sent to Google Ads via [Linkrunner](/ad-networks/google-ads) or directly.
### Steps
1. In Google Ads, go to **Tools > Shared library > Audience manager**.
2. Click the **+** icon and select the source — **App Users** or **Website Users**.
3. Scroll down to the **Actions** section and pick the event.
4. Select **Any action** and enter the number of days to include all app users who have been active in that range. The default is 30 days and the maximum is 540 days.
5. Select **Exclude Action** and choose the action name and number of days as required.
6. Repeat the previous step to exclude additional users who took other actions.
7. Name the segment clearly.
8. Save the audience and wait for it to populate.
# Creating Audience Segments
Source: https://docs.linkrunner.io/features/remarketing-audience-segments
Before running remarketing campaigns, you need to create audience segments on your ad platform. These segments define which users to target with your remarketing ads.
There are two ways to create audience segments:
* **[Upload a customer list](/features/remarketing-audience-upload)** — Upload a list of customers directly to the ad platform.
* **[Create an audience from tracked events](/features/remarketing-audience-events)** — Build an audience from events already being sent to the ad platform.
# Audience from Customer List
Source: https://docs.linkrunner.io/features/remarketing-audience-upload
Upload a list of customers directly to the ad platform to create a custom audience for remarketing.
## Google
### 1. Prepare Your Data
* Create a segment in Linkrunner from Audience Cohort and download the CSV
### 2. Upload to Google Ads
#### Navigate to Audience Manager
1. In your Google Ads account, click the **Tools** icon (the wrench) in the left menu
2. Under the "Shared library" section, click **Audience manager**
#### Create a New Customer List
1. Click the blue **+** button
2. Select **Customer list** from the drop-down menu
3. Select "Upload a file manually" and click **Continue**
#### Configure the List
* **Segment Name**: Give your audience a clear, recognizable name (e.g., "Newsletter Subscribers Q1")
* **Data Type**: Choose "Upload emails, phones, and/or mailing addresses"
* **Data to upload**: Attach your prepared CSV file
* **Membership Duration**: Choose how long you want a user to stay on this list. You can set a specific number of days or choose "No expiration" (the maximum is 540 days)
Click on **"Save and continue"** to create the customer list.
## Meta
### 1. Prepare Your Data
* Create a segment in Linkrunner from Audience Cohort and download the CSV
### 2. Upload to Meta Ads
#### Navigate to the Audiences Dashboard
1. Open Meta Ads Manager
2. Click the **All Tools** menu (the three horizontal lines or "hamburger" icon) on the left sidebar
3. Select **Audiences**
#### Create a Custom Audience
1. Click the blue **Create Audience** dropdown button
2. Select **Custom Audience**
#### Choose Your Source
1. Under "Your sources," select **Customer list** and click **Next**
2. **Upload File**: Drag and drop your CSV or TXT file, or click to upload it
#### Set Up Your List
1. **Customer Value**: Meta will ask if your list includes a column for customer value. Select **No**, then click **Next**
2. **Name Your Audience**: Give it a clear, descriptive name (e.g., "Purchasers - March 2026") and an optional description. Click **Next**
#### Map Your Identifiers
1. Meta will preview your data and attempt to map your columns to its accepted identifiers
2. **Action Needed**: Review the "Mapped" and "Unmapped" tabs. If a column is unmapped, manually select the correct identifier from the dropdown menu, or tell Meta to ignore that column
3. Click **Import and Create** to create the audience.
# Running Remarketing Campaigns
Source: https://docs.linkrunner.io/features/remarketing-campaigns
Once you have [created your audience segments](/features/remarketing-audience-segments), you can use them to run remarketing campaigns on Meta and Google.
## Meta
### Prerequisites
* You have created a [Custom Audience](/features/remarketing-audience-segments#meta) in Meta Ads Manager.
### Steps
1. Open **Ads Manager** and click **Create**.
2. Select the right campaign objective.
3. Set the campaign name.
4. At the ad set level, scroll to the **Audience** section.
5. Under **Include these custom audiences**, search for and add the custom audiences you created in Audience Manager.
6. Configure the rest of your ad set (placements, budget, schedule) and add your creatives.
7. Review and publish the campaign.
## Google
### Prerequisites
* You have created an [audience segment](/features/remarketing-audience-segments#google) in Google Ads Audience Manager.
### Steps
1. In Google Ads, click **New campaign**.
2. Pick a campaign goal and subtype.
The **App engagement** subtype is only available for apps with 50,000+ installs.
3. Select your app platform and choose the app.
4. Set budget, bidding, locations, and campaign dates.
5. At the ad group level, scroll to the **Audience signal** section.
6. Click **Add an audience signal** and select your saved audience list or audience segment.
7. Add creatives and assets.
8. Review campaign settings and launch.
# How to Run Remarketing Campaigns Using Linkrunner
Source: https://docs.linkrunner.io/features/remarketing-guide
Follow these steps to set up and run remarketing campaigns with Linkrunner.
Learn how Linkrunner detects returning users, the different reactivation events, and how attribution is assigned.
[Remarketing & Reattribution](/features/remarketing)
Ensure your app is configured for reattribution tracking.
* Set up [deep linking](/features/deep-linking-setup) in your app.
* Implement the `handleDeepLink` function from the Linkrunner SDK. Refer to your SDK guide ([React Native](/sdk/react-native#handle-deeplink), [Flutter](/sdk/flutter#handle-deeplink), [Android](/sdk/android#handle-deeplink), [iOS](/sdk/ios#handle-deeplink), [Unity](/sdk/unity#handle-deeplinks)) for details.
* Android backup configuration must be set up for reinstall detection on Android. Refer to your SDK guide ([React Native](/sdk/react-native#step-4%3A-android-backup-configuration), [Flutter](/sdk/flutter#backup-configuration), [Android](/sdk/android#step-3%3A-backup-configuration)) for details.
* On iOS, ATT (App Tracking Transparency) permission must be granted by the user. Without ATT consent, Linkrunner cannot access the IDFA needed to identify returning users.
Build custom audience segments on Meta and Google using tracked events already being sent to the ad platform.
[Creating Audience Segments](/features/remarketing-audience-segments)
Use the audience segments you created to run remarketing campaigns on Meta and Google.
[Running Remarketing Campaigns](/features/remarketing-campaigns)
# Retention: Cumulative vs Classic vs Rolling
Source: https://docs.linkrunner.io/features/retention
What app retention rate is and how Linkrunner calculates cumulative, classic, and rolling retention, including the formula and a worked example.
**App retention rate is the percentage of a group of installs (a cohort) that comes back to open the app again after installing, measured a set number of days later.** Linkrunner reports it three ways: **cumulative**, **classic**, and **rolling**. In the dashboard these appear as the **Cumulative Retention**, **Classic Retention**, and **Rolling Retention** columns. This page explains what each one means, how the numbers are calculated, and which one to use.
## What is app retention rate?
Every retention number is built from three things:
* **Cohort**: a group of installs, grouped by their install day. The install day is **Day 0**.
* **A return**: any time a user opens the app after installing. Opening the app several times on the same day counts once.
* **Day N**: how many days after install you are measuring (Day 1, Day 7, and so on).
Retention is always a percentage of the cohort.
## How is retention rate calculated?
The retention rate formula is the same for all three methods:
```
Day N retention = (installs from the cohort that count as retained on Day N) / (cohort size) × 100
```
The only thing that changes between the three methods is which installs "count as retained" on Day N.
## Cumulative vs classic vs rolling retention: what is the difference?
Here are the three definitions as standalone statements:
* **Cumulative retention** is the percentage of an install cohort that opened the app at least once between Day 1 and Day N.
* **Classic retention** is the percentage of an install cohort that opened the app on exactly Day N.
* **Rolling retention** is the percentage of an install cohort that opened the app on Day N or any day after.
| Method | An install counts on Day N if it opened the app... |
| -------------- | -------------------------------------------------- |
| **Cumulative** | on **any day from Day 1 through Day N** |
| **Classic** | on **exactly** Day N |
| **Rolling** | on Day N **or any day after** |
Each method answers a different question:
* **Cumulative Day 7**: "What share of the cohort came back at all during the first 7 days?"
* **Classic Day 7**: "What share of the cohort opened the app on day 7 specifically?"
* **Rolling Day 7**: "What share of the cohort was still active on day 7 or later?"
Both cumulative and rolling are always equal to or higher than classic for the same day, because each one includes "exactly Day N" plus more. Cumulative and rolling count different groups, so either one can be higher than the other depending on your users.
## A worked retention example
Five users install your app on the same day (Day 0). Here are the days each of them opened the app afterwards:
| User | Days the app was opened after install |
| ---- | ------------------------------------- |
| A | 1, 2, 3, 5, 7, 9 |
| B | 1, 4, 10 |
| C | 1, 2 |
| D | 3 |
| E | (never opened again) |
The cohort size is **5**.
### Day 1
* **Cumulative D1** = opened on any day from 1 to 1 → A, B, C = **3 of 5 = 60%**
* **Classic D1** = opened on exactly day 1 → A, B, C = **3 of 5 = 60%**
* **Rolling D1** = opened on day 1 or later → A, B, C, D = **4 of 5 = 80%**
At Day 1, cumulative and classic are identical, because "between day 1 and day 1" is just day 1. D only opened on day 3, so it misses both, but rolling Day 1 counts it because it returned after day 1. E never came back, so no number counts E.
### Day 7
* **Cumulative D7** = opened on any day from 1 to 7 → A, B, C, D = **4 of 5 = 80%**
* **Classic D7** = opened on exactly day 7 → A = **1 of 5 = 20%**
* **Rolling D7** = opened on day 7 or later → A (days 7 and 9) and B (day 10) = **2 of 5 = 40%**
Same cohort, three different numbers. Cumulative is high because A, B, C, and D all came back at some point in the first week. Classic is low because only A happened to open on day 7 itself. Rolling sits in between: it counts A and B, who were still active on or after day 7, but not C or D, whose last visit was before day 7.
## What does a date filter do?
A date filter selects cohorts by their **install date**, not by the day users came back. Picking January 1 to January 31 measures the installs that happened in January and how many of them returned, no matter which day they returned on.
* The numerator and the denominator both use the install date, so they always describe the same group of installs.
* Installs still need a fair chance to reach Day N. If your range ends today, the most recent installs in it have not reached Day 7 or Day 30 yet, so they drop out of those longer windows. To read a complete Day 30 number, pick a range that ended at least 30 days ago.
## Which retention metric should you use?
All three measure retention with different rules, so they answer different questions.
* **Cumulative** counts an install if it returned at all within the first N days. It is the best measure of early engagement: how many of your installs you managed to bring back at least once. It is bounded to the first N days, so once a cohort matures it stops changing.
* **Classic** measures activity on one specific day. Use it for reports and for comparing against industry Day 1 or Day 7 benchmarks, which are usually defined this way. It is also fixed once a cohort matures.
* **Rolling** counts an install if it was still active on Day N or later. Use it to gauge long-term stickiness, since it captures users who stayed around even if they skipped the exact day.
**Rolling retention keeps changing over time.** Because it counts a return on Day N or any later day, a cohort's rolling Day N value keeps rising as users come back weeks or months later.
* Older time periods almost always show higher rolling retention than recent ones, because older cohorts have had more time for late returns to add up. A recent period can look worse than an old one purely for this reason, not because retention actually dropped.
* A rolling number you record today can go stale. If you export it or write it into a report, it will keep climbing afterwards, so refresh it regularly or it will understate the final value.
Cumulative and classic retention do not have this problem. Both are bounded (cumulative to the first N days, classic to a single day), so once a cohort matures their values are final.
## Which retention windows are available?
* **Cumulative retention**: Day 1, Day 7, Day 14, Day 30
* **Classic retention**: Day 1, Day 7, Day 14, Day 30
* **Rolling retention**: Day 1, Day 7
In the [Campaign Reporting API](/api-reference/reporting-campaigns), cumulative retention is returned as `retention` (`d1`, `d7`, `d14`, `d30`), rolling retention as `rolling_retention` (`d1`, `d7`), and classic retention as `classic_retention` (`d1`, `d7`, `d14`, `d30`).
## What details affect the retention numbers?
**Days are calendar days in your project's timezone.** Day N is a calendar-day difference, not a rolling 24-hour period. A user who installs at 11:55 PM and opens the app at 12:05 AM the next day is counted on Day 1.
**Retention is counted per install.** Each install is counted once per day, no matter how many times the app is opened. Installs reported by an ad network that never produced a tracked app open are not part of the retention cohort.
## How is the cohort size counted?
The denominator is the same for all three methods: the **cohort size**, or the number of installs being measured. It is not a separate "total installs" figure pulled from somewhere else. It is the installs in view that have had a fair chance to reach Day N.
That "fair chance" is the key rule. An install is only counted in the Day N cohort once it has existed for at least N full days, so each window has its own denominator:
* The **Day 1** cohort = installs that happened at least 1 day ago.
* The **Day 7** cohort = installs that happened at least 7 days ago.
* The **Day 30** cohort = installs that happened at least 30 days ago.
An install from 3 days ago is in the Day 1 cohort (it has had more than a day) but not yet in the Day 7 cohort (it has not reached its 7th day), so it is left out of the Day 7 number entirely, on both sides of the formula.
Because this rule is applied to the numerator and the denominator together, an install can only be counted as retained if it is also in the cohort. That is why a retention percentage never goes above 100%.
## Glossary
* **Cohort**: a group of installs grouped by the day they installed. The install day is Day 0.
* **Day 0**: the install day itself, the starting point for every retention window.
* **Day N**: the number of days after install at which retention is measured, such as Day 1, Day 7, Day 14, or Day 30.
* **N-day retention**: any retention figure measured a set number of days (N) after install.
* **Return**: an app open that happens after Day 0. Multiple opens on the same day count once.
* **Cohort maturity**: the point at which every install in a cohort has had a full N days to return, so a Day N number can no longer change (for cumulative and classic).
* **Bounded retention**: a metric that stops changing once a cohort matures. Cumulative and classic are bounded.
* **Unbounded retention**: a metric that keeps rising as late returns accumulate. Rolling retention is unbounded.
## Frequently asked questions
**What is app retention rate?**
App retention rate is the percentage of a group of installs (a cohort) that comes back to open the app again after installing, measured a set number of days later.
**How is retention rate calculated?**
Day N retention = (installs from the cohort that count as retained on Day N) / (cohort size) × 100. The only thing that changes between methods is which installs count as retained on Day N.
**What is the difference between cumulative, classic, and rolling retention?**
Cumulative retention counts an install that opened the app on any day from Day 1 through Day N. Classic retention counts an install that opened the app on exactly Day N. Rolling retention counts an install that opened the app on Day N or any day after.
**Why does rolling retention keep going up for the same period?**
Rolling retention counts a return on Day N and every day after, so late returns keep adding to it. Use cumulative or classic retention if you need a number that stays fixed once a cohort matures.
**Why is retention lower than my total install count?**
Retention only counts installs that opened the app and were tracked by the Linkrunner SDK. Installs reported by an ad network without a tracked app open are excluded from the cohort.
## Troubleshooting
**The Cumulative, Classic, and Rolling Retention columns show different numbers.** This is expected. Cumulative counts installs that returned at any point in the first N days, classic counts only installs that returned on day N exactly, and rolling counts installs that returned on day N or later. Each answers a different question, so the numbers rarely match.
**Day 14 or Day 30 retention looks empty or low.** Your recent cohorts have not matured yet. Widen the date range to include older installs that have had a full 14 or 30 days to return.
**Rolling retention keeps going up for the same period.** This is expected. Rolling counts Day N and every day after, so late returns keep adding to it. Use cumulative or classic retention if you need a number that stays fixed once a cohort matures.
**Retention is lower than your total install count suggests.** Retention only counts installs that opened the app and were tracked by the Linkrunner SDK. Installs reported by an ad network without a tracked app open are excluded from the cohort.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Send Consent
Source: https://docs.linkrunner.io/features/send-consent
Report DMA consent signals with your installs and events
## What this consent is for
Google's [EU User Consent Policy](https://www.google.com/about/company/user-consent-policy/) and the EU **Digital Markets Act (DMA)** require you to tell Google what a user agreed to before Google may use their conversion. Enforcement began on 6 March 2024.
It applies to end users located in the **European Economic Area** (the 27 EU states plus Iceland, Liechtenstein, and Norway), the **United Kingdom**, and **Switzerland**. It follows the user's location, not where your company is based.
Linkrunner forwards these values with every install and event it sends to Google. Report nothing for an in-scope user and Google has less to match on, so conversions can be dropped and campaign performance suffers.
**By default Linkrunner sends nothing.** Every signal starts as `unknown` until you set it, and unknown signals are left out of the request entirely. Linkrunner never fills in a default on your behalf, in either direction, so a user's consent state is only ever what your app reported.
Other privacy laws (India's DPDP Act, Brazil's LGPD, and so on) are separate frameworks with no Google conversion parameter. For a user outside the EEA, the UK, and Switzerland, report `isEEA` as denied and leave the other two unset.
## The three signals
| SDK field | Sent to Google as | Denied (`0`) | Granted (`1`) |
| --------------------------------- | -------------------- | -------------------------------------------------- | --------------- |
| `isEEA` | `eea` | European regulations do not apply to this user | They do apply |
| `hasConsentForDataUsage` | `ad_user_data` | The user refused sending user-level data to Google | The user agreed |
| `hasConsentForAdsPersonalization` | `ad_personalization` | The user refused ads personalization | The user agreed |
Each signal is `granted`, `denied`, or `unknown`. Google treats all three as required whenever their value is known, which cuts both ways:
* A value you know **must** be sent.
* A value you do not know **must not** be invented.
Linkrunner drops any signal left `unknown` instead of sending it as a denial, so a choice your user never made is never reported on their behalf. Never map `unknown` to `granted`.
Google also requires `lat` (limit ad tracking), but you do not set it. Linkrunner derives it from the device. A missing advertising ID is not a consent signal and is never treated as one.
## What to report
**For users outside the EEA, the UK, and Switzerland, report `isEEA` as denied and leave the other two unset.** The DMA does not apply to them, so `ad_user_data` and `ad_personalization` are not required.
Report `isEEA` as denied and leave the other two unset. The DMA does not apply, so `ad_user_data` and `ad_personalization` are not required for this user. Send them only if you genuinely know them.
```kotlin theme={null}
LinkRunner.getInstance().setConsent(
LinkrunnerConsent(isEEA = ConsentStatus.DENIED)
)
```
The two omitted fields default to `UNKNOWN` and are dropped from the payload.
Report all three from the answers your consent screen actually collected.
```kotlin theme={null}
LinkRunner.getInstance().setConsent(
LinkrunnerConsent(
isEEA = ConsentStatus.GRANTED,
hasConsentForDataUsage = ConsentStatus.GRANTED,
hasConsentForAdsPersonalization = ConsentStatus.DENIED
)
)
```
The values above are only an example of the shape of the call, not what you should send. Report whatever the user actually chose. Denying personalization while granting data usage is a normal combination, not a mistake.
Leave the signal `unknown`, which is what it is before you set anything. Linkrunner omits it.
```kotlin theme={null}
LinkRunner.getInstance().setConsent(
LinkrunnerConsent(isEEA = ConsentStatus.UNKNOWN)
)
```
This is the correct state before your consent screen has been answered. Call `setConsent` again once the user chooses.
Do not guess the region. If you cannot determine whether the user is in scope, leave `isEEA` unknown rather than assuming denied. Reporting a European user as non-European applies the wrong rule set to their conversion.
## Collecting consent
A **Consent Management Platform (CMP)** shows the consent screen at first launch and stores the answers, or you can build the screen yourself. Either way, you read the answers and report them with `setConsent`.
Show your consent screen at first launch, before you initialize the SDK. Ask whether they agree to their data being sent to Google for advertising, and whether they agree to it being used to personalize ads.
Most CMPs detect this for you. Otherwise resolve it server side from the IP address. If you cannot resolve it, leave `isEEA` unknown.
Map each answer to `granted`, `denied`, or `unknown`, then pass them in. The first payload has to carry the right state, so this must run before `init`.
The values below are an example. Send what the user actually chose, never a hardcoded set.
```kotlin theme={null}
import io.linkrunner.sdk.models.ConsentStatus
import io.linkrunner.sdk.models.LinkrunnerConsent
LinkRunner.getInstance().setConsent(
LinkrunnerConsent(
isEEA = ConsentStatus.GRANTED,
hasConsentForDataUsage = ConsentStatus.GRANTED,
hasConsentForAdsPersonalization = ConsentStatus.DENIED
)
)
```
```swift theme={null}
LinkrunnerSDK.shared.setConsent(
LinkrunnerConsent(
isEEA: .granted,
hasConsentForDataUsage: .granted,
hasConsentForAdsPersonalization: .denied
)
)
```
```javascript theme={null}
linkrunner.setConsent({
isEEA: "granted",
hasConsentForDataUsage: "granted",
hasConsentForAdsPersonalization: "denied",
});
```
```dart theme={null}
await LinkRunner().setConsent(
LRConsent(
isEEA: ConsentStatus.GRANTED,
hasConsentForDataUsage: ConsentStatus.GRANTED,
hasConsentForAdsPersonalization: ConsentStatus.DENIED,
),
);
```
```javascript theme={null}
linkrunner.setConsent({
isEEA: "granted",
hasConsentForDataUsage: "granted",
hasConsentForAdsPersonalization: "denied",
});
```
```csharp theme={null}
LinkrunnerSDK.SetConsent("granted", "granted", "denied");
```
Consent is stored between launches, so the last value you set keeps being sent. If the user reopens your privacy settings and withdraws consent, call `setConsent` again or you will keep reporting the old answer.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Conversion Values in SKAdNetwork
Source: https://docs.linkrunner.io/features/skadnetwork-conversion-values
Understanding conversion values in SKAdNetwork 4.0 for iOS attribution
SKAdNetwork conversion values allow you to measure the quality and behavior of users who install your app through Apple's privacy-preserving attribution framework. This guide explains what conversion values are, how they work, and how to design an effective schema for your app.
## What Are Conversion Values?
Conversion values are integer values between 0 and 63 that represent user actions or engagement levels within your app. When a user installs your app through an ad, you can update the conversion value based on their behavior, and this value is included in the SKAdNetwork postback sent to the ad network.
Think of conversion values as a way to communicate "how valuable is this user?" to your ad networks, helping them optimize campaigns for quality users rather than just install volume.
Conversion values provide a privacy-preserving way to measure user quality without sharing individual user data.
Apple uses crowd anonymity to protect user privacy while still providing useful attribution data.
## How Conversion Values Work in SKAN 4.0
SKAdNetwork 4.0 introduced significant improvements to conversion value tracking through a multi-window approach:
### Three Measurement Windows
Instead of a single 24-hour window, SKAN 4.0 uses three distinct measurement windows:
**0-2 days** after install Early engagement signals
**3-7 days** after install Mid-term user behavior
**8-35 days** after install Long-term value signals
Each window can have its own conversion value, allowing you to track user progression over time. For example:
* **Window 1**: User completed onboarding
* **Window 2**: User made their first purchase
* **Window 3**: User became a repeat customer
### Conversion Value Updates
You can update the conversion value multiple times within a measurement window:
* The value can be increased or decreased at any time
* The final value at the end of each window is what gets reported in the postback
* Each window's postback is sent independently
Unlike SKAN 3.0, where you could only increase conversion values, SKAN 4.0 allows both increases and decreases,
giving you more flexibility in tracking user behavior.
## Fine vs Coarse Conversion Values
SKAN 4.0 introduces two types of conversion values based on privacy thresholds:
### Fine Conversion Values
**Fine conversion values** provide the full 6-bit granularity (0-63) and are available when Apple's privacy threshold is met.
* **Range**: 0 to 63 (64 possible values)
* **Requirement**: Sufficient ad campaign volume to meet crowd anonymity threshold
* **Use Case**: Detailed user segmentation and behavior tracking
Example fine conversion value schema:
```
0-9: Non-engaged users
10-19: Completed onboarding
20-29: Made first in-app action
30-39: Light spenders ($0.99 - $4.99)
40-49: Medium spenders ($5 - $19.99)
50-59: High spenders ($20 - $49.99)
60-63: VIP users ($50+)
```
### Coarse Conversion Values
**Coarse conversion values** are a privacy fallback that provides only three levels of granularity when the privacy threshold is not met.
* **Values**: Low, Medium, High
* **Requirement**: Used when campaign volume is insufficient for fine values
* **Use Case**: Basic quality signals for smaller campaigns
You cannot predict whether your campaign will receive fine or coarse conversion values. Design your schema to work
effectively with both by ensuring your most critical user segments align with the three coarse levels.
### Privacy Thresholds
Apple uses crowd anonymity to determine whether fine or coarse values are provided:
* **Threshold Met**: Campaign has enough installs → Fine conversion values (0-63)
* **Threshold Not Met**: Campaign has too few installs → Coarse conversion values (Low/Medium/High)
The exact threshold is not publicly disclosed by Apple and may vary based on factors like geography and time period.
## Designing Your Conversion Value Schema
A well-designed conversion value schema aligns with your business goals and provides actionable insights. Here's how to create an effective schema:
### Step 1: Identify Key User Actions
List the most important actions users can take in your app:
* Completing onboarding or tutorial
* First meaningful action (e.g., creating content, adding items to cart)
* Subscription or purchase events
* Social engagement (sharing, inviting friends)
* Retention milestones (daily usage, weekly activity)
### Step 2: Prioritize Actions by Business Value
Rank these actions based on their importance to your business:
1. **Highest Value**: Actions that directly generate revenue
2. **High Value**: Actions that strongly correlate with retention
3. **Medium Value**: Engagement signals that predict future value
4. **Low Value**: Basic app usage indicators
### Step 3: Map Actions to Conversion Values
Assign conversion value ranges to different user segments:
Values increase as users progress through your funnel Best for: Apps with clear progression paths
Values map directly to revenue brackets Best for: E-commerce and subscription apps
### Step 4: Align with Coarse Values
Ensure your schema works with coarse conversion values:
* **Low (0-21)**: Basic engagement, no revenue
* **Medium (22-42)**: Moderate engagement, small revenue
* **High (43-63)**: Strong engagement, significant revenue
The mapping of fine values to coarse values (Low/Medium/High) is approximate. Design your schema so that critical
business segments have clear separation even at the coarse level.
## Example Conversion Value Schemas
### E-commerce App Example
A practical conversion value schema for a shopping app:
**Window 1 (0-2 days):**
```
0: No activity
10: Viewed products
20: Added to cart
30: Completed purchase ($0-$25)
40: Completed purchase ($25-$50)
50: Completed purchase ($50-$100)
60: Completed purchase ($100+)
```
**Window 2 (3-7 days):**
```
0: No return visit
15: Returned but no action
25: Viewed products
35: Made 2nd purchase (any amount)
50: Made multiple purchases
63: High-value repeat customer
```
**Window 3 (8-35 days):**
```
0: Churned (no activity)
20: Occasional browser
40: Regular purchaser (3+ purchases)
60: VIP customer (high LTV)
```
### Subscription App Example
A conversion value schema for a subscription-based app:
**Window 1 (0-2 days):**
```
0: Opened app only
15: Completed onboarding
30: Used core feature
45: Started free trial
63: Converted to paid subscription
```
**Window 2 (3-7 days):**
```
0: Inactive
20: Active free trial
40: Daily active user
55: Paid subscriber (active)
63: Paid subscriber (power user)
```
**Window 3 (8-35 days):**
```
0: Churned
25: Canceled trial/subscription
45: Active subscriber (low engagement)
60: Active subscriber (high engagement)
63: Subscriber + referred others
```
### Gaming App Example
A conversion value schema for a mobile game:
**Window 1 (0-2 days):**
```
0: Tutorial incomplete
10: Completed tutorial
20: Played 5+ levels
30: Made in-app purchase ($0.99-$4.99)
45: Made in-app purchase ($5-$19.99)
60: Made in-app purchase ($20+)
```
**Window 2 (3-7 days):**
```
0: Did not return
15: Played 1-2 sessions
30: Daily player
45: Daily player + purchase
60: Power player (10+ sessions, purchase)
```
**Window 3 (8-35 days):**
```
0: Churned
20: Weekly player
40: Retained daily player
55: High LTV ($50+ total spend)
63: Whale ($100+ total spend)
```
## Best Practices
Begin with a straightforward schema and iterate based on data. Complexity doesn't always mean better
optimization.
Map conversion values to actions that matter to your business, not just any user activity.
Monitor campaign performance and adjust your schema based on what drives the best results.
Ensure your schema provides value even when only coarse conversion values are available.
### Common Pitfalls to Avoid
**Don't use all 64 values**: Most apps only need 5-10 distinct segments per window. Too many values dilute the
signal.
**Don't ignore early actions**: Window 1 happens in just 0-2 days. Make sure you're tracking early engagement
signals, not just revenue.
**Don't forget about retention**: A user who returns regularly but hasn't paid yet might be more valuable than a
one-time purchaser.
## Privacy Considerations
SKAdNetwork conversion values are designed with privacy at their core:
* **Crowd Anonymity**: Postbacks are only sent when there's sufficient volume to prevent individual user tracking
* **Delayed Reporting**: Postbacks are sent with random delays to prevent timing-based tracking
* **Limited Information**: Only the conversion value is shared, not specific user actions or identities
Your conversion value schema should respect user privacy:
* Don't try to encode personally identifiable information
* Focus on aggregate user quality signals
* Design schemas that work within Apple's privacy framework
## Next Steps
Now that you understand conversion values, you can:
1. **Design your schema**: Map out conversion values that align with your business goals
2. **Implement tracking**: Configure your SDK to update conversion values based on user actions
3. **Set up dashboard**: Configure your conversion value schema in the Linkrunner dashboard
4. **Monitor and optimize**: Track campaign performance and refine your schema over time
For implementation details, see the [SKAdNetwork Integration Guide](/sdk/skadnetwork/integration).
# SKAN Setup
Source: https://docs.linkrunner.io/features/skadnetwork-integration
Configure SKAdNetwork for the Linkrunner SDK and your ad networks to enable privacy-preserving iOS attribution
SKAdNetwork (SKAN) is Apple's privacy-preserving framework for iOS attribution. With Linkrunner, SKAN setup happens in three places:
1. **Your app**: update the SDK and add the SKAN keys to `Info.plist`
2. **Linkrunner**: configure the SKAN schema (events, priority, ranges)
3. **Your ad networks**: complete the Google Ads and Meta Ads specific steps
This page covers the first two, which are shared across all networks. Then follow the network guide for each platform you advertise on.
## 1. Prepare Your App
### Minimum SDK Versions
Make sure your app is using at least these SDK versions before enabling SKAN. Linkrunner uses the SDK to update conversion values as users perform events.
**linkrunner-ios >=3.1.0**
**rn-linkrunner >=2.3.0**
**linkrunner >=3.1.1**
**expo-linkrunner >=3.2.0**
Capacitor, Cordova, and Unity apps use the native iOS SDK (LinkrunnerKit) under the hood, so the \*\*linkrunner-ios
> \=3.1.0\*\* requirement applies to them as well.
### How the SDK updates conversion values
You don't need to call any SKAN APIs yourself. On first launch the SDK registers an initial conversion value (0/low), and every `trackEvent`, `capturePayment`, and `signup` call returns the updated conversion value from Linkrunner, which the SDK applies automatically. On iOS 17.4+ the SDK uses Apple's AdAttributionKit; on iOS 14.0–17.3 it falls back to SKAdNetwork.
### Add Ad Network Identifiers
Apple only generates SKAN postbacks for ad networks listed under `SKAdNetworkItems` in your app's `Info.plist`. Add the identifiers for the networks you run campaigns on:
```xml theme={null}
SKAdNetworkItems
SKAdNetworkIdentifier
cstr6suwn9.skadnetwork
SKAdNetworkIdentifier
v9wttpbfk9.skadnetwork
SKAdNetworkIdentifier
n38lu8286q.skadnetwork
```
Without these entries, Apple never sends SKAN postbacks for those networks, even if everything else is configured
correctly. Identifiers are lowercase and must end in `.skadnetwork`.
If you run campaigns on other networks, add their identifiers too. Each network publishes its own list (for example, [Google](https://developers.google.com/admob/ios/3p-skadnetworks) and [Meta](https://developers.facebook.com/docs/SKAdNetwork)).
On Expo, add these under `ios.infoPlist.SKAdNetworkItems` in your `app.json` instead of editing `Info.plist`
directly.
### Configure Postback Copies to Linkrunner
To receive SKAdNetwork postback copies, configure `NSAdvertisingAttributionReportEndpoint` and `AttributionCopyEndpoint` in your app's `Info.plist`:
#### Native iOS, Flutter, and React Native
1. Open `Info.plist` in Xcode
2. Add a new key `NSAdvertisingAttributionReportEndpoint` (type `String`) with value `https://linkrunner-skan.com`
3. Add a new key `AttributionCopyEndpoint` (type `String`) with value `https://linkrunner-skan.com`
```xml theme={null}
NSAdvertisingAttributionReportEndpoint
https://linkrunner-skan.com
AttributionCopyEndpoint
https://linkrunner-skan.com
```
#### Expo
The Linkrunner Expo plugin adds this configuration automatically. No additional setup required.
These keys tell Apple to send copies of SKAdNetwork postbacks to Linkrunner alongside the ad network, enabling
accurate attribution and reporting in your Linkrunner dashboard.
## 2. Configure the SKAN Schema
Linkrunner converts your event list into SKAN conversion values (0–63). The order you put events in determines their priority. Higher-priority events overwrite lower-priority ones in the same measurement window.
1. Go to **Settings → SKAN Setup → Events Setup** in the [Linkrunner Dashboard](https://dashboard.linkrunner.io/dashboard/settings/skan-setup?tab=event-setup)
2. Add the events that matter for optimization (e.g., `Purchase`, `Subscription Started`, `Onboarding Complete`)
3. Drag rows to set priority (the topmost event has the highest priority)
4. For revenue events, set a **min** and **max** range; for occurrence events, set an **average**
5. Click **Generate SKAN Mapping** to produce the fine and coarse conversion values
Apple's SKAN allows up to **64 conversion values total**. The dashboard shows the combination count in real time and warns if your configuration would exceed the limit.
The schema is configured once and shared. Linkrunner automatically generates the fine and coarse conversion values
from it for both Google and Meta.
For background on how this maps to SKAN 4.0, see [Conversion Values in SKAdNetwork](/features/skadnetwork-conversion-values).
## 3. Complete Your Ad Network Setup
Follow the dedicated guide for each network you advertise on:
iOS link ID, account connection, and event mapping to conversion actions.
Conversion values in Events Manager and the Facebook SDK toggle.
Once postbacks start flowing, view them in the [SKAN dashboard](/features/skan-dashboard/overview).
## Support
For SKAN integration support, contact us at [support@linkrunner.io](mailto:support@linkrunner.io).
# All Conversion Values Table
Source: https://docs.linkrunner.io/features/skan-dashboard/conversion-table
Detailed breakdown of every SKAN event showing installs, reinstalls, click-through, and view-through metrics
## Overview
The All Conversion Values Table provides a detailed, event-by-event breakdown of your SKAN performance. Unlike the other dashboard components that show aggregated data, this table lets you see exactly how many installs performed each specific event in your SKAN configuration.
## Table Structure
### Column Descriptions
**Events**
* Lists every event you've configured in your SKAN setup
* Shows event names and ranges
* Events are ordered by your SKAN configuration priority
**Channel Icon**
* Visual indicator showing which ad network(s) the data represents
* Specific icons = Individual channel data (Meta, Google, TikTok, Snapchat)
**Installs**
* Total number of installs that performed this event
* Includes both new installs and reinstalls
**Re-Installs**
* Number of reinstalls (users who previously had your app) that performed this event
**Click Through**
* Installs from users who clicked on an ad before installing
* Indicates direct response to your advertising
**View Through**
* Installs from users who saw an ad but didn't click before installing
* Shows the value of impression-based advertising
## Postback Sequence Toggle
At the top right of the table, you'll see three toggle options:
### Postback 1 (0-2 days)
* **Conversion window**: Ends 2 days after the user first launches the app
* **Postback sent**: After conversion window ends + random 24-48 hour delay
* **Events tracked**: Actions performed within the first 0-2 days after install
### Postback 2 (3-7 days)
* **Conversion window**: Ends 7 days after the user first launches the app
* **Postback sent**: After conversion window ends + random 24-144 hour delay
* **Events tracked**: Actions performed within days 3-7 after install
### Postback 3 (8-35 days)
* **Conversion window**: Ends 35 days after the user first launches the app
* **Postback sent**: After conversion window ends + random 24-144 hour delay
* **Events tracked**: Actions performed within days 8-35 after install
## How to Read the Table
The table shows installs which performed the SKAN configured events.
### Simple Event Names
**Example**: "completed\_tutorial"
* Straightforward event name
* Installs that completed the tutorial
### Events with Ranges
**Example**: "purchase \[10-50]"
**For revenue events:**
* Indicates installs which did that payment event within the range given
* Example: Users who made purchases between $10-$50
**For custom events:**
* Indicates installs which performed that event that many times
* Example: Users who completed a level 10-50 times
### Special Case: INSTALL Event
If a row shows the "INSTALL" event:
* Users who installed but didn't engage
* Did not perform any other configured events within the time window
## Comparing Across Postbacks
Use the postback toggle to compare the same event across different time windows:
1. **Select P1** - Shows installs which performed the event within 0-2 days
2. **Switch to P2** - Shows installs which performed the event within 3-7 days
3. **Switch to P3** - Shows installs which performed the event within 8-35 days
**What this tells you**:
* Which channels get users that complete events quickly (high quality within few days)
* How many users convert immediately vs. over time
## Common Questions
Second and third postbacks are only available in **SKAdNetwork 4.0**. If the ad network is using SKAN versions below 4.0, only a single postback is sent.
Even in SKAN 4.0, additional postbacks are sent only if Apple's privacy tier thresholds are met. If an install does not meet the required privacy thresholds, Apple may limit reporting and only send the first postback.
[Learn more about Privacy Tiers →](/features/skan-dashboard/privacy-tiers)
## Related Components
* [Stats Overview Cards](/features/skan-dashboard/stats-cards) - See aggregated metrics
* [Conversion Value Distribution](/features/skan-dashboard/conversion-values) - Visual representation of conversion values
* [Install Trends Over Time](/features/skan-dashboard/trend-graph) - See daily patterns by channel
## Need Help?
If you're seeing unexpected patterns in your conversion table or need help interpreting your event data, reach out to our support team at [support@linkrunner.io](mailto:support@linkrunner.io).
# Conversion Value Distribution
Source: https://docs.linkrunner.io/features/skan-dashboard/conversion-values
Analyze which conversion values you receive and understand user actions through fine and coarse conversion values
## Overview
The Conversion Value Distribution visualization shows which conversion values you're receiving from SKAN postbacks. This helps you understand what actions users are taking in your app after installing.
Multiple postbacks are only available in **SKAdNetwork 4.0+**. Earlier versions only support a single postback.
## Postback Sequences
SKAN 4.0 can send up to three postbacks per install, each covering a different time window:
### Postback 1 (P1): Days 0-2
* **Conversion window**: Ends 2 days after the user first launches the app
* **Postback sent**: After conversion window ends + random delay
* **Data available**: Fine conversion values (0-63) OR coarse values (Low/Medium/High)
### Postback 2 (P2): Days 3-7
* **Conversion window**: Ends 7 days after the user first launches the app
* **Postback sent**: After conversion window ends + random 24-144 hour delay
* **Data available**: Coarse values only (Low/Medium/High)
### Postback 3 (P3): Days 8-35
* **Conversion window**: Ends 35 days after the user first launches the app
* **Postback sent**: After conversion window ends + random 24-144 hour delay
* **Data available**: Coarse values only (Low/Medium/High)
## Conversion Value Types
### Fine Conversion Values (0-63)
* **Available in**: Postback 1 only
* **Number of values**: 64 (0 through 63)
* **Visualization**: 8x8 grid of numbered squares
**How to read the grid:**
* Each square represents one conversion value
* Color intensity shows install volume (darker = more installs)
* Hover over any square to see exact counts and mapped events
### Coarse Conversion Values (Low/Medium/High)
* **Available in**: All three postbacks (P1, P2, P3)
* **Number of values**: 3
## Switching Between Views
**Postback toggle (P1, P2, P3)**: Switch between time windows to see user behavior at different stages.
**Fine/Coarse toggle**: For P1 only, switch between the detailed 64-value grid and the simplified 3-value pie chart. P2 and P3 only support coarse values.
**Channel filter**: Use the channel filter at the top of the dashboard to see the distribution of conversion values across different ad networks (Meta Ads, Google Ads, TikTok, Snapchat).
## Setting Up Conversion Values
The values shown here are based on your SKAN configuration. To modify your mapping:
1. Navigate to [SKAN settings in the Linkrunner dashboard](https://dashboard.linkrunner.io/dashboard/settings/skan-setup)
2. Define the priority of events, and Linkrunner will configure the corresponding fine and coarse conversion values.
[Learn more about configuring conversion values →](/features/skadnetwork-conversion-values)
## Common Questions
Apple only provides fine-grained conversion values (0-63) in the first postback. This is by design in SKAdNetwork 4.0.
For P2 and P3, Apple only sends coarse conversion values (Low/Medium/High) to maintain user privacy over longer time windows. The longer the measurement period, the more restrictive the data to prevent user identification.
These numbers represent the SKAN conversion value configuration you've set up with your ad network or Linkrunner.
Each number maps to specific in-app events. For example:
* 0 might mean "app opened"
* 15 might mean "completed registration"
* 63 might mean "made a purchase"
The exact mapping depends on how you configured your conversion values in your SKAN setup.
These are coarse conversion values that represent user engagement tiers:
* **High**: Users who performed your most valuable actions (e.g., purchases, subscriptions)
* **Medium**: Users with moderate engagement (e.g., completed onboarding, added to cart)
* **Low**: Users with minimal engagement (e.g., only opened the app)
You define what events map to each tier in your SKAN configuration. The categories help you understand user quality even when fine-grained data isn't available.
Second and third postbacks are only available in **SKAdNetwork 4.0**. If the ad network is using SKAN versions below 4.0, only a single postback is sent.
Even in SKAN 4.0, additional postbacks are sent only if Apple's privacy tier thresholds are met. If an install does not meet the required privacy thresholds, Apple may limit reporting and only send the first postback.
[Learn more about Privacy Tiers →](/features/skan-dashboard/privacy-tiers)
## Related
* [Stats Overview Cards](/features/skan-dashboard/stats-cards)
* [Privacy Tier Breakdown](/features/skan-dashboard/privacy-tiers)
* [Conversion Values Table](/features/skan-dashboard/conversion-table)
# SKAN Dashboard Overview
Source: https://docs.linkrunner.io/features/skan-dashboard/overview
Track and analyze your SKAdNetwork conversion data with detailed insights
## SKAN Dashboard?
All data in this dashboard comes directly from SKAN postbacks that Apple sends to Linkrunner through user devices. These postbacks report app installs and post-install activity in an aggregated, privacy-preserving way, without exposing any user-level data.
**What are SKAN postbacks?** When someone installs your app from an ad, Apple waits a bit (to protect privacy), then sends a postback to Linkrunner with limited information about that install and what the user did in your app, without identifying the user. This is Apple's way of helping you measure ad performance while keeping user data private.
## Dashboard Components
The SKAN Dashboard consists of five main components:
### 1. Stats Overview Cards
Quick snapshot metrics showing total installs, conversion value null counts, and null rates. These cards give you an at-a-glance view of your overall SKAN performance.
[Learn more about Stats Cards →](/features/skan-dashboard/stats-cards)
### 2. Privacy Tier Breakdown
Visualizes how your installs are distributed across Apple's four privacy tiers.
[Learn more about Privacy Tiers →](/features/skan-dashboard/privacy-tiers)
### 3. Conversion Value Distribution
Shows which conversion values you're receiving and helps you understand what actions users are taking in your app after installing.
[Learn more about Conversion Values →](/features/skan-dashboard/conversion-values)
### 4. Install Trends Over Time
Line chart showing daily install volume broken down by ad channel. Filter by events to see which channels drive specific user actions.
[Learn more about Trend Analysis →](/features/skan-dashboard/trend-graph)
### 5. All Conversion Values Table
Detailed breakdown of every event in your SKAN configuration, showing installs, reinstalls, click-through, and view-through metrics.
[Learn more about the Conversion Table →](/features/skan-dashboard/conversion-table)
## Dashboard Filters
### Date Range Filter
Choose how far back to look at your data:
* Last 7 days
* Last 30 days (default)
* Last 60 days
* Last 90 days
### Channel Filter
Filter data by ad network:
* **All Channels** (default): Shows combined data from all sources
* **Meta Ads**
* **Google Ads**
* **TikTok**
* **Snapchat**
You can select multiple channels to compare performance across networks.
## FAQs
Null conversion values happen because of Apple's **crowd anonymity** requirements.
To protect user privacy, Apple only shares conversion value data when enough installs occur to make individual users unidentifiable. Apple assigns each install to a privacy tier (0-3) based on crowd size:
* **Tier 0-1**: Low install volume → No fine-grained conversion value, only coarse or nothing
* **Tier 2-3**: Higher install volume → Full conversion value data
If you're seeing many null values, your campaigns likely aren't hitting the volume thresholds needed for higher tiers.
**How to reduce null rates:**
* Consolidate smaller campaigns into fewer, larger ones
* Increase budget on well-performing campaigns
* Focus spend on channels where you can achieve higher volume
[Learn more about Privacy Tiers →](/features/skan-dashboard/privacy-tiers)
Yes, you can see the SKAN dashboard without completing SKAN configuration on Linkrunner.
Linkrunner analyzes the postbacks received from Apple for the SKAN dashboard. If you've [configured your app to send postbacks to Linkrunner](/features/skadnetwork-integration#configure-postback-copies-to-linkrunner), data will be shown on the SKAN dashboard.
However, without SKAN configuration on Linkrunner, the analysis of conversion values cannot be done. You'll see raw conversion value numbers, but won't have the event mapping that explains what those numbers mean.
To get full insights, complete your [SKAN configuration](https://dashboard.linkrunner.io/dashboard/settings/skan-setup) to map events to conversion values.
The SKAN dashboard shows data via the postbacks received from Apple. Setting up conversion values alone is not enough.
Make sure you've completed the [postback configuration to send data to Linkrunner](/features/skadnetwork-integration#configure-postback-copies-to-linkrunner). Without this setup, Apple won't send postbacks to Linkrunner, and the dashboard will remain empty.
Once postback copies are configured correctly, data will start appearing in the dashboard as Apple sends postbacks (typically 1-3 days after installs occur).
## Related Resources
* [SKAdNetwork Integration Guide](/features/skadnetwork-integration) - Set up SKAN tracking in your app
* [SKAN Conversion Values Configuration](/features/skadnetwork-conversion-values) - Map events to conversion values
* [Apple's SKAdNetwork Documentation](https://developer.apple.com/documentation/storekit/skadnetwork) - Technical details from Apple
## Need Help?
If you have questions about your SKAN data or need help optimizing your setup, reach out to our support team at [support@linkrunner.io](mailto:support@linkrunner.io).
# Privacy Tier Breakdown
Source: https://docs.linkrunner.io/features/skan-dashboard/privacy-tiers
Understand how Apple assigns privacy tiers and what data you receive at each tier level
## What is Crowd Anonymity?
To protect user privacy, Apple limits the data in SKAdNetwork postbacks based on **crowd anonymity**. The idea is simple: the more installs a campaign generates, the harder it is to identify any individual user, so Apple shares more data.
Apple assigns each app download a **postback data tier** (0-3). Higher tiers = more data.
## How Apple Determines Your Privacy Tier
Apple calculates your tier based on:
* **Crowd size** of the app/domain displaying the ad
* **Crowd size** of the advertised app
* **Country** where the app was installed
* **Hierarchical source identifier** provided by the ad network
## What Data You Receive at Each Tier
| Field | Tier 0 | Tier 1 | Tier 2 | Tier 3 |
| ------------------------------------- | -------- | -------- | -------- | --------------------------- |
| **source-identifier** | 2 digits | 2 digits | 3 digits | 4 digits |
| **conversion-value** (fine) | ❌ | ❌ | ✅ | ✅ |
| **coarse-conversion-value** | ❌ | ✅ | ✅ | ✅ |
| **source-app-id** / **source-domain** | ❌ | ❌ | ❌ | ✅ |
| **country-code** | ❌ | ❌ | ❌ | ✅ (if crowd size qualifies) |
### Tier 3 (Best Data)
* Full 4-digit source identifier
* Fine-grained conversion value (0-63) in first postback
* Coarse conversion value in all postbacks
* Source app ID or source domain
* Country code (if crowd size qualifies)
### Tier 2
* 3-digit source identifier
* Fine-grained conversion value (0-63) in first postback
* Coarse conversion value in all postbacks
### Tier 1
* 2-digit source identifier
* Coarse conversion value only (Low/Medium/High)
* No fine-grained conversion value
### Tier 0 (Minimal Data)
* 2-digit source identifier
* No conversion value data
* Only confirms an install happened
## Related
* [Stats Overview Cards](/features/skan-dashboard/stats-cards)
* [Conversion Value Distribution](/features/skan-dashboard/conversion-values)
* [Install Trends Over Time](/features/skan-dashboard/trend-graph)
# Stats Overview Cards
Source: https://docs.linkrunner.io/features/skan-dashboard/stats-cards
Understand your key SKAN metrics at a glance with total installs, CV null counts, and null rates
## Overview
The Stats Overview Cards display four key metrics that provide a snapshot of your SKAN performance. Each card shows data for your selected time period as well as lifetime totals, along with a trend graph to visualize changes over time.
**Important note**: The install date shown is when the postback was received, not when the actual install happened. Apple does not send postbacks immediately after an install. After a user installs and launches an app, Apple delivers attribution postbacks on a delayed schedule (within \~48-72 hours).
## Metrics
The widget displays data for the following metrics:
### Installs (SKAN)
Returns the count of valid SKAN postbacks.
**What you'll see:**
* **Filtered Period**: Number of installs within your selected date range
* **Lifetime**: Total installs since you started using Linkrunner
* **Trend Graph**: Daily install volume over the selected period
### Reinstalls (SKAN)
For reinstall events, Apple includes redownload=true in the SKAN postbacks sent to Linkrunner. The Reinstalls metric reflects the number of reinstalls during the selected period, as reported by these postbacks.
**What you'll see:**
* **Filtered Period**: Number of reinstalls within your selected date range
* **Lifetime**: Total reinstalls since you started using Linkrunner
* **Trend Graph**: Daily reinstall volume over the selected period
### Conversion Value Null Count
This metric counts SKAN postbacks with a null conversion value, meaning no post-install actions were reported. This may happen when Apple limits reporting due to privacy tier requirements.
**What you'll see:**
* **Filtered Period**: Number of null conversion values in your selected date range
* **Lifetime**: Total null conversion values since you started using Linkrunner
* **Trend Graph**: Daily null count over the selected period
### Conversion Value Null Rate
The number of postbacks received with a conversion value of null divided by the number of SKAN installs.
**What you'll see:**
* **Filtered Period**: Percentage of null conversion values in your selected date range
* **Lifetime**: Overall null rate since you started using Linkrunner
* **Trend Graph**: Daily null rate percentage over the selected period
## Related Components
* [Privacy Tier Breakdown](/features/skan-dashboard/privacy-tiers) - See how privacy tiers affect your data quality
* [Conversion Value Distribution](/features/skan-dashboard/conversion-values) - Understand which events users are performing
* [Install Trends Over Time](/features/skan-dashboard/trend-graph) - Analyze install patterns by channel
# Install Trends Over Time
Source: https://docs.linkrunner.io/features/skan-dashboard/trend-graph
Analyze daily install patterns by channel and filter by events to understand which campaigns drive specific user actions
## Overview
The Install Trends Over Time graph shows your daily install volume broken down by ad channel. Each channel is represented by a different colored line, making it easy to compare performance across networks and identify trends over time.
## Filtering by Events
One of the most powerful features of this graph is the ability to filter by specific events. This shows you which channels drive installs where users performed particular actions.
### How to Filter by Events
1. Click the **"Add Event"** button in the top right
2. A dropdown appears showing all events from your SKAN configuration
3. Select one or more events to filter by
4. The graph updates to show only installs where those events occurred within 0-2 days
### Understanding Event Filters
When you filter by an event, the graph shows:
* **Only installs** where the user performed that event within the first 0-2 days
* **Conversion value data** from Postback 1 (P1)
* **Channel breakdown** of which networks drive that specific behavior
**Example use case:**
**Filter by "completed\_purchase"**:
* See which channels drive buyers, not just installers
* Identify your highest-quality traffic sources
* Optimize budget toward channels with best conversion rates
### Multiple Event Selection
You can select multiple events to see installs where users performed of those events:
1. Click "Add Event" and select your first event
2. Click "Add Event" again and select additional events
3. The graph shows installs where users performed both of the selected events
**Example use case:**
**Filter by "signup" and "completed\_purchase"**:
* See which channels drive users who both signed up and made a purchase
* Compare this to filtering only "completed\_purchase" to understand how many buyers also created accounts
* Identify channels that drive more complete user journeys vs. quick purchases
### Special Filter: "INSTALL" Event
If you filter by the "INSTALL" event specifically, you'll see something different:
* Installs where the user did NOT perform any of your other configured events
* Users who installed but didn't engage within 0-2 days
* Helps identify campaigns bringing in low-quality or unengaged users
### Removing Event Filters
To remove an event filter:
1. Find the event chip (colored pill) next to the "Add Event" button
2. Click the X icon on the chip
3. The graph updates to remove that filter
To see all installs again, remove all event filters.
## Related Components
* [Stats Overview Cards](/features/skan-dashboard/stats-cards) - See overall install volumes and trends
* [Conversion Values Table](/features/skan-dashboard/conversion-table) - Detailed event-level breakdown
* [Privacy Tier Breakdown](/features/skan-dashboard/privacy-tiers) - Understand data quality by channel
# Social Media Intermediary Page
Source: https://docs.linkrunner.io/features/social-media-intermediary-page
Why Linkrunner shows an intermediary page on social media platforms and how to customise it
Wonder why you are seeing this page when you click on a Linkrunner link on Instagram or other social media platforms? This page explains why it exists and the problems it solves.
## The Problem
Social media platforms like Instagram block redirecting users outside of their app. If we were to remove this intermediary page, here's what would happen:
* **Instagram opens its own in-app browser** — Instead of opening the Play Store or App Store app, Instagram opens the store's *website* inside its built-in browser. This prevents users from actually downloading the app through the native store experience.
* **No redirect to an already installed app** — If a user already has the app installed, they won't be redirected into it. The in-app browser has no way to trigger the app to open.
* **Additional friction** — Users are stuck in a web view with no clear path to the store app or the installed app, leading to drop-offs.
## How Linkrunner Solves This
This intermediary page acts as a bypass mechanism. When a user taps a Linkrunner link on Instagram (or similar platforms), the intermediary page:
1. **Detects the environment** — It identifies that the user is coming from a social media in-app browser.
2. **Redirects to the native store app** — Instead of loading the store website, it triggers the actual Play Store or App Store app to open.
3. **Handles already-installed apps** — If the user already has the app installed, Linkrunner redirects them directly into the app via deep linking.
All of these cases are handled automatically by Linkrunner via this intermediary page — no extra setup required from your end.
## Customising the Page
We are happy to customise this intermediary page to match your brand. If you'd like a custom design, here's what we need:
* **A design reference** — Ideally a Figma link so we can match it precisely.
* **SVGs only** — Please ensure that no raster images (PNG, JPG, etc.) are used in the design. SVGs keep the page lightweight and fast-loading, which is critical since this page needs to load quickly before redirecting the user.
Page load speed is critical here — the intermediary page needs to load as fast as possible so users experience
minimal delay before being redirected. This is why we require SVGs instead of images.
***
To request a custom design for your intermediary page, reach out to us at [support@linkrunner.io](mailto:support@linkrunner.io).
# Subdomain Setup
Source: https://docs.linkrunner.io/features/subdomain-setup
Learn how to configure a custom subdomain for tracking link clicks and deep linking with Linkrunner
Subdomains let you use your own domain (e.g. `link.yourdomain.com`) for Linkrunner tracking links and deep links instead of a generic URL. This makes your links more branded and trustworthy to users.
**What you need:**
* A domain you own
* Access to your domain's DNS settings
## Step 1 — Choose a subdomain
From the [Linkrunner dashboard](https://dashboard.linkrunner.io), go to **Settings → Manage Domains** and click **Add Subdomain**. Enter a subdomain prefix (e.g. `link`, `app`, `get`, `open`) and your domain name.
Your subdomain will look like: `subdomain.yourdomain.com`
## Step 2 — Add a CNAME record
Go to your domain registrar's DNS settings and add a **CNAME** record with these values:
| Name | Type | Value |
| ---------------- | ----- | ------------------- |
| `your-subdomain` | CNAME | `api.linkrunner.io` |
For example, if your subdomain is `link.example.com`, set:
* **Name:** `link`
* **Type:** CNAME
* **Value:** `api.linkrunner.io`
### Provider-specific guides
1. Log in to your [GoDaddy account](https://account.godaddy.com/) and go to **My Products**
2. Find your domain and click **DNS** (or **Manage DNS**)
3. Click **Add New Record**
4. Select **CNAME** as the record type
5. In the **Name** field, enter your subdomain prefix (e.g. `link`)
6. In the **Value** field, enter `api.linkrunner.io`
7. Leave **TTL** as the default (or set to 1 hour)
8. Click **Save**
1. Open the [Route 53 console](https://console.aws.amazon.com/route53/) and go to **Hosted zones**
2. Click on your domain name
3. Click **Create record**
4. Enter your subdomain prefix in the **Record name** field (e.g. `link`)
5. Set **Record type** to **CNAME**
6. In the **Value** field, enter `api.linkrunner.io`
7. Leave **TTL** as `300` (or your preferred value)
8. Click **Create records**
1. Log in to your [Namecheap account](https://www.namecheap.com/) and go to **Domain List**
2. Click **Manage** next to your domain
3. Go to the **Advanced DNS** tab
4. Click **Add New Record**
5. Select **CNAME Record** as the type
6. In the **Host** field, enter your subdomain prefix (e.g. `link`)
7. In the **Target** field, enter `api.linkrunner.io`
8. Leave **TTL** as **Automatic**
9. Click the **checkmark** to save
You **must** turn off Cloudflare's proxy for this record.
1. Log in to your [Cloudflare dashboard](https://dash.cloudflare.com/) and select your domain
2. Go to **DNS > Records**
3. Click **Add record**
4. Set **Type** to **CNAME**
5. In the **Name** field, enter your subdomain prefix (e.g. `link`)
6. In the **Target** field, enter `api.linkrunner.io`
7. Set the **Proxy status** to **DNS only** (grey cloud icon)
8. Click **Save**
## Step 3 — Verification
After adding the CNAME record, Linkrunner automatically checks your DNS configuration and provisions an SSL certificate.
* **CNAME Not Verified** — DNS record hasn't been detected yet. This can take a few minutes to propagate.
* **CNAME Verified** — Your DNS record is correctly pointing to `api.linkrunner.io`.
* **SSL Active** — Your SSL certificate has been provisioned. Your subdomain is fully ready to use.
You can click the **refresh** button next to your subdomain in the dashboard to manually re-check the status.
## Troubleshooting
**DNS propagation delay**
DNS changes can take up to 24–48 hours to propagate globally, though most providers update within a few minutes. If your CNAME isn't verified after adding it, wait and try refreshing later.
**Wrong CNAME value**
Make sure the CNAME value is exactly `api.linkrunner.io` — not your subdomain URL, not an IP address.
**Cloudflare proxy interference**
If you use Cloudflare, ensure the proxy status is set to **DNS only** (grey cloud). The orange cloud (proxied) mode will interfere with SSL certificate provisioning.
**SSL still pending**
SSL provisioning happens automatically after CNAME verification and usually takes a few minutes. If it remains inactive after 30 minutes with a verified CNAME, contact support.
## Managing subdomains
From the **Settings → Manage Domains** page in the dashboard, you can:
* **Add** new subdomains to your project
* **Delete** subdomains you no longer need
* **Monitor** the CNAME and SSL status of each subdomain
# Uninstall Tracking
Source: https://docs.linkrunner.io/features/uninstall-tracking
Uninstall tracking allows you to measure when users remove your app from their devices, providing valuable insights into user retention and app performance.
## How does it work
* Linkrunner sends a daily API request to Firebase Cloud Messaging (FCM) and Apple Push Notification Services (APNS).
* FCM and APNS then send a silent push notification to verify if an app is still installed on a given device.
* An uninstall is recorded if the response indicates that the app was uninstalled (FCM and APNS) for 3 consecutive days.
## Setup
Refer to the following guides to enable uninstall tracking for your application:
* [Flutter](/sdk/flutter#uninstall-tracking)
* [React Native](/sdk/react-native#uninstall-tracking)
* [Capacitor](/sdk/capacitor#uninstall-tracking)
* [iOS](/sdk/ios#uninstall-tracking)
* [Android](/sdk/android#uninstall-tracking)
# Users
Source: https://docs.linkrunner.io/features/users-tab
Look up any user, trace their journey from ad click to in-app event, and save filters as reusable cohorts.
The **Users** tab lists the individual people using your app. For each one you can see where they came from, what device they are on, and every action they have taken in your app since they installed it.
A person appears here once Linkrunner has **attributed** them, which means Linkrunner has worked out where they came from and matched their app install back to the ad click or link that led to it. When there was no ad click to match, the user is attributed to **Organic**.
Open the tab at [dashboard.linkrunner.io/dashboard/users](https://dashboard.linkrunner.io/dashboard/users). The dashboard adds `?p_id=YOUR_PROJECT_ID` to the address for the project you have selected, so you can bookmark or share a link that opens on a specific project.
The Users tab answers "who is this person and where did they come from". For questions about everyone at once,
such as where people give up partway through a flow, use the [Events tab](/features/events-tab).
## Finding a user
The table lists every user in the project. Each row shows **User ID**, **Name**, **Email**, **Phone**, **Channel**, **Campaign**, **Platform**, and **Created At**.
Use the filters above the table to narrow the list down:
| Filter | What it does |
| ---------------- | -------------------------------------------------------------------------------------------------- |
| **Search** | Matches on name, email, phone, or user ID |
| **Channel** | Where the user came from: Meta Ads, Google Ads, TikTok, Apple Search Ads, Organic, Referral |
| **Platform** | Android or iOS |
| **Select dates** | Limits the list by when the user was first created |
| **Events** | Keeps only users who took particular actions. Use AND to require all of them, OR to accept any one |
| **Campaign** | Pick one or more of your campaigns |
Filters stack on top of each other, so you can ask for iOS users from a Meta Ads campaign who triggered `add_to_cart` in the last 30 days.
If a set of filters is worth keeping, save it as a **cohort**, a named group of users you can come back to. See [Cohorts](#cohorts) below.
## The user profile
Click any row to open that user's profile. It has two halves: their **Journey** on the left, and their information on the right.
### User information
The right side summarizes who the user is and where they came from:
* **Identity**: their name, with a badge counting how many days it has been since you first saw them.
* **Contact**: email and phone, each with a button to copy it.
* **Platform** and **Channel**: the type of device they use and where they came from.
* **Acquisition campaign**: the campaign that brought them in. Click it to open that campaign's full details.
* **First seen** and **Last seen** dates.
* **Activity heatmap**: a grid covering the last six months, one square per day, shaded according to how many actions the user took that day. A flag marks their very first day of activity. Click any square to jump the Journey to that day.
* **Cohorts**: the saved groups this user belongs to. Click one to open it in the **Cohorts** sub-tab.
The heatmap is the quickest way to judge how engaged someone is. A dense block of activity that stops several weeks ago is someone who has stopped using the app. A few scattered squares are someone who installed it and never got into the habit.
### Journey
The left side is the list of everything the user has done, in order, grouped by day. You can collapse and expand each day.
* Look for **App installed** and **First open** to see how the user arrived. The install shows the campaign that drove it, with a **View Details** link through to that campaign.
* Expand any action to see its event ID, the exact time, the screen or URL path involved, and the session ID.
* Actions where the user interacted with an ad show a thumbnail and the creative details.
* Search by name to show only certain actions.
* Set a date range to reload the journey over a narrower window.
* Click **Load more** to go further back in time.
## Hashed and identified users
Some users show as **Hashed**, with a dotted outline in place of a picture and the word `Hashed` where their name, email, and phone would be.
This does not mean the data is missing or broken. It means your app has not yet told Linkrunner who that person is. Everything else about them is intact: user ID, channel, campaign, platform, and their complete journey are all still there. Only the personal details are held back.
To see real names and contact details, your app needs to identify the user once it knows who they are. See [User Identification](/sdk/react-native#user-identification-required) in your SDK guide, or [Setting the Customer User ID](/sdk/react-native#setting-the-customer-user-id) if you would rather attach your own identifier.
In the [Events tab](/features/events-tab) you will also come across **Unidentified**, which is a different state.
**Hashed** means there is a user record and only the personal details are held back, so the user still has a user
ID. **Unidentified** means no user record is attached to that event at all, so it has no user ID either.
## Exporting users
Click **Export** to download the list as a CSV file you can open in a spreadsheet.
The file contains every user matching your current filters, not just the rows you can see on screen. Use it to move a group of users into a CRM, an email tool, or an ad platform audience.
## Cohorts
A cohort is a saved group of users who have something in common, defined by the actions they have taken and the platform they use. "Triggered `add_to_cart` but never triggered `purchase`, Android only" is a cohort. Naming it once means you stop rebuilding the same set of filters every time you need that group.
The **Cohorts** sub-tab shows each saved cohort as a card with its name, description, how many users are in it, and the events it is built from. Each card separates **Events Done** from **Events Not Done**, so you can see at a glance whether a cohort is defined by what people did, what they did not do, or both. Search by name or filter by event name to find one.
### Saving a cohort from your filters
Set the **Events** and **Platform** filters so the table shows the group you want.
Those event conditions and the platform carry over into the new cohort.
Give it a name and description that will still make sense to a colleague in three months.
Only the **Events** and **Platform** filters become part of the cohort. If you also set a date range or a campaign
filter on the table, those are not carried over.
### Building a cohort from scratch
In the **Cohorts** sub-tab, click **Create Cohort** and set the conditions directly. Choose the events, decide whether a user has to match all of them or just one of them, mark whether each event must have been done or not done, and set the platform. Editing an existing cohort opens the same builder.
### Using a cohort
Cohorts let you group users for analysis, and to target them with ads. To act on one, see [How to Run Remarketing Campaigns](/features/remarketing-guide).
## What to use this for
**Answer a support ticket without involving engineering.** A user writes in saying their purchase never applied. Search their email, open their profile, and expand the day in question to see exactly which actions their app reported and which it did not.
**Check where one specific user came from.** Rather than arguing about why a campaign's totals look wrong, open a user who should have come from that campaign and look at the start of their journey to see which campaign Linkrunner actually recorded.
**Build a group of users without a spreadsheet.** Filtering to "triggered `add_to_cart`, never triggered `purchase`" and saving it as a cohort replaces exporting a user list, filtering it by hand, and re-uploading it every time the group changes.
**Judge how engaged someone is at a glance.** The activity heatmap tells you whether a user opens the app daily or installed it once and never returned, without running a separate report.
**Move a list into another tool.** Export gives you the filtered set as a CSV for a CRM, an email platform, or an ad platform audience.
## Troubleshooting
Linkrunner only keeps data for users it has attributed. If Linkrunner never matched that person's install to a
click or an organic install, they will not appear. Also check your date range, and clear the **Channel** and
**Campaign** filters in case one of them is excluding the user.
Your app is not telling Linkrunner who its users are, so there are no names or contact details to show. See
[Hashed and identified users](#hashed-and-identified-users).
Linkrunner attributed the user, but their app has not reported any actions since. Confirm your app calls
`trackEvent` for the actions you expect to see. See [Tracking Custom
Events](/sdk/react-native#tracking-custom-events).
A cohort keeps only the **Events** and **Platform** conditions. If your table was also filtered by date range or
campaign, those limits are dropped when the cohort is saved, so the cohort covers a wider group than the list
you were looking at.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Web to App Journeys
Source: https://docs.linkrunner.io/features/web-to-app-journeys
Track users as they move from your website to your app, whether the app is already installed or not
A web to app journey is any path that starts on your website and continues inside your app. The simplest way to reason about these journeys is to split them by install state:
* **App already installed**: the link should open the app directly (deep linking).
* **App not installed**: context has to survive the install (deferred deep linking).
This guide maps the common journeys in each bucket to how Linkrunner tracks them, where the data appears in the dashboard, and what you need to set up.
## How Linkrunner bridges web and app
One thing does the bridging: a **Linkrunner link on your custom subdomain**, placed wherever the journey starts (a website button, a CRM message, an ad destination URL).
* **If the app is installed**, the link opens the app directly. Your app reports the open with [`handleDeeplink`](/sdk/react-native#handle-deeplink), which records a re-engagement against the campaign.
* **If the app is not installed**, the link routes the user to the store. After they install and open the app, [`getAttributionData`](/sdk/react-native#getting-attribution-data) returns the attributed campaign and the deferred deep link so you can drop them into the right screen.
Everything after the open is the mobile SDK's job: `init` records the app open, [`signup`](/sdk/react-native#user-identification-required) registers the user, [`trackEvent`](/sdk/react-native#tracking-custom-events) captures actions, and [`capturePayment`](/sdk/react-native#revenue-tracking) captures revenue.
For Meta campaigns, use the dedicated handoff instead of building the link yourself. Linkrunner auto-creates and attributes the campaign for you. See [Meta Web to App](/ad-networks/meta-web-to-app).
### Prerequisites
Set these up once and every journey below works:
1. **Custom subdomain** for your Linkrunner links. See [Subdomain Setup](/features/subdomain-setup).
2. **Deep linking** so installed apps open directly. See [Deep Linking Setup](/features/deep-linking-setup).
3. **Deferred deep linking** on campaigns so context survives the install. See [Deferred Deep Linking](/features/deferred-deep-linking).
4. **Mobile SDK installed** with `init`, `handleDeeplink`, and `getAttributionData` wired up, and [`signup`](/sdk/react-native#user-identification-required) called once you know who the user is. Events and revenue are only stored for users registered through `signup`.
***
## App already installed
These journeys open the app directly through the Linkrunner link. You track the re-engagement plus whatever the user does once inside.
| Journey | What Linkrunner tracks | Where in the dashboard | What to set up |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| **Website → App Open** | Link click + app re-engagement via `handleDeeplink` | Campaigns → **Clicks**; switch **Install Attribution** to **Re-engagement** | Deep linking configured; `handleDeeplink` called on every launch; "Open App" button uses a Linkrunner link |
| **Web content → specific screen** | Same, with a deep link **path** that routes in-app (e.g. `/product/123`) | Same as above; in-screen actions show in event columns | App routes match the link's paths; encode the path in the link |
| **Web CTA → in-app conversion** | Re-engagement + in-app [`trackEvent`](/sdk/react-native#tracking-custom-events) or [`capturePayment`](/sdk/react-native#revenue-tracking) | Custom **event columns**; **Revenue**, **ROAS**, **Paying Users** | Call `signup` first; instrument the conversion event or payment |
| **CRM → app** (email, SMS, WhatsApp, push) | One tagged link per channel + downstream events | One **campaign per channel**: Clicks, Reengagements, and event columns | A Linkrunner link per channel; for push, carry the link in the notification payload |
At the top of the [Campaigns](https://dashboard.linkrunner.io/dashboard/campaigns) page, switch the **Install Attribution** dropdown to **Re-engagement** to see already-installed opens instead of new installs.
***
## App not installed
These journeys go through the store. Deferred deep linking carries the original context across the install so the user resumes where they intended.
| Journey | What Linkrunner tracks | Where in the dashboard | What to set up |
| ------------------------------------------ | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| **Website → Install** | Click → install, with the attributed campaign from `getAttributionData` | **Overview** stat cards (All / Attributed / Organic Installs); Campaigns → **Installs** | Linkrunner link as the store target; `init` + `getAttributionData` after first open |
| **Website → Install → Signup** | Install + user registration | **Sign-ups** and **Conversion** (sign-ups / installs) columns; user list in the campaign drawer | Call [`signup`](/sdk/react-native#user-identification-required) with `user_data.id` |
| **Website → Install → Purchase** | Install + revenue | **Revenue**, **ROAS**, **Paying Users**, **Daywise Revenue** columns | [`capturePayment`](/sdk/react-native#revenue-tracking); convert to one currency; map the event for Meta/Google ROAS |
| **Website → Install → Onboarding** | Install + activation event | Custom **event column**; [Events](https://dashboard.linkrunner.io/dashboard/events) | Define the activation event and fire it with [`trackEvent`](/sdk/react-native#tracking-custom-events) |
| **Specific page → Install → Same context** | Deferred deep link carrying the page context through the install | Campaign **Installs**; the link resolves via `getAttributionData().deeplink` | Enable deferred deep linking on the campaign; pass the path into the link; parse `deeplink` in-app |
| **Referral link → Install** | Install + referral conversion | Campaign per referral; event column | [Referral tracking](/features/referral-codes); add `?referrer=` to the link; handle it in-app |
The deferred deep link is read once, after the first open, from [`getAttributionData`](/sdk/react-native#getting-attribution-data). Check the `deeplink` field, parse the URL, and navigate. The SDK does not navigate for you.
***
## Reading the full journey
The end-to-end question (*which campaign drove this user, and what did they eventually do in the app*) is answered at the **campaign** level:
* Open a campaign's detail drawer ([`dashboard/campaigns?d=campaign-details&campaign={display_id}`](https://dashboard.linkrunner.io/dashboard/campaigns)) to see the **Clicks → Installs → Sign-ups** funnel as a flow diagram, plus revenue and paying users.
* Use the per-campaign **user list** in that drawer to see who signed up.
* Use the **Retention** columns to see whether those users stuck around.
### Metrics and where they live
| Metric | Where to find it |
| ----------------------------------------------------- | -------------------------------------------------------------------------------- |
| Source / Channel / Campaign / Ad Set / Ad / Creative | Campaigns table (expand ad set and creative rows) |
| Clicks | Campaigns table + Overview |
| Installs (All / Attributed / Organic / iOS / Android) | Overview stat cards + Installs column |
| App re-engagement | Campaigns → **Re-engagement** view |
| Sign-ups / Conversion | Sign-ups and Conversion columns; user list in the campaign drawer |
| Activation / custom events | Custom event columns; [Events](https://dashboard.linkrunner.io/dashboard/events) |
| Purchase / Revenue / ROAS | Revenue, ROAS, Paying Users, Daywise Revenue columns |
| Retention (D1, D7, …) | Rolling and Classic Retention columns |
Most of these are columns on the Campaigns table. Use **Edit Columns** to choose which ones show, including per-event and cost-per-event columns:
Custom and payment events received from the SDK are listed under **Events settings**, along with the ad networks each is mapped to:
Website page views and channel breakdowns are attribution inputs that feed the campaign numbers above; they are not a standalone report in the dashboard today.
## Troubleshooting
**App opens the store even though it is installed?** Deep linking is not verified. Check [Deep Linking Setup](/features/deep-linking-setup) and confirm your domain's `assetlinks.json` and `apple-app-site-association` are saved in Linkrunner.
**Deep link is null after install?** Confirm deferred deep linking is enabled on the campaign, and that you read `deeplink` from `getAttributionData` after `init` completes. See [Deferred Deep Linking](/features/deferred-deep-linking).
**Events or revenue not showing for a user?** They are only stored for users registered through [`signup`](/sdk/react-native#user-identification-required). Register the user, then re-send the event.
**Re-engagement not recorded for an installed app?** Make sure `handleDeeplink` runs on both cold start and warm start with the opened URL.
## Related guides
Open installed apps directly from web links
Carry context across the install
Run Meta web-to-app ads with CAPI
Track conversions and activation events
Capture payments and measure ROAS
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Webhooks
Source: https://docs.linkrunner.io/features/webhooks
Receive real-time notifications when attribution events occur
**Using an AI coding agent?** Have it write your webhook receiver for you:
```bash theme={null}
npx @linkrunner/skills add webhooks
```
The skill generates a handler for your backend that verifies the `linkrunner-key` header and processes install/signup payloads. See [Linkrunner Agent Skills](/ai-agent-skills).
## Overview
Webhooks allow you to receive real-time HTTP notifications when attribution events occur in your app. When a user installs your app or signs up, Linkrunner sends a POST request to your configured endpoint with detailed attribution data.
**Common use cases:**
* Server-side analytics and reporting
* CRM integration for user onboarding
* Real-time Slack notifications
* Custom attribution pipelines
## Configuration
In the dashboard, go to **Settings → Data Export**, then select the **Webhooks** tab. You can also [open Webhook settings directly](https://dashboard.linkrunner.io/dashboard/settings/data-export?tab=webhooks).
Enter your public webhook URL, then click **Update Webhook**.
Click **Test Webhook**. Your endpoint must accept JSON POST requests and return a direct 2xx response.
Turn on **Send webhooks for organic users** to receive install and signup webhooks for organic users. These payloads use `network_name: "ORGANIC"`.
Your endpoint must be publicly accessible. Linkrunner retries failed requests up to three times, but does not follow redirects.
## Webhook Events
Linkrunner sends webhooks for the following events:
| Event | Description |
| --------- | --------------------------------------------------------------------------- |
| `install` | Triggered when an app install is attributed to a campaign or organic source |
| `signup` | Triggered when a user signup event is recorded via the SDK |
### Install Webhook
The `install` webhook is triggered immediately when Linkrunner attributes an app installation. At this point, your app has just been opened for the first time and the user hasn't had a chance to sign up or log in yet. Because of this, user identity fields such as `user_id`, `name`, `phone`, `email`, and `additional_data` are only included in signup webhooks.
By default, install webhooks fire only for attributed (paid) installs. To also receive webhooks for organic users, enable **Send webhooks for organic users** in [Settings → Webhooks](https://dashboard.linkrunner.io/dashboard/settings/data-export?tab=webhooks). This covers both install and signup webhooks. Organic webhooks are sent with `network_name` set to `ORGANIC`, `ad_channel` set to `null`, and no campaign details.
However, the install webhook does include `lr_install_id` (Linkrunner's ID for the install) and device identifiers (`gaid` for Android, `idfa` for iOS) when available. You can use these to match the install with the user later when they sign up.
Use the install webhook for:
* Tracking install counts by campaign
* Analyzing attribution data (network, ad creative, etc.)
* Monitoring app store conversion rates
* Storing device IDs to link with user accounts later
### Signup Webhook
The `signup` webhook is triggered when you call the `.signup()` method in your app via the Linkrunner SDK. This happens after the user has signed up or logged in, so the `user_id` field will contain the ID you passed to the SDK.
The signup webhook includes `lr_install_id` and device identifiers (`gaid`/`idfa`) along with the `user_id`, giving you a complete picture of both the device and the user. Because the install webhook carries the same `lr_install_id`, you can join the two payloads without relying on device identifiers. It also includes user identity fields (`name`, `phone`, `email`) and any custom parameters you passed via `additional_data`, such as referral codes or other custom key-value pairs.
Like install webhooks, signup webhooks fire only for attributed users by default. Enabling **Send webhooks for organic users** also delivers signup webhooks for organic users, with `network_name` set to `ORGANIC` and no campaign details.
Use the signup webhook for:
* Linking attribution data to your user records
* CRM integration and user onboarding flows
* Calculating signup conversion rates from installs
* Forwarding custom parameters (e.g., referral codes) to your backend
To receive signup webhooks, you must call `.signup()` in your app after the user signs up or logs in. See your SDK's usage guide for implementation details.
## Payload Structure
All webhooks are sent as POST requests with a JSON body.
### Headers
| Header | Description |
| ---------------- | --------------------------------------------- |
| `Content-Type` | `application/json` |
| `linkrunner-key` | Your project's private key for authentication |
### Body Parameters
| Field | Type | Description |
| -------------------------- | ------------------------- | --------------------------------------------------------------------------- |
| `event_type` | `"install"` \| `"signup"` | The type of attribution event |
| `lr_install_id` | `string` | Linkrunner's unique ID for the install |
| `user_id` | `string` \| `null` | Customer user ID for signup webhooks |
| `campaign_id` | `string` | Unique identifier for the campaign |
| `campaign_name` | `string` \| `null` | Human-readable campaign name |
| `network_name` | `string` \| `null` | Attribution network: `ORGANIC`, `META`, `GOOGLE`, etc. |
| `ad_channel` | `string` \| `null` | Ad channel: `META`, `GOOGLE`, `TIKTOK`, `APPLE_SEARCH_ADS`, etc. |
| `attributed_on` | `ISO 8601 date` | Timestamp when attribution occurred |
| `installed_at` | `ISO 8601 date` \| `null` | Timestamp of app installation |
| `store_click_at` | `ISO 8601 date` \| `null` | Timestamp of store redirect click |
| `link` | `string` | The campaign link URL |
| `app_version` | `string` \| `null` | Installed app version |
| `gaid` | `string` \| `null` | Google Advertising ID (Android) |
| `idfa` | `string` \| `null` | Identifier for Advertisers (iOS) |
| `name` | `string` \| `null` | User's name (from `user_data.name` passed to the SDK) |
| `phone` | `string` \| `null` | User's phone number (from `user_data.phone` passed to the SDK) |
| `email` | `string` \| `null` | User's email address (from `user_data.email` passed to the SDK) |
| `additional_data` | `object` \| `null` | Custom parameters and device data (from the `data` field passed to the SDK) |
| `meta_campaign_details` | `object` \| `null` | Meta Ads campaign details (see below) |
| `google_campaign_details` | `object` \| `null` | Google Ads campaign details (see below) |
| `apple_search_ads_details` | `object` \| `null` | Apple Search Ads campaign details (see below) |
| `campaign_details` | `object` \| `null` | Generic ad network campaign details (see below) |
The `name`, `phone`, and `email` fields are populated from `user_data` passed to the SDK's `.signup()` or `.trigger()` methods.\
The `additional_data` field is populated from the SDK `data` object.\
Fields that are not available can be `null` or omitted from the payload.
### Install ID
`lr_install_id` identifies a single install of your app. Both webhooks for that install carry the same value, so it is the join key between them: store it from the install webhook, then look it up when the signup webhook for the same install arrives.
It is also the `install_instance_id` used by the [SDK-less API](/sdk-less/api-reference), so a webhook payload can be matched to the `attribution-data` response for the same install.
A user who reinstalls or reuses your app on another device produces a new install, and therefore a new `lr_install_id`. Use `user_id` to group installs by user.
### Meta Campaign Details
When the attribution is from Meta Ads, `meta_campaign_details` contains:
| Field | Type | Description |
| --------------------- | ------------------- | ----------------------------------------- |
| `ad_creative_id` | `string` \| `null` | Meta ad creative ID |
| `ad_creative_name` | `string` \| `null` | Ad creative name |
| `ad_set_id` | `string` \| `null` | Ad set ID |
| `ad_set_name` | `string` \| `null` | Ad set name |
| `campaign_group_id` | `string` \| `null` | Campaign group ID |
| `campaign_group_name` | `string` \| `null` | Campaign group name |
| `account_id` | `string` \| `null` | Meta Ads account ID |
| `ad_objective_name` | `string` \| `null` | Campaign objective (e.g., `APP_INSTALLS`) |
| `is_instagram` | `boolean` \| `null` | Whether the ad was on Instagram |
| `publisher_platform` | `string` \| `null` | Platform where ad was shown |
| `platform_position` | `string` \| `null` | Placement position |
### Google Campaign Details
When the attribution is from Google Ads, `google_campaign_details` contains:
| Field | Type | Description |
| --------------- | ------------------ | -------------------------------------- |
| `gclid` | `string` \| `null` | Google Click ID |
| `gbraid` | `string` \| `null` | Google app campaign tracking parameter |
| `ga_source` | `string` \| `null` | Google Analytics source |
| `ad_group_id` | `string` \| `null` | Ad group ID |
| `ad_group_name` | `string` \| `null` | Ad group name |
### Apple Search Ads Details
When the attribution is from Apple Search Ads, `apple_search_ads_details` contains:
| Field | Type | Description |
| ------------------- | ------------------ | ------------------------------------- |
| `ad_group_id` | `string` \| `null` | Apple Search Ads ad group ID |
| `ad_group_name` | `string` \| `null` | Ad group name |
| `keyword_id` | `string` \| `null` | Keyword ID |
| `keyword_name` | `string` \| `null` | Keyword name |
| `ad_id` | `string` \| `null` | Ad creative ID |
| `ad_name` | `string` \| `null` | Ad creative name |
| `country_or_region` | `string` \| `null` | Country or region for the attribution |
### Campaign Details
For supported non-Meta and non-Google ad networks, `campaign_details` contains:
| Field | Type | Description |
| ----------------- | ------------------ | -------------------------------------- |
| `ad_network_code` | `string` \| `null` | Internal ad network code |
| `ad_network_name` | `string` \| `null` | Ad network name |
| `campaign_id` | `string` \| `null` | Network campaign ID, when available |
| `adset_id` | `string` \| `null` | Network ad set ID, when available |
| `ad_creative_id` | `string` \| `null` | Network ad creative ID, when available |
### Additional Data
The `additional_data` object contains custom parameters and device data passed through the SDK. This is useful for forwarding arbitrary key-value pairs like referral codes or custom identifiers through the attribution flow.
Example:
```json theme={null}
{
"id": "user_123",
"name": "Test User",
"email": "test@example.com",
"phone": "9876543210",
"device_data": {},
"referral_code": "ABC123",
"custom_param_name": "custom_value"
}
```
## Example Payloads
### Install Event
```json theme={null}
{
"event_type": "install",
"lr_install_id": "e1c0f4a2-5b7d-4c19-9a3f-2d8e6b1c7a04",
"campaign_id": "camp_XYZ123",
"ad_channel": "META",
"network_name": "META",
"app_version": "2.4.1",
"campaign_name": "Summer Promotion 2023",
"attributed_on": "2026-03-24T08:11:00.464Z",
"installed_at": "2026-03-24T08:11:00.464Z",
"store_click_at": "2026-03-24T08:11:00.464Z",
"link": "https://dl.linkrunner.io/?c=camp_XYZ123",
"meta_campaign_details": {
"ad_creative_id": "cr_987654321",
"ad_creative_name": "Summer Sale Creative",
"ad_set_id": "as_12345",
"ad_set_name": "Mobile Users Segment",
"campaign_group_id": null,
"campaign_group_name": null,
"account_id": "acc_1122334455",
"ad_objective_name": "APP_INSTALLS",
"is_instagram": null,
"publisher_platform": "facebook",
"platform_position": "feed"
},
"google_campaign_details": null,
"apple_search_ads_details": null,
"campaign_details": null,
"gaid": "bk9384xs-p449-96ds-r132",
"idfa": null
}
```
### Install Event (Organic)
Sent only when **Send webhooks for organic users** is enabled:
```json theme={null}
{
"event_type": "install",
"lr_install_id": "7f3b9d61-24ae-4f08-b512-c9a70e3d8b45",
"campaign_id": "",
"ad_channel": null,
"network_name": "ORGANIC",
"app_version": "2.4.1",
"campaign_name": null,
"attributed_on": "2026-03-24T08:11:00.464Z",
"installed_at": "2026-03-24T08:11:00.464Z",
"store_click_at": null,
"link": null,
"meta_campaign_details": null,
"google_campaign_details": null,
"apple_search_ads_details": null,
"campaign_details": null,
"gaid": "bk9384xs-p449-96ds-r132",
"idfa": null
}
```
### Signup Event
```json theme={null}
{
"event_type": "signup",
"lr_install_id": "a48d2c07-6e91-4b33-8f5a-1b0c9de74265",
"user_id": "test_user_webhook_002",
"campaign_id": "OgWmhiSXhG",
"ad_channel": "META",
"network_name": "META",
"app_version": "1.0.0",
"campaign_name": "TOF - Free trial - AAA - DSDT - 17/12",
"attributed_on": "2026-03-25T15:52:00.007Z",
"installed_at": "2025-12-30T12:34:29.742Z",
"store_click_at": null,
"link": "https://dl.linkrunner.io/?c=OgWmhiSXhG&utm_source=meta_ads",
"meta_campaign_details": null,
"google_campaign_details": null,
"apple_search_ads_details": null,
"campaign_details": null,
"gaid": "5faa2433-d7e1-4a8e-9a1c-a5880c26ab5c",
"idfa": null,
"name": "Test User",
"phone": "9876543210",
"email": "test@example.com",
"additional_data": {
"id": "test_user_webhook_002",
"name": "Test User",
"email": "test@example.com",
"phone": "9876543210",
"device_data": {},
"referral_code": "ABC123",
"custom_param_name": "custom_value"
}
}
```
## Authentication
Every webhook request includes a `linkrunner-key` header containing your project's private key. Use this to verify that requests are genuinely from Linkrunner.
```javascript theme={null}
// Example: Verifying the webhook key
const PRIVATE_KEY = process.env.LINKRUNNER_PRIVATE_KEY;
app.post('/webhook', (req, res) => {
const receivedKey = req.headers['linkrunner-key'];
if (receivedKey !== PRIVATE_KEY) {
return res.status(401).json({ error: 'Unauthorized' });
}
// Process the webhook...
res.status(200).json({ received: true });
});
```
Never expose your private key in client-side code. Store it securely as an environment variable.
## Slack Integration
Linkrunner automatically formats webhook payloads for Slack when your URL contains `hooks.slack.com`. Instead of raw JSON, Slack receives a rich Block Kit formatted message displaying:
* Event type and user ID
* App version and network
* Campaign name
* Attribution timestamps
* Device identifiers (GAID/IDFA)
* User identity (name, phone, email)
* Meta, Google, Apple Search Ads, or generic campaign details, when available
To set up Slack notifications:
1. Create an [Incoming Webhook](https://api.slack.com/messaging/webhooks) in your Slack workspace
2. Copy the webhook URL (format: `https://hooks.slack.com/services/...`)
3. Paste it as your webhook URL in Linkrunner settings
## Retry Behavior
If your endpoint fails to respond with a 2xx status code, Linkrunner makes up to 3 attempts total with exponential backoff:
| Attempt | Delay |
| ----------- | ---------------------------------- |
| 1st attempt | Immediate |
| 2nd attempt | 1 second after the first failure |
| 3rd attempt | 2 seconds after the second failure |
After 3 failed attempts, the webhook is marked as failed. Ensure your endpoint is reliable to avoid missing events.
## Best Practices
Return a 2xx status code as fast as possible. Process the webhook data asynchronously to avoid timeouts.
Store processed webhook events using `event_type` and `lr_install_id`, plus `user_id` when present.
Always verify the `linkrunner-key` header matches your private key before processing.
Log failed webhook processing for debugging and implement alerting for critical failures.
## Troubleshooting
**Webhooks not being received?**
* Verify your endpoint URL is correct and publicly accessible
* Check that your server accepts POST requests with JSON body
* Ensure your firewall allows incoming requests from Linkrunner
**Getting 401 errors?**
* Verify the `linkrunner-key` header validation in your code
* Check your private key matches the one in your [dashboard settings](https://dashboard.linkrunner.io/settings)
**Missing data in payload?**
* `name`, `phone`, and `email` require you to pass `user_data` to the SDK's `.signup()` or `.trigger()` methods
* `user_id` requires SDK configuration to be passed
* Device identifiers (`gaid`/`idfa`) depend on user consent and SDK implementation
* `additional_data` requires passing a `data` object to the SDK and contains those custom parameters
* Network-specific detail objects are only populated when that network provided attribution data
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Welcome to Linkrunner
Source: https://docs.linkrunner.io/introduction
The modern mobile attribution and marketing analytics platform
Linkrunner is a Mobile Measurement Partner (MMP) that helps founders, growth marketers, and mobile developers track every user journey from first click to revenue, across web and mobile.
## Key Features
* **Multi-platform attribution** for web, iOS, Android, and cross-platform frameworks
* **Deferred deep linking** that routes users to specific in-app content after install
* **Revenue and ROAS tracking** tied to acquisition source down to ad and creative
* **Branded short links** on your own custom domain
* **SKAdNetwork & privacy-safe iOS measurement** with conversion value management
* **Remarketing audiences** built from on-device events
* **Webhooks, data APIs, and an MCP server** to plug attribution data into any tool
* **Multi-cloud, low-latency ingestion** from US, EU, and Asia
## Get Started
Set up your first project, install the SDK, and see your first attributed install in minutes.
Add Linkrunner to your iOS, Android, or cross-platform app.
Wire up Meta, Google, TikTok, Snapchat, or LinkedIn for full-funnel attribution.
Validate clicks, installs, events, and postbacks end-to-end before launch.
## SDKs
Pick your platform. Mobile SDKs cover app attribution, while the Web SDK measures website traffic and events.
Native Swift / Objective-C
Native Kotlin / Java
Cross-platform mobile
Dart-based mobile apps
Managed React Native workflow
iOS and Android games
Ionic / Capacitor apps
Apache Cordova plugin
Beta website attribution for traffic, campaigns, users, and events
Storefront and checkout attribution
## Ad Network Integrations
Connect the channels you spend on and Linkrunner handles attribution, postbacks, and ROAS.
App campaign attribution
Including Web-to-App
Events API & postbacks
Conversion API
B2B install attribution
Privacy-safe iOS measurement
## Analytics & MarTech
Forward attribution data to the tools your team already uses.
## Build with the API
Pull attribution, install, and campaign data programmatically.
Send revenue events from your backend or payments stack.
Server-to-server events without an SDK.
Create and manage campaigns and links from code.
Pull campaign analytics: clicks, installs, spend, revenue, and ROAS.
Receive real-time events for installs, opens, and conversions.
Query your attribution data from Claude and other AI clients.
## Stay in Touch
Manage projects, campaigns, billing, and team members.
Reach out for personalized integration help.
# Quickstart
Source: https://docs.linkrunner.io/quickstart
Get Linkrunner attributing your installs in under 10 minutes
**Using an AI coding agent?** You can skip the manual steps below. Install the Linkrunner skill for your platform and let Claude Code, Cursor, Copilot, or Windsurf integrate the SDK and deep links for you:
```bash theme={null}
npx @linkrunner/skills list # see all platforms
npx @linkrunner/skills add flutter # e.g. Flutter
```
Then ask your agent to "integrate Linkrunner". See [Linkrunner Agent Skills](https://github.com/linkrunner-labs/skills).
This guide walks you through the six steps every Linkrunner integration follows. Each step links out to platform-specific instructions when you need them.
For mobile SDK testing, [Open SDK Console](https://dashboard.linkrunner.io/dashboard/sdk-console/register-test-device) in the dashboard. It helps you register a test device and verify SDK events.
## 1. Create a Project & Get Your Token
1. Sign in to the [Linkrunner Dashboard](https://dashboard.linkrunner.io).
2. Create a new project (or open an existing one).
3. Copy your **Project Token** from the [SDK Console](https://dashboard.linkrunner.io/dashboard/sdk-console/register-test-device).
4. (Optional, recommended for production) Grab your **Secret Key** and **Key ID** from [Dashboard → Settings → SDK Signing](https://dashboard.linkrunner.io/settings?s=sdk-signing) to enable request signing.
## 2. Install the SDK
Pick your platform and follow the install steps in the corresponding SDK guide.
```bash theme={null}
npm install rn-linkrunner
# or
yarn add rn-linkrunner
```
[Full React Native guide →](/sdk/react-native)
```bash theme={null}
flutter pub add linkrunner
```
[Full Flutter guide →](/sdk/flutter)
```ruby theme={null}
# Podfile
pod 'LinkrunnerKit'
```
[Full iOS guide →](/sdk/ios)
```kotlin theme={null}
// build.gradle.kts
implementation("io.linkrunner:sdk:")
```
[Full Android guide →](/sdk/android)
```bash theme={null}
npx expo install expo-linkrunner rn-linkrunner
```
[Full Expo guide →](/sdk/expo)
```bash theme={null}
npm install capacitor-linkrunner
npx cap sync
```
[Full Capacitor guide →](/sdk/capacitor)
```bash theme={null}
cordova plugin add cordova-plugin-linkrunner
```
[Full Cordova guide →](/sdk/cordova)
Add the Android and iOS bridges, then drop in `LinkrunnerSDK.cs`. [Full Unity guide →](/sdk/unity)
## 3. Initialize the SDK (Required)
Call `init` as early as possible in your app's startup — typically in your root component, `Application` class, or `AppDelegate`. Pass the optional `secretKey` and `keyId` if you set them up in step 1.
```javascript theme={null}
import linkrunner from "rn-linkrunner";
await linkrunner.init(
"YOUR_PROJECT_TOKEN",
"YOUR_SECRET_KEY", // Optional
"YOUR_KEY_ID", // Optional
);
```
```dart theme={null}
import 'package:linkrunner/linkrunner.dart';
await LinkRunner().init(
'YOUR_PROJECT_TOKEN',
'YOUR_SECRET_KEY', // Optional
'YOUR_KEY_ID', // Optional
);
```
```swift theme={null}
import LinkrunnerKit
try await LinkrunnerSDK.shared.initialize(
token: "YOUR_PROJECT_TOKEN",
secretKey: "YOUR_SECRET_KEY", // Optional
keyId: "YOUR_KEY_ID" // Optional
)
```
```kotlin theme={null}
LinkRunner.getInstance().init(
context = applicationContext,
token = "YOUR_PROJECT_TOKEN",
secretKey = "YOUR_SECRET_KEY", // Optional
keyId = "YOUR_KEY_ID" // Optional
)
```
```javascript theme={null}
linkrunner.init({ token: "YOUR_WEB_SDK_TOKEN" });
```
## 4. Identify the User (Required)
Call `signup` **once**, as soon as the user is identified — whether through signup or login. This is what ties the install (and any future events) to a user identifier and powers revenue and lifecycle attribution.
```javascript theme={null}
await linkrunner.signup({
user_data: {
id: "user_123", // Required
name: "Jane Doe", // Optional
email: "jane@example.com",
phone: "9876543210",
is_first_time_user: true,
},
});
```
```dart theme={null}
await LinkRunner().signup(
userData: LRUserData(
id: 'user_123',
name: 'Jane Doe',
email: 'jane@example.com',
),
);
```
```swift theme={null}
try await LinkrunnerSDK.shared.signup(
userData: LinkrunnerUserData(
id: "user_123",
name: "Jane Doe",
email: "jane@example.com"
)
)
```
```kotlin theme={null}
LinkRunner.getInstance().signup(
userData = UserDataRequest(
id = "user_123",
name = "Jane Doe",
email = "jane@example.com"
)
)
```
**`signup` is required.** `setUserData` is optional and is **not** a replacement for `signup` — call it later only when additional user details become available (e.g., the user adds a phone or completes their profile after the initial signup).
## 5. Handle Deep Links
To unlock [remarketing](/features/remarketing) and deferred deep linking, forward incoming deep link URLs to Linkrunner. Each SDK exposes a `handleDeeplink` (or equivalent) method — see the SDK guide for the cold-start and warm-start hooks specific to your platform.
Configure universal links and app links for your domain.
Route brand-new installs to the right in-app screen.
## 6. Verify Your Setup
For mobile SDKs, use SDK Console to register a test device, verify SDK initialization, check attribution data, and test signup, custom events, and revenue events.
For normal testing, registering the device in SDK Console is enough. Set `debug = true` in SDK init only if GAID collection is disabled on Android, or the ATT prompt is disabled on iOS so IDFA is unavailable. Remove `debug = true` before production.
End-to-end checks for clicks, installs, events, and postbacks.
Common issues and how to debug them.
## What to Build Next
Hook up Meta, Google, TikTok, Snapchat, or LinkedIn.
Send purchases and tie them back to acquisition source.
Pipe attribution into Mixpanel, Amplitude, PostHog, GA4, and more.
Re-engage users with on-device event-based segments.
## Need Help?
Email [support@linkrunner.io](mailto:support@linkrunner.io) — we typically respond within a few hours during business hours.
# Collecting Device Data
Source: https://docs.linkrunner.io/sdk-less/android-device-data
Where each device_data field comes from on Android, and what attribution loses without it.
`device_data` is the object you send on `init`, `attribution-data`, and `signup`. It carries the signals Linkrunner uses to match an install to a click. Most fields are simple lookups. Three need Google Play libraries and do the heavy lifting for attribution.
The [full client](/sdk-less/android-quickstart) collects all of these for you. This page explains each one so you can trust, trim, or rebuild that collection.
## Dependencies
```gradle theme={null}
dependencies {
// Advertising ID (GAID)
implementation 'com.google.android.gms:play-services-ads-identifier:18.0.1'
// App Set ID
implementation 'com.google.android.gms:play-services-appset:16.0.2'
// Google Play Install Referrer
implementation 'com.android.installreferrer:installreferrer:2.2'
}
```
Going SDK-less does not remove these libraries. They are how Android exposes the advertising ID and the install referrer, so you need them no matter how you call Linkrunner.
## Permissions and queries
```xml theme={null}
```
## What to send
| Field | Source on Android | Importance |
| -------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------ |
| `gaid` | `AdvertisingIdClient` | **High**. Google Ads and device matching. |
| `install_ref` (and related) | Play Install Referrer | **High**. Carries `gclid`, `fbclid`, `utm_*`, Meta referrer. |
| `meta_install_ref` | Meta content provider | **High** for Meta ads. |
| `device_id` | `Settings.Secure.ANDROID_ID` | Recommended. Matching fallback. |
| `device_ip` | Network interfaces | Recommended. IP matching fallback. |
| `user_agent` | `WebSettings.getDefaultUserAgent` | Recommended. Fingerprint matching. |
| `app_version`, `build_number`, `bundle_id`, `application_name` | `PackageManager` | Recommended. |
| `manufacturer`, `brand`, `device_name`, `system_version` | `Build` | Recommended. |
| `connectivity`, `carrier` | System services | Optional. |
| `appsetid`, `appsetid_scope` | `AppSet` | Optional. |
If you send only the basics and skip `gaid`, `install_ref`, and `meta_install_ref`, install registration still works, but paid attribution for Google and Meta will be weak or missing.
## Advertising ID (GAID)
The Google Advertising ID is the primary signal for Google Ads and device matching. Read it off the main thread.
```kotlin theme={null}
fun gaid(context: Context): String? = try {
val info = AdvertisingIdClient.getAdvertisingIdInfo(context)
if (info.isLimitAdTrackingEnabled) null else info.id
} catch (e: Exception) { null }
```
It returns `null` when the user has limited ad tracking. If your app targets children, do not collect the GAID and remove the `AD_ID` permission. See the [SDK guidance on AAID](/sdk/android#disabling-aaid-collection).
## Google Play Install Referrer
The install referrer is the single most valuable attribution signal on Android. It carries the `gclid`, `fbclid`, `utm_*` parameters, and the Meta encrypted referrer from the Play Store click that led to the install.
```kotlin theme={null}
suspend fun installReferrer(context: Context): JSONObject? = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { cont ->
val client = InstallReferrerClient.newBuilder(context).build()
client.startConnection(object : InstallReferrerStateListener {
override fun onInstallReferrerSetupFinished(code: Int) {
try {
if (code == InstallReferrerClient.InstallReferrerResponse.OK) {
val r = client.installReferrer
cont.resume(JSONObject().apply {
put("install_ref", r.installReferrer ?: "")
put("install_ref_install_version", r.installVersion ?: "")
put("install_ref_installBeginTimestampSeconds", r.installBeginTimestampSeconds)
put("install_ref_referrerClickTimestampSeconds", r.referrerClickTimestampSeconds)
put("install_ref_googlePlayInstantParam", r.googlePlayInstantParam)
})
} else cont.resume(null)
} catch (e: Exception) {
cont.resume(null)
} finally {
runCatching { client.endConnection() }
}
}
override fun onInstallReferrerServiceDisconnected() {
if (cont.isActive) cont.resume(null)
}
})
cont.invokeOnCancellation { runCatching { client.endConnection() } }
}
}
```
The install referrer is available right after install. Read it once on first launch, send it with `init`, and cache it. The Play Store keeps it only briefly, so do not delay the first read.
## Meta Install Referrer
For Meta (Facebook and Instagram) attribution, query the Meta install referrer from the installed Meta app. It needs your Facebook App ID in the manifest and the `` block above.
```xml theme={null}
```
```kotlin theme={null}
fun metaInstallReferrer(context: Context): JSONObject? {
val appId = facebookAppId(context) ?: return null
val providers = listOf(
"com.facebook.katana.provider.InstallReferrerProvider" to "facebook",
"com.instagram.contentprovider.InstallReferrerProvider" to "instagram",
"com.facebook.lite.provider.InstallReferrerProvider" to "facebook_lite",
)
for ((authority, source) in providers) {
if (context.packageManager.resolveContentProvider(authority, 0) == null) continue
val uri = Uri.parse("content://$authority/$appId")
context.contentResolver.query(
uri, arrayOf("install_referrer", "is_ct", "actual_timestamp"), null, null, null
)?.use { c ->
if (c.moveToFirst()) {
val refIdx = c.getColumnIndex("install_referrer")
val ref = if (refIdx >= 0) c.getString(refIdx) else null
if (!ref.isNullOrEmpty()) return JSONObject().apply {
put("install_referrer", ref)
put("source", source)
c.getColumnIndex("is_ct").let { if (it >= 0) put("is_ct", c.getInt(it)) }
c.getColumnIndex("actual_timestamp").let { if (it >= 0) put("actual_timestamp", c.getLong(it)) }
}
}
}
}
return null
}
fun facebookAppId(context: Context): String? = try {
val meta = context.packageManager
.getApplicationInfo(context.packageName, PackageManager.GET_META_DATA).metaData
meta?.getString("com.facebook.sdk.ApplicationId")
?: meta?.getString("com.linkrunner.FacebookApplicationId")
} catch (e: Exception) { null }
```
See [Meta Install Referrer](/features/meta-install-referrer) for how Linkrunner uses this signal.
## Everything else
These are plain system lookups. The [full client](/sdk-less/android-quickstart) includes them.
* `device_id`: `Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)`
* `device_ip`: first non-loopback IPv4 from `NetworkInterface.getNetworkInterfaces()`
* `user_agent`: `WebSettings.getDefaultUserAgent(context)`
* `manufacturer`, `brand`, `device_name`, `system_version`: `Build.MANUFACTURER`, `Build.BRAND`, `Build.MODEL`, `Build.VERSION.RELEASE`
* App info: `PackageManager.getPackageInfo` for `app_version` and `build_number`, `context.packageName` for `bundle_id`
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Minimal Integration
Source: https://docs.linkrunner.io/sdk-less/android-minimal
The smallest Android integration that still attributes: install, signup, event, payment.
This is the leanest SDK-less path. It registers an install, reads attribution, identifies the user, and sends one event and one payment. It collects only the two highest-value signals: the advertising ID and the install referrer.
For production, prefer the [full client](/sdk-less/android-quickstart). It collects more signals and handles retries, which improves match rates. Use this minimal version only when you want the smallest possible footprint.
## Dependencies
```gradle theme={null}
dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.google.android.gms:play-services-ads-identifier:18.0.1'
implementation 'com.android.installreferrer:installreferrer:2.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
}
```
## Permissions
```xml theme={null}
```
## LinkrunnerMinimal.kt
Set your project token at the top.
```kotlin theme={null}
import android.content.Context
import com.android.installreferrer.api.InstallReferrerClient
import com.android.installreferrer.api.InstallReferrerStateListener
import com.google.android.gms.ads.identifier.AdvertisingIdClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.util.UUID
import kotlin.coroutines.resume
object LinkrunnerMinimal {
private const val BASE_URL = "https://api.linkrunner.io"
private const val TOKEN = "YOUR_PROJECT_TOKEN"
private val http = OkHttpClient()
private val jsonType = "application/json; charset=utf-8".toMediaType()
/** Generated once, then reused for the life of the install. */
private fun installId(context: Context): String {
val prefs = context.getSharedPreferences("linkrunner_min", Context.MODE_PRIVATE)
prefs.getString("iid", null)?.let { return it }
return UUID.randomUUID().toString().also { prefs.edit().putString("iid", it).apply() }
}
private fun base(context: Context) = JSONObject()
.put("token", TOKEN)
.put("platform", "ANDROID")
.put("install_instance_id", installId(context))
private suspend fun post(path: String, body: JSONObject): JSONObject = withContext(Dispatchers.IO) {
val req = Request.Builder()
.url(BASE_URL + path)
.post(body.toString().toRequestBody(jsonType))
.build()
http.newCall(req).execute().use { res ->
val text = res.body?.string().orEmpty()
if (!res.isSuccessful) throw RuntimeException("Linkrunner ${res.code}: $text")
if (text.isEmpty()) JSONObject() else JSONObject(text)
}
}
// ---- Device signals ----
private suspend fun deviceData(context: Context): JSONObject = withContext(Dispatchers.IO) {
val d = JSONObject().put("idfa", "").put("idfv", "")
try {
val info = AdvertisingIdClient.getAdvertisingIdInfo(context)
d.put("gaid", if (info.isLimitAdTrackingEnabled) "" else info.id)
} catch (e: Exception) {
d.put("gaid", "")
}
installReferrer(context)?.let { d.put("install_ref", it) }
d
}
private suspend fun installReferrer(context: Context): String? = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { cont ->
val client = InstallReferrerClient.newBuilder(context).build()
client.startConnection(object : InstallReferrerStateListener {
override fun onInstallReferrerSetupFinished(code: Int) {
try {
if (code == InstallReferrerClient.InstallReferrerResponse.OK) {
cont.resume(client.installReferrer.installReferrer)
} else cont.resume(null)
} catch (e: Exception) {
cont.resume(null)
} finally {
runCatching { client.endConnection() }
}
}
override fun onInstallReferrerServiceDisconnected() {
if (cont.isActive) cont.resume(null)
}
})
cont.invokeOnCancellation { runCatching { client.endConnection() } }
}
}
// ---- Endpoints ----
suspend fun init(context: Context) =
post("/api/client/init", base(context).put("device_data", deviceData(context)))
suspend fun attribution(context: Context): JSONObject =
post("/api/client/attribution-data", base(context).put("device_data", deviceData(context)))
suspend fun signup(context: Context, userId: String) =
post(
"/api/client/trigger",
base(context)
.put("user_data", JSONObject().put("id", userId))
.put("data", JSONObject().put("device_data", deviceData(context)))
)
suspend fun trackEvent(context: Context, name: String, data: JSONObject? = null) =
post(
"/api/client/capture-event",
base(context).put("event_name", name).apply { if (data != null) put("event_data", data) }
)
suspend fun capturePayment(context: Context, userId: String, amount: Double, paymentId: String) =
post(
"/api/client/capture-payment",
base(context).put("user_id", userId).put("amount", amount).put("payment_id", paymentId).put("status", "PAYMENT_COMPLETED")
)
}
```
## Using it
Register the install and read attribution on app start.
```kotlin theme={null}
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
CoroutineScope(Dispatchers.IO).launch {
// 1. Register the install
LinkrunnerMinimal.init(applicationContext)
// 2. Read attribution (poll until campaign_data appears)
repeat(5) {
val data = LinkrunnerMinimal.attribution(applicationContext).optJSONObject("data")
if (data?.isNull("campaign_data") == false) {
println("Attributed: ${data.getJSONObject("campaign_data")}")
return@launch
}
delay(2000)
}
}
}
}
```
Then identify the user and track activity.
```kotlin theme={null}
// 3. When the user signs up or logs in
CoroutineScope(Dispatchers.IO).launch {
LinkrunnerMinimal.signup(context, userId = "user_123")
}
// 4. Track an event
CoroutineScope(Dispatchers.IO).launch {
LinkrunnerMinimal.trackEvent(context, "purchase_initiated", JSONObject().put("amount", 99.99))
}
// 5. Capture a payment
CoroutineScope(Dispatchers.IO).launch {
LinkrunnerMinimal.capturePayment(context, userId = "user_123", amount = 99.99, paymentId = "payment_123")
}
```
Run every call off the main thread, and call from the device so Linkrunner sees the device IP.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Android Quickstart
Source: https://docs.linkrunner.io/sdk-less/android-quickstart
The recommended SDK-less path: one Kotlin client that collects every signal and calls every endpoint.
This is the recommended way to integrate without the SDK. You add two Kotlin files, configure your token, and call typed methods that mirror the SDK. The client collects all device signals, manages the install instance ID, and retries failed requests.
## 1. Add dependencies
```gradle theme={null}
dependencies {
// HTTP and JSON
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
// Device signals
implementation 'com.google.android.gms:play-services-ads-identifier:18.0.1'
implementation 'com.google.android.gms:play-services-appset:16.0.2'
implementation 'com.android.installreferrer:installreferrer:2.2'
}
```
## 2. Add permissions and queries
In `AndroidManifest.xml`:
```xml theme={null}
```
For Meta attribution, add your Facebook App ID inside ``:
```xml theme={null}
```
## 3. Add the device data collector
Create `LinkrunnerDeviceData.kt`. This gathers everything Linkrunner uses for matching. See [Collecting device data](/sdk-less/android-device-data) for what each field means.
```kotlin theme={null}
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.telephony.TelephonyManager
import android.webkit.WebSettings
import com.android.installreferrer.api.InstallReferrerClient
import com.android.installreferrer.api.InstallReferrerStateListener
import com.google.android.gms.ads.identifier.AdvertisingIdClient
import com.google.android.gms.appset.AppSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.net.Inet4Address
import java.net.NetworkInterface
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
/** Collects the device signals Linkrunner uses for matching. */
object LinkrunnerDeviceData {
@SuppressLint("HardwareIds")
suspend fun collect(context: Context): JSONObject = withContext(Dispatchers.IO) {
val d = JSONObject()
// App and package info
runCatching {
val pm = context.packageManager
val pkg = context.packageName
val info = pm.getPackageInfo(pkg, 0)
d.put("application_name", pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString())
d.put("app_version", info.versionName ?: "")
d.put("version", info.versionName ?: "")
d.put(
"build_number",
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode.toString()
else @Suppress("DEPRECATION") info.versionCode.toString()
)
d.put("bundle_id", pkg)
}
// Device info
d.put("device_id", Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: "")
d.put("device_name", "${Build.MANUFACTURER} ${Build.MODEL}")
d.put("manufacturer", Build.MANUFACTURER)
d.put("brand", Build.BRAND)
d.put("system_version", Build.VERSION.RELEASE ?: "")
d.put("connectivity", connectivity(context))
d.put("user_agent", userAgent(context))
d.put("carrier", JSONArray().apply { carrier(context)?.let { put(it) } })
// iOS-only fields, sent empty on Android
d.put("idfa", "")
d.put("idfv", "")
// Device IP (best effort)
deviceIp()?.let { d.put("device_ip", it) }
// Advertising ID
d.put("gaid", gaid(context) ?: "")
// App Set ID
appSet(context)?.let {
d.put("appsetid", it.first)
d.put("appsetid_scope", it.second)
}
// Google Play install referrer (flattened into device_data)
installReferrer(context)?.let { ref -> ref.keys().forEach { d.put(it, ref.get(it)) } }
// Meta install referrer
metaInstallReferrer(context)?.let { d.put("meta_install_ref", it) }
d
}
private fun connectivity(context: Context): String = try {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
"Unknown"
} else {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val caps = cm.getNetworkCapabilities(cm.activeNetwork)
when {
caps == null -> "Not Connected"
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi"
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "Mobile Network"
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "Ethernet"
else -> "Unknown"
}
}
} catch (e: Exception) {
"Unknown"
}
private fun userAgent(context: Context): String = try {
WebSettings.getDefaultUserAgent(context)
} catch (e: Exception) {
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL})"
}
private fun carrier(context: Context): String? = try {
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
tm.networkOperatorName?.takeIf { it.isNotEmpty() }
} catch (e: Exception) {
null
}
private fun deviceIp(): String? = try {
var ip: String? = null
for (ni in java.util.Collections.list(NetworkInterface.getNetworkInterfaces())) {
for (addr in java.util.Collections.list(ni.inetAddresses)) {
if (!addr.isLoopbackAddress && addr is Inet4Address) ip = addr.hostAddress
}
}
ip
} catch (e: Exception) {
null
}
private fun gaid(context: Context): String? = try {
val info = AdvertisingIdClient.getAdvertisingIdInfo(context)
if (info.isLimitAdTrackingEnabled) null else info.id
} catch (e: Exception) {
null
}
private fun appSet(context: Context): Pair? = try {
val info = com.google.android.gms.tasks.Tasks.await(
AppSet.getClient(context).appSetIdInfo, 1500, TimeUnit.MILLISECONDS
)
if (info.id.isNullOrBlank()) null else Pair(info.id, info.scope)
} catch (e: Exception) {
null
}
private suspend fun installReferrer(context: Context): JSONObject? = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { cont ->
val client = InstallReferrerClient.newBuilder(context).build()
client.startConnection(object : InstallReferrerStateListener {
override fun onInstallReferrerSetupFinished(code: Int) {
try {
if (code == InstallReferrerClient.InstallReferrerResponse.OK) {
val r = client.installReferrer
val o = JSONObject().apply {
put("install_ref", r.installReferrer ?: "")
put("install_ref_install_version", r.installVersion ?: "")
put("install_ref_installBeginTimestampSeconds", r.installBeginTimestampSeconds)
put("install_ref_referrerClickTimestampSeconds", r.referrerClickTimestampSeconds)
put("install_ref_googlePlayInstantParam", r.googlePlayInstantParam)
}
if (cont.isActive) cont.resume(o)
} else if (cont.isActive) {
cont.resume(null)
}
} catch (e: Exception) {
if (cont.isActive) cont.resume(null)
} finally {
runCatching { client.endConnection() }
}
}
override fun onInstallReferrerServiceDisconnected() {
if (cont.isActive) cont.resume(null)
}
})
cont.invokeOnCancellation { runCatching { client.endConnection() } }
}
}
private fun metaInstallReferrer(context: Context): JSONObject? {
val appId = facebookAppId(context) ?: return null
val providers = listOf(
"com.facebook.katana.provider.InstallReferrerProvider" to "facebook",
"com.instagram.contentprovider.InstallReferrerProvider" to "instagram",
"com.facebook.lite.provider.InstallReferrerProvider" to "facebook_lite",
)
for ((authority, source) in providers) {
if (context.packageManager.resolveContentProvider(authority, 0) == null) continue
val uri = Uri.parse("content://$authority/$appId")
context.contentResolver.query(
uri, arrayOf("install_referrer", "is_ct", "actual_timestamp"), null, null, null
)?.use { c ->
if (c.moveToFirst()) {
val refIdx = c.getColumnIndex("install_referrer")
val ref = if (refIdx >= 0) c.getString(refIdx) else null
if (!ref.isNullOrEmpty()) {
return JSONObject().apply {
put("install_referrer", ref)
put("source", source)
c.getColumnIndex("is_ct").let { if (it >= 0) put("is_ct", c.getInt(it)) }
c.getColumnIndex("actual_timestamp").let { if (it >= 0) put("actual_timestamp", c.getLong(it)) }
}
}
}
}
}
return null
}
private fun facebookAppId(context: Context): String? = try {
val meta = context.packageManager
.getApplicationInfo(context.packageName, PackageManager.GET_META_DATA).metaData
meta?.getString("com.facebook.sdk.ApplicationId")
?: meta?.getString("com.linkrunner.FacebookApplicationId")
} catch (e: Exception) {
null
}
}
```
## 4. Add the client
Create `LinkrunnerClient.kt`. It authenticates with your project token in the request body.
```kotlin theme={null}
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.util.UUID
import java.util.concurrent.TimeUnit
/**
* Linkrunner REST client. Mirrors the SDK's API over plain HTTP.
* Call configure() once, then use the suspend methods from a background coroutine.
*/
object LinkrunnerClient {
private const val BASE_URL = "https://api.linkrunner.io"
private const val PLATFORM = "ANDROID"
private const val PREFS = "linkrunner_sdkless"
private const val KEY_INSTALL_ID = "install_instance_id"
private const val MAX_RETRIES = 3
private lateinit var appContext: Context
private lateinit var token: String
private var debug = false
private val http = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
private val jsonMedia = "application/json; charset=utf-8".toMediaType()
private val retriable = setOf(408, 429, 500, 502, 503, 504)
fun configure(context: Context, token: String, debug: Boolean = false) {
this.appContext = context.applicationContext
this.token = token
this.debug = debug
}
/** Stable per-install identity. Generated once, then reused forever. */
fun installInstanceId(): String {
val prefs = appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
prefs.getString(KEY_INSTALL_ID, null)?.let { return it }
return UUID.randomUUID().toString().also { prefs.edit().putString(KEY_INSTALL_ID, it).apply() }
}
// ---- Endpoints ----
suspend fun init(link: String? = null): JSONObject {
val device = LinkrunnerDeviceData.collect(appContext)
val body = base().apply {
put("package_version", "android-rest-1.0")
put("app_version", device.optString("app_version", ""))
put("device_data", device)
if (link != null) put("link", link)
put("debug", debug)
}
return post("/api/client/init", body)
}
suspend fun getAttributionData(): JSONObject =
post("/api/client/attribution-data", base().put("device_data", LinkrunnerDeviceData.collect(appContext)))
suspend fun signup(
userData: Map,
additionalData: Map = emptyMap(),
): JSONObject {
val data = JSONObject().put("device_data", LinkrunnerDeviceData.collect(appContext))
additionalData.forEach { (k, v) -> if (v != null) data.put(k, wrap(v)) }
val body = base().apply {
put("user_data", jsonOf(userData))
put("data", data)
}
return post("/api/client/trigger", body)
}
suspend fun setUserData(userData: Map): JSONObject =
post("/api/client/set-user-data", base().put("user_data", jsonOf(userData)))
suspend fun trackEvent(eventName: String, eventData: Map? = null): JSONObject {
val body = base().put("event_name", eventName)
if (eventData != null) body.put("event_data", jsonOf(eventData))
return post("/api/client/capture-event", body)
}
suspend fun capturePayment(
amount: Double,
paymentId: String,
userId: String? = null,
type: String = "DEFAULT",
status: String = "PAYMENT_COMPLETED",
eventData: Map? = null,
): JSONObject {
val body = base().apply {
put("amount", amount)
put("type", type)
put("status", status)
put("payment_id", paymentId)
if (userId != null) put("user_id", userId)
if (eventData != null) put("event_data", jsonOf(eventData))
}
return post("/api/client/capture-payment", body)
}
suspend fun removePayment(paymentId: String? = null, userId: String? = null): JSONObject {
require(paymentId != null || userId != null) { "Provide paymentId or userId" }
val body = base().apply {
if (paymentId != null) put("payment_id", paymentId)
if (userId != null) put("user_id", userId)
}
return post("/api/client/remove-captured-payment", body)
}
suspend fun setPushToken(pushToken: String): JSONObject =
post("/api/client/update-push-token", base().put("push_token", pushToken))
suspend fun setIntegrationData(clevertapId: String): JSONObject =
post(
"/api/client/integrations",
base().put("integration_info", JSONObject().put("clevertap_id", clevertapId))
)
suspend fun handleDeeplink(deeplinkUrl: String): JSONObject {
val device = LinkrunnerDeviceData.collect(appContext)
val body = base().apply {
put("deeplink_url", deeplinkUrl)
put("device_data", device)
device.optJSONObject("meta_install_ref")?.let { put("meta_install_referrer", it) }
}
return post("/api/client/handle-deeplink", body)
}
// ---- Internals ----
private fun base() = JSONObject()
.put("token", token)
.put("platform", PLATFORM)
.put("install_instance_id", installInstanceId())
private suspend fun post(path: String, body: JSONObject): JSONObject = withContext(Dispatchers.IO) {
var attempt = 0
while (true) {
try {
val request = Request.Builder()
.url(BASE_URL + path)
.post(body.toString().toRequestBody(jsonMedia))
.build()
http.newCall(request).execute().use { res ->
val text = res.body?.string().orEmpty()
when {
res.isSuccessful -> return@withContext if (text.isEmpty()) JSONObject() else JSONObject(text)
res.code in retriable && attempt < MAX_RETRIES -> Unit // fall through to retry
else -> throw RuntimeException("Linkrunner ${res.code}: $text")
}
}
} catch (e: IOException) {
if (attempt >= MAX_RETRIES) throw e
}
delay(1000L * (1L shl attempt)) // 1s, 2s, 4s
attempt++
}
}
private fun wrap(value: Any?): Any = when (value) {
is Map<*, *> -> JSONObject().also { o -> value.forEach { (k, v) -> if (v != null) o.put(k.toString(), wrap(v)) } }
is List<*> -> JSONArray().also { a -> value.forEach { a.put(if (it == null) JSONObject.NULL else wrap(it)) } }
else -> value ?: JSONObject.NULL
}
private fun jsonOf(map: Map): JSONObject {
val o = JSONObject()
map.forEach { (k, v) -> if (v != null) o.put(k, wrap(v)) }
return o
}
}
```
## 5. Initialize and read attribution
Call `configure` once, then `init`, then poll `getAttributionData`. The `Application` class is a good place.
```kotlin theme={null}
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
LinkrunnerClient.configure(applicationContext, token = "YOUR_PROJECT_TOKEN")
CoroutineScope(Dispatchers.IO).launch {
LinkrunnerClient.init()
repeat(5) {
val data = LinkrunnerClient.getAttributionData().optJSONObject("data")
if (data?.isNull("campaign_data") == false) {
println("Campaign: ${data.getJSONObject("campaign_data")}")
return@launch
}
delay(2000)
}
}
}
}
```
## 6. Identify the user
Call `signup` when the user signs up or logs in so events and payments are linked to a user.
```kotlin theme={null}
CoroutineScope(Dispatchers.IO).launch {
LinkrunnerClient.signup(
userData = mapOf(
"id" to "user_123",
"email" to "user@example.com",
"name" to "John Doe"
)
)
}
```
## 7. Track events and payments
```kotlin theme={null}
// Custom event
LinkrunnerClient.trackEvent(
"purchase_initiated",
mapOf("product_id" to "12345", "amount" to 99.99)
)
// Payment
LinkrunnerClient.capturePayment(
amount = 99.99,
userId = "user_123",
paymentId = "payment_123",
type = "FIRST_PAYMENT"
)
```
Add `amount` as a number in event or payment data to enable revenue sharing with Google and Meta.
## Other endpoints
The client also wraps the remaining endpoints:
```kotlin theme={null}
LinkrunnerClient.setUserData(mapOf("id" to "user_123", "phone" to "9876543210"))
LinkrunnerClient.setPushToken(fcmToken)
LinkrunnerClient.setIntegrationData(clevertapId = "YOUR_CLEVERTAP_ID")
LinkrunnerClient.handleDeeplink("https://app.example.com/product/123")
LinkrunnerClient.removePayment(paymentId = "payment_123")
```
## Retries and idempotency
The client retries failed requests up to 3 times with exponential backoff (1s, 2s, 4s). It retries on network errors and on HTTP `408`, `429`, and `5xx`. Other responses, such as `400` or `401`, are not retried.
A retry can send the same request twice if the first attempt reached the server but the response was lost. How that is handled depends on the endpoint:
| Endpoint | Safe to retry? | Why |
| ----------------- | ---------------------- | --------------------------------------------------------- |
| `init` | Yes | The server deduplicates on `install_instance_id`. |
| `capture-payment` | Yes, with `payment_id` | The server deduplicates on `payment_id`. Always send one. |
| `capture-event` | Not deduplicated | A retried event can be counted twice. |
`payment_id` is required for `capturePayment`, and a unique value ensures retries cannot double count revenue. Events have no server-side deduplication, so if exact event counts matter, disable retries for those calls.
## Troubleshooting
Make sure `init` ran on the device, not your backend, and that `gaid` and `install_ref` are present in `device_data`. Without them, only organic installs resolve. See [Collecting device data](/sdk-less/android-device-data).
Check that you called `signup` and used the same `install_instance_id` everywhere. Events and payments are stored for all users, including organic ones, so a missing campaign does not mean the event was dropped.
You are likely calling from your backend. The server matches on the calling IP, so calls must come from the user's device.
You are over the 25 requests per second per token limit. The client already retries with backoff. Slow down bursty calls.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# API Reference
Source: https://docs.linkrunner.io/sdk-less/api-reference
Every Linkrunner client endpoint used for SDK-less Android integration.
All endpoints are `POST` requests to `https://api.linkrunner.io` with a JSON body and `Content-Type: application/json`.
## Authentication
Send your project token in the body of every request as `token`. That is the only credential these endpoints need.
```json theme={null}
{ "token": "YOUR_PROJECT_TOKEN" }
```
## Common fields
These fields appear on most requests. Send them on every call once your install is registered.
| Field | Type | Description |
| --------------------- | ------ | ------------------------------------------------------------------------------------------------------------ |
| `token` | string | **Required**. Your project token. |
| `platform` | string | Use `"ANDROID"` or `"IOS"`. |
| `install_instance_id` | string | The UUID you generate once and reuse. See [Install instance ID](/sdk-less/introduction#install-instance-id). |
## Rules
* **Rate limit:** 25 requests per second per token. Over the limit returns `429`.
* **Response envelope:** most endpoints return `{ "msg": string, "status": number, "data": object }`. `init` is the exception (see below).
* **Call from the device** so Linkrunner sees the device IP.
This reference uses Android examples. On iOS, send `"platform": "IOS"` and a different `device_data` shape (IDFA, IDFV, and `adservices_attribution_token` instead of GAID and the install referrer). The `init`, `trigger`, `capture-event`, and `capture-payment` responses may also include SKAdNetwork values (`fine_conversion_value`, `coarse_conversion_value`, `lock_postback`). See [iOS device data](/sdk-less/ios-device-data).
***
## init
```
POST /api/client/init
```
Registers an install or app open and starts attribution. Call once on app start.
| Field | Type | Description |
| --------------------- | ------- | ---------------------------------------------------------------------------- |
| `token` | string | **Required**. |
| `install_instance_id` | string | The persisted UUID. Send it so events tie to this install. |
| `device_data` | object | Device signals. See [Collecting device data](/sdk-less/android-device-data). |
| `platform` | string | `"ANDROID"`. |
| `app_version` | string | Your app's version name. |
| `package_version` | string | A version string for your integration. |
| `link` | string | Optional. The deeplink the app was opened with, if any. |
| `debug` | boolean | Optional. |
Returns `202`. Attribution runs in the background, so the response only acknowledges receipt.
```json theme={null}
{
"processing_id": "f1e2...c9",
"status": "accepted",
"message": "Install event accepted for processing",
"success": true
}
```
To read the attribution result, poll [`attribution-data`](#attribution-data) with the same `install_instance_id`.
## attribution-data
```
POST /api/client/attribution-data
```
Returns the attribution result for an install. Safe to poll. The result is cached for 24 hours.
| Field | Type | Description |
| --------------------- | ------ | --------------------------------------------------------------------- |
| `token` | string | **Required**. |
| `install_instance_id` | string | The install to look up. |
| `device_data` | object | Optional. Include `install_ref` so the Meta referrer can be resolved. |
| `platform` | string | `"ANDROID"`. |
```json theme={null}
{
"msg": "Attribution data retrieved successfully!",
"status": 200,
"data": {
"deeplink": "https://app.example.com/product/123",
"campaign_data": {
"id": "1234",
"name": "summer_sale",
"type": "INORGANIC",
"ad_network": "META",
"group_name": "prospecting",
"asset_group_name": "video_ads",
"asset_name": "reel_01",
"installed_at": "2026-06-14T10:00:00Z",
"store_click_at": "2026-06-14T09:55:00Z"
},
"attribution_source": "META"
}
}
```
For an organic install, `campaign_data` is `null` and `attribution_source` is `"ORGANIC"`.
## trigger (signup)
```
POST /api/client/trigger
```
Identifies the user and ties the install to your user ID. Call when the user signs up or logs in.
| Field | Type | Description |
| ------------------------ | ------- | -------------------------------------------------- |
| `token` | string | **Required**. |
| `user_data` | object | The user, see below. |
| `data` | object | Pass-through bag. Include `device_data` inside it. |
| `install_instance_id` | string | The persisted UUID. |
| `platform` | string | `"ANDROID"`. |
| `customer_created_at` | string | Optional. ISO date the user account was created. |
| `is_first_time_customer` | boolean | Optional. |
`user_data` fields (only `id` is needed):
| Field | Type | Description |
| --------------------------------------------------------------------------------------- | ------ | ------------------------------------- |
| `id` | string | Your user ID. |
| `name`, `email`, `phone` | string | User details. |
| `mixpanel_distinct_id`, `amplitude_device_id`, `posthog_distinct_id`, `braze_device_id` | string | Analytics IDs for those integrations. |
| `ga_app_instance_id`, `ga_session_id` | string | Google Analytics IDs. |
```json theme={null}
{
"token": "YOUR_PROJECT_TOKEN",
"platform": "ANDROID",
"install_instance_id": "f1e2...c9",
"user_data": { "id": "user_123", "email": "user@example.com" },
"data": { "device_data": { "...": "..." } }
}
```
```json theme={null}
{ "msg": "Trigger processed successfully", "status": 200, "data": { "deeplink": "", "trigger": true } }
```
## set-user-data
```
POST /api/client/set-user-data
```
Updates user details after signup, for example when the user adds a phone number. Not a replacement for `signup`.
| Field | Type | Description |
| --------------------- | ------ | ----------------------- |
| `token` | string | **Required**. |
| `user_data` | object | Same shape as `signup`. |
| `install_instance_id` | string | The persisted UUID. |
| `platform` | string | `"ANDROID"`. |
## capture-event
```
POST /api/client/capture-event
```
Tracks a custom event. Events are stored for all users, including organic ones. Identify the user with `user_id` or `install_instance_id`.
| Field | Type | Description |
| --------------------- | ------ | ---------------------------------------------------------------------------------------- |
| `token` | string | **Required**. |
| `event_name` | string | **Required**. |
| `event_data` | object | Optional. Custom attributes. Add `amount` as a number to share revenue with ad networks. |
| `install_instance_id` | string | The persisted UUID. |
| `user_id` | string | Optional. |
| `platform` | string | `"ANDROID"`. |
```json theme={null}
{ "msg": "Event capture request received!", "status": 200, "data": { "message": "Event capture request received!" } }
```
## capture-payment
```
POST /api/client/capture-payment
```
Tracks revenue. Like events, the user must be attributed via `signup`.
| Field | Type | Description |
| --------------------- | ------ | ---------------------------------------------------------------------------------- |
| `token` | string | **Required**. |
| `amount` | number | Payment amount. Must be zero or greater. |
| `user_id` | string | The paying user. |
| `payment_id` | string | **Required**. Used to deduplicate. Sending the same `payment_id` twice is ignored. |
| `type` | string | Optional. Defaults to `DEFAULT`. |
| `status` | string | Optional. Defaults to `PAYMENT_COMPLETED`. |
| `event_data` | object | Optional. Ecommerce attributes. |
| `install_instance_id` | string | The persisted UUID. |
| `platform` | string | `"ANDROID"`. |
`type` values: `FIRST_PAYMENT`, `SECOND_PAYMENT`, `ONE_TIME`, `RECURRING`, `WALLET_TOPUP`, `FUNDS_WITHDRAWAL`, `SUBSCRIPTION_CREATED`, `SUBSCRIPTION_RENEWED`, `SUBSCRIPTION_CANCELLED`, `SUBSCRIPTION_REFUNDED`, `SUBSCRIPTION_BILLING_ISSUE`, `DEFAULT`.
`status` values: `PAYMENT_INITIATED`, `PAYMENT_COMPLETED`, `PAYMENT_FAILED`, `PAYMENT_CANCELLED`.
```json theme={null}
{ "msg": "Payment capture request received!", "status": 201, "data": { "message": "Payment capture request received!" } }
```
## remove-captured-payment
```
POST /api/client/remove-captured-payment
```
Removes a captured payment, for a refund. Provide `payment_id` or `user_id`.
| Field | Type | Description |
| --------------------- | ------ | ------------------------------ |
| `token` | string | **Required**. |
| `payment_id` | string | The payment to remove. |
| `user_id` | string | Remove payments for this user. |
| `install_instance_id` | string | The persisted UUID. |
| `platform` | string | `"ANDROID"`. |
## update-push-token
```
POST /api/client/update-push-token
```
Stores the device push token, used for uninstall tracking.
| Field | Type | Description |
| --------------------- | ------ | ------------------- |
| `token` | string | **Required**. |
| `push_token` | string | The FCM token. |
| `install_instance_id` | string | The persisted UUID. |
| `platform` | string | `"ANDROID"`. |
## integrations
```
POST /api/client/integrations
```
Sends third-party IDs, such as a CleverTap ID.
| Field | Type | Description |
| --------------------- | ------ | ---------------------------------------- |
| `token` | string | **Required**. |
| `integration_info` | object | For example `{ "clevertap_id": "..." }`. |
| `install_instance_id` | string | The persisted UUID. |
| `platform` | string | `"ANDROID"`. |
## handle-deeplink
```
POST /api/client/handle-deeplink
```
Reports a deeplink open for remarketing and reattribution. Call when your app opens from a deeplink.
| Field | Type | Description |
| ----------------------- | ------ | ------------------------------------------------ |
| `token` | string | **Required**. |
| `deeplink_url` | string | The deeplink the app opened with. |
| `device_data` | object | Device signals. |
| `meta_install_referrer` | object | Optional. The Meta referrer object if available. |
| `install_instance_id` | string | The persisted UUID. |
| `platform` | string | `"ANDROID"`. |
```json theme={null}
{ "msg": "Deeplink processed", "status": 200, "data": { "deeplink": "https://app.example.com/x", "is_linkrunner": true, "processing": false } }
```
## Errors
| Code | Meaning |
| ----- | ----------------------------------------- |
| `400` | Missing required field or invalid token. |
| `401` | Invalid token (on `attribution-data`). |
| `429` | Rate limit exceeded (25 req/s per token). |
| `500` | Server error. Retry with backoff. |
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# SDK-less Integration
Source: https://docs.linkrunner.io/sdk-less/introduction
Integrate Linkrunner on Android by calling the REST API directly, without the native SDK.
Linkrunner's native SDKs handle attribution for you. If you cannot add the Android SDK to your app, you can integrate the same way the SDK does: by calling Linkrunner's REST API directly from your app.
This section lists every endpoint the SDK uses, shows where to collect each signal on the device, and gives you ready-to-use client code for Android and iOS.
Use the [Android SDK](/sdk/android) when you can. It handles data collection, retries, and edge cases for you. Reach for SDK-less integration only when a policy, build constraint, or custom runtime stops you from adding the SDK.
## How it works
Call `init` with your token, the install instance ID, and device data. Linkrunner starts attribution in the background.
Poll `attribution-data` with the same install instance ID until campaign data appears.
Call `signup` when the user signs up or logs in. Events and payments are only stored for users who have been through `signup`.
Send events and payments using the same install instance ID.
## Key concepts
### Project token
Every request is authenticated with your project token in the JSON body. That is the only credential these endpoints need. Find your token on the [dashboard](https://dashboard.linkrunner.io/dashboard?s=members\&m=documentation).
### Install instance ID
`install_instance_id` is a UUID your app generates once, stores, and sends on every request. It ties an install to the events, signups, and payments that follow.
**Generate it a single time and persist it** (`SharedPreferences` on Android, `UserDefaults` on iOS). If you create a new one per call, every request looks like a different device and attribution falls apart.
## Two things that break attribution
Get these right before anything else. Both fail silently.
| Rule | Why it matters |
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Call the API from the device, not your backend.** | Linkrunner matches installs using the IP of the client that calls the API. If you proxy these calls through your server, every install shares one IP and matching fails. |
| **Treat `init` as asynchronous.** | The `init` response never contains campaign data. Attribution runs in the background, so you read the result by polling [`attribution-data`](/sdk-less/api-reference#attribution-data). |
## Integrate on Android
One Kotlin client that collects every signal and calls every endpoint. Closest to the real SDK.
The smallest path that still attributes: install, signup, one event, one payment.
See [collecting device data on Android](/sdk-less/android-device-data) for where each field comes from.
## Integrate on iOS
One Swift client that collects every signal, applies SKAdNetwork values, and calls every endpoint.
The smallest path that still attributes: install, signup, one event, one payment.
See [collecting device data on iOS](/sdk-less/ios-device-data) for where each field comes from.
## Reference
Every endpoint, with request and response bodies.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Collecting Device Data
Source: https://docs.linkrunner.io/sdk-less/ios-device-data
Where each device_data field comes from on iOS, and what attribution loses without it.
`device_data` is the object you send on `init`, `attribution-data`, and `signup`. It carries the signals Linkrunner uses to match an install to a click. On iOS, every field comes from an Apple framework, so you need no third-party libraries.
The [full client](/sdk-less/ios-quickstart) collects all of these for you. This page explains each one so you can trust, trim, or rebuild that collection.
## Info.plist
To collect the IDFA, add a tracking usage description. Nothing else here needs a permission.
```xml theme={null}
NSUserTrackingUsageDescription
We use your data to attribute installs and measure ad performance.
```
## What to send
| Field | Source on iOS | Importance |
| -------------------------------------------------------------- | ---------------------------------------- | ---------------------------------------- |
| `adservices_attribution_token` | `AAAttribution.attributionToken()` | **High**. Apple Search Ads attribution. |
| `idfa` | `ASIdentifierManager`, after ATT consent | **High**, when the user allows tracking. |
| `idfv` | `UIDevice.identifierForVendor` | Recommended. Matching fallback. |
| `device`, `device_name`, `system_version` | `UIDevice` | Recommended. |
| `brand`, `manufacturer` | Always `"Apple"` | Recommended. |
| `version`, `build_number`, `bundle_id` | `Bundle.main` | Recommended. |
| `locale`, `language`, `country`, `timezone`, `timezone_offset` | `Locale`, `TimeZone` | Recommended. |
| `device_display` | `UIScreen.main` | Optional. |
| `user_agent`, `connectivity` | App-defined, `NWPathMonitor` | Optional. |
If you send neither `adservices_attribution_token` nor `idfa`, install registration still works, but paid attribution for Apple Search Ads and ad-network matching will be weak or missing.
## Apple Search Ads attribution token
This is the most reliable iOS attribution signal and needs no user permission. The `AdServices` framework returns a short-lived token that Linkrunner exchanges with Apple server-side.
```swift theme={null}
import AdServices
func adServicesToken() -> String? {
try? AAAttribution.attributionToken()
}
```
The token is valid only briefly. Fetch it fresh each time you build `device_data` rather than caching it.
## Advertising ID (IDFA)
The IDFA is only available after the user grants App Tracking Transparency. Add `NSUserTrackingUsageDescription`, prompt while the app is active, then read the IDFA.
```swift theme={null}
import AppTrackingTransparency
import AdSupport
// Prompt once, when the app is active (for example after the first screen appears)
func requestTracking() async {
await withCheckedContinuation { cont in
ATTrackingManager.requestTrackingAuthorization { _ in cont.resume() }
}
}
// Read the IDFA only if the user authorized tracking
func idfa() -> String? {
guard ATTrackingManager.trackingAuthorizationStatus == .authorized else { return nil }
return ASIdentifierManager.shared().advertisingIdentifier.uuidString
}
```
If your app targets children, do not request tracking and do not collect the IDFA.
## Vendor ID (IDFV)
The IDFV needs no permission. It is stable across your apps on the same device, but resets when the user removes all of your apps.
```swift theme={null}
import UIKit
let idfv = await UIDevice.current.identifierForVendor?.uuidString
```
## SKAdNetwork
iOS measures ad-driven installs through SKAdNetwork. After you call `init`, `signup`, `capture-event`, or `capture-payment`, the response can include a conversion value:
```json theme={null}
{
"data": {
"fine_conversion_value": 12,
"coarse_conversion_value": "medium",
"lock_postback": false
}
}
```
Apply it with `SKAdNetwork.updatePostbackConversionValue`. The [full client](/sdk-less/ios-quickstart#skadnetwork) does this for you in `applySKAN`. See [SKAdNetwork integration](/features/skadnetwork-integration) for the dashboard side.
## Everything else
Plain framework lookups. The [full client](/sdk-less/ios-quickstart) includes them.
* `device`, `device_name`, `system_version`: `UIDevice.current`
* `device_display`: `UIScreen.main.bounds` and `scale`
* `locale`, `language`, `country`: `Locale.current`
* `timezone`, `timezone_offset`: `TimeZone.current`
* App info: `Bundle.main` for `version` (`CFBundleShortVersionString`), `build_number` (`CFBundleVersion`), and `bundle_id`
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Minimal Integration
Source: https://docs.linkrunner.io/sdk-less/ios-minimal
The smallest iOS integration that still attributes: install, signup, event, payment.
This is the leanest SDK-less path on iOS. It registers an install, reads attribution, identifies the user, and sends one event and one payment. It collects only the highest-value signals: the Apple Search Ads token, the IDFV, and the IDFA when tracking is already authorized.
For production, prefer the [full client](/sdk-less/ios-quickstart). It collects more signals, applies SKAdNetwork conversion values, and retries failed requests, which improves match rates. Use this minimal version only when you want the smallest possible footprint.
## LinkrunnerMinimal.swift
Set your project token at the top. No third-party dependencies are needed.
```swift theme={null}
import Foundation
import UIKit
import AdSupport
import AppTrackingTransparency
import AdServices
@available(iOS 15.0, *)
enum LinkrunnerMinimal {
private static let baseURL = "https://api.linkrunner.io"
private static let token = "YOUR_PROJECT_TOKEN"
private static let installIdKey = "linkrunner_min_iid"
/// Generated once, then reused for the life of the install.
private static func installId() -> String {
if let id = UserDefaults.standard.string(forKey: installIdKey) { return id }
let id = UUID().uuidString
UserDefaults.standard.set(id, forKey: installIdKey)
return id
}
private static func base() -> [String: Any] {
["token": token, "platform": "IOS", "install_instance_id": installId()]
}
private static func post(_ path: String, _ body: [String: Any]) async throws -> [String: Any] {
var request = URLRequest(url: URL(string: baseURL + path)!)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await URLSession.shared.data(for: request)
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
guard (200..<300).contains(code) else {
throw NSError(domain: "Linkrunner", code: code,
userInfo: [NSLocalizedDescriptionKey: String(data: data, encoding: .utf8) ?? ""])
}
return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]
}
private static func deviceData() async -> [String: Any] {
var d: [String: Any] = ["brand": "Apple", "manufacturer": "Apple"]
if let idfv = await MainActor.run(body: { UIDevice.current.identifierForVendor?.uuidString }) {
d["idfv"] = idfv
}
if ATTrackingManager.trackingAuthorizationStatus == .authorized {
d["idfa"] = ASIdentifierManager.shared().advertisingIdentifier.uuidString
}
if let token = try? AAAttribution.attributionToken() {
d["adservices_attribution_token"] = token
}
return d
}
// MARK: - Endpoints
@discardableResult
static func initialize() async throws -> [String: Any] {
var body = base()
body["device_data"] = await deviceData()
return try await post("/api/client/init", body)
}
static func attribution() async throws -> [String: Any] {
var body = base()
body["device_data"] = await deviceData()
return try await post("/api/client/attribution-data", body)
}
@discardableResult
static func signup(userId: String) async throws -> [String: Any] {
var body = base()
body["user_data"] = ["id": userId]
body["data"] = ["device_data": await deviceData()]
return try await post("/api/client/trigger", body)
}
@discardableResult
static func trackEvent(_ name: String, data: [String: Any]? = nil) async throws -> [String: Any] {
var body = base()
body["event_name"] = name
if let data { body["event_data"] = data }
return try await post("/api/client/capture-event", body)
}
@discardableResult
static func capturePayment(userId: String, amount: Double, paymentId: String) async throws -> [String: Any] {
var body = base()
body["user_id"] = userId
body["amount"] = amount
body["payment_id"] = paymentId
body["status"] = "PAYMENT_COMPLETED"
return try await post("/api/client/capture-payment", body)
}
}
```
## Using it
Register the install and read attribution on app start.
```swift theme={null}
import SwiftUI
@main
struct MyApp: App {
init() {
Task {
// 1. Register the install
try? await LinkrunnerMinimal.initialize()
// 2. Read attribution (poll until campaign_data appears)
for _ in 0..<5 {
let data = (try? await LinkrunnerMinimal.attribution())?["data"] as? [String: Any]
if let campaign = data?["campaign_data"] as? [String: Any] {
print("Attributed: \(campaign)")
break
}
try? await Task.sleep(nanoseconds: 2_000_000_000)
}
}
}
var body: some Scene {
WindowGroup { ContentView() }
}
}
```
Then identify the user and track activity.
```swift theme={null}
// 3. When the user signs up or logs in
Task { try? await LinkrunnerMinimal.signup(userId: "user_123") }
// 4. Track an event
Task { try? await LinkrunnerMinimal.trackEvent("purchase_initiated", data: ["amount": 99.99]) }
// 5. Capture a payment
Task { try? await LinkrunnerMinimal.capturePayment(userId: "user_123", amount: 99.99, paymentId: "payment_123") }
```
Call from the device so Linkrunner sees the device IP. To collect the IDFA, request App Tracking Transparency first (see the [full client](/sdk-less/ios-quickstart)).
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# iOS Quickstart
Source: https://docs.linkrunner.io/sdk-less/ios-quickstart
The recommended SDK-less path on iOS: one Swift client that collects every signal and calls every endpoint.
This is the recommended way to integrate without the SDK on iOS. You add one Swift file, configure your token, and call typed methods that mirror the SDK. The client collects device signals, manages the install instance ID, applies SKAdNetwork conversion values, and retries failed requests.
No third-party dependencies are needed. The client uses `URLSession` and Apple frameworks (`AdSupport`, `AppTrackingTransparency`, `AdServices`, `StoreKit`). It targets iOS 15 and above.
## 1. Configure Info.plist
To collect the IDFA, add a tracking usage description. Without it, iOS will not show the App Tracking Transparency prompt and the IDFA stays empty.
```xml theme={null}
NSUserTrackingUsageDescription
We use your data to attribute installs and measure ad performance.
```
## 2. Add the client
Create `LinkrunnerClient.swift`. It authenticates with your project token in the request body.
```swift theme={null}
import Foundation
import UIKit
import AdSupport
import AppTrackingTransparency
import AdServices
import StoreKit
@available(iOS 15.0, *)
final class LinkrunnerClient {
static let shared = LinkrunnerClient()
private init() {}
private let baseURL = "https://api.linkrunner.io"
private let platform = "IOS"
private var token = "YOUR_PROJECT_TOKEN"
private var debug = false
private let installIdKey = "linkrunner_install_instance_id"
private let installTimeKey = "linkrunner_install_time"
func configure(token: String, debug: Bool = false) {
self.token = token
self.debug = debug
_ = installInstanceId()
if UserDefaults.standard.object(forKey: installTimeKey) == nil {
UserDefaults.standard.set(Date(), forKey: installTimeKey)
}
}
/// Stable per-install identity. Generated once, then reused forever.
func installInstanceId() -> String {
if let id = UserDefaults.standard.string(forKey: installIdKey) { return id }
let id = UUID().uuidString
UserDefaults.standard.set(id, forKey: installIdKey)
return id
}
/// Ask for IDFA permission. Requires NSUserTrackingUsageDescription. Call when the app is active.
func requestTrackingAuthorization() async {
await withCheckedContinuation { cont in
ATTrackingManager.requestTrackingAuthorization { _ in cont.resume() }
}
}
// MARK: - Endpoints
@discardableResult
func initialize(link: String? = nil) async throws -> [String: Any] {
var body = base()
body["package_version"] = "ios-rest-1.0"
body["app_version"] = appVersion()
body["device_data"] = await deviceData()
body["debug"] = debug
if let link { body["link"] = link }
return try await post("/api/client/init", body)
}
func attributionData() async throws -> [String: Any] {
var body = base()
body["device_data"] = await deviceData()
body["debug"] = debug
return try await post("/api/client/attribution-data", body)
}
@discardableResult
func signup(userData: [String: Any], additionalData: [String: Any] = [:]) async throws -> [String: Any] {
var data = additionalData
data["device_data"] = await deviceData()
var body = base()
body["user_data"] = userData
body["data"] = data
body["time_since_app_install"] = timeSinceInstall()
let res = try await post("/api/client/trigger", body)
applySKAN(res)
return res
}
@discardableResult
func setUserData(_ userData: [String: Any]) async throws -> [String: Any] {
var body = base()
body["user_data"] = userData
body["device_data"] = await deviceData()
return try await post("/api/client/set-user-data", body)
}
@discardableResult
func trackEvent(_ name: String, data eventData: [String: Any]? = nil) async throws -> [String: Any] {
var body = base()
body["event_name"] = name
if let eventData { body["event_data"] = eventData }
body["device_data"] = await deviceData()
body["time_since_app_install"] = timeSinceInstall()
let res = try await post("/api/client/capture-event", body)
applySKAN(res)
return res
}
@discardableResult
func capturePayment(
amount: Double,
userId: String,
paymentId: String,
type: String = "DEFAULT",
status: String = "PAYMENT_COMPLETED",
eventData: [String: Any]? = nil
) async throws -> [String: Any] {
var body = base()
body["amount"] = amount
body["user_id"] = userId
body["type"] = type
body["status"] = status
body["payment_id"] = paymentId
if let eventData { body["event_data"] = eventData }
body["time_since_app_install"] = timeSinceInstall()
body["data"] = ["device_data": await deviceData()]
let res = try await post("/api/client/capture-payment", body)
applySKAN(res)
return res
}
@discardableResult
func removePayment(userId: String, paymentId: String? = nil) async throws -> [String: Any] {
var body = base()
body["user_id"] = userId
if let paymentId { body["payment_id"] = paymentId }
body["data"] = ["device_data": await deviceData()]
return try await post("/api/client/remove-captured-payment", body)
}
@discardableResult
func setPushToken(_ pushToken: String) async throws -> [String: Any] {
var body = base()
body["push_token"] = pushToken
return try await post("/api/client/update-push-token", body)
}
@discardableResult
func setIntegrationData(clevertapId: String) async throws -> [String: Any] {
var body = base()
body["integration_info"] = ["clevertap_id": clevertapId]
return try await post("/api/client/integrations", body)
}
@discardableResult
func handleDeeplink(_ url: String) async throws -> [String: Any] {
var body = base()
body["deeplink_url"] = url
body["device_data"] = await deviceData()
let res = try await post("/api/client/handle-deeplink", body)
applySKAN(res)
return res
}
// MARK: - Networking
private enum LRError: Error { case http(Int, String) }
private let retriableStatus: Set = [408, 429, 500, 502, 503, 504]
private let maxRetries = 3
private func base() -> [String: Any] {
["token": token, "platform": platform, "install_instance_id": installInstanceId()]
}
private func post(_ path: String, _ body: [String: Any]) async throws -> [String: Any] {
let url = URL(string: baseURL + path)!
var attempt = 0
while true {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
do {
let (data, response) = try await URLSession.shared.data(for: request)
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
if (200..<300).contains(code) {
return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]
}
if !(retriableStatus.contains(code) && attempt < maxRetries) {
throw LRError.http(code, String(data: data, encoding: .utf8) ?? "")
}
} catch let error as LRError {
throw error
} catch {
if attempt >= maxRetries { throw error }
}
try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(attempt)) * 1_000_000_000)) // 1s, 2s, 4s
attempt += 1
}
}
// MARK: - SKAdNetwork
/// Applies the conversion value returned by the server, if present.
private func applySKAN(_ response: [String: Any]) {
guard let data = response["data"] as? [String: Any],
let fine = data["fine_conversion_value"] as? Int else { return }
let coarse = data["coarse_conversion_value"] as? String
let lock = data["lock_postback"] as? Bool ?? false
if #available(iOS 16.1, *) {
let coarseValue: SKAdNetwork.CoarseConversionValue
switch coarse?.lowercased() {
case "high": coarseValue = .high
case "medium": coarseValue = .medium
default: coarseValue = .low
}
SKAdNetwork.updatePostbackConversionValue(fine, coarseValue: coarseValue, lockWindow: lock) { _ in }
} else if #available(iOS 15.4, *) {
SKAdNetwork.updatePostbackConversionValue(fine) { _ in }
} else {
SKAdNetwork.updateConversionValue(fine)
}
}
// MARK: - Device data
private func deviceData() async -> [String: Any] {
var d = await uiDeviceData()
d["brand"] = "Apple"
d["manufacturer"] = "Apple"
d["install_instance_id"] = installInstanceId()
if let bundleId = Bundle.main.bundleIdentifier { d["bundle_id"] = bundleId }
d["version"] = appVersion()
if let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String { d["build_number"] = build }
if let idfa = idfa() { d["idfa"] = idfa }
let locale = Locale.current
d["locale"] = locale.identifier
if let lang = locale.languageCode { d["language"] = lang }
if let region = locale.regionCode { d["country"] = region }
d["timezone"] = TimeZone.current.identifier
d["timezone_offset"] = TimeZone.current.secondsFromGMT() / 60
if let token = adServicesToken() { d["adservices_attribution_token"] = token }
return d
}
@MainActor
private func uiDeviceData() -> [String: Any] {
let device = UIDevice.current
let screen = UIScreen.main
var d: [String: Any] = [
"device": device.model,
"device_name": device.name,
"system_version": device.systemVersion,
"device_display": [
"width": screen.bounds.width,
"height": screen.bounds.height,
"scale": screen.scale
]
]
if let idfv = device.identifierForVendor?.uuidString { d["idfv"] = idfv }
return d
}
private func idfa() -> String? {
guard ATTrackingManager.trackingAuthorizationStatus == .authorized else { return nil }
return ASIdentifierManager.shared().advertisingIdentifier.uuidString
}
private func adServicesToken() -> String? {
try? AAAttribution.attributionToken()
}
private func appVersion() -> String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
}
private func timeSinceInstall() -> TimeInterval {
let installed = UserDefaults.standard.object(forKey: installTimeKey) as? Date ?? Date()
return Date().timeIntervalSince(installed)
}
}
```
## 3. Initialize and read attribution
Call `configure` once, then `initialize`, then poll `attributionData`.
```swift theme={null}
import SwiftUI
@main
struct MyApp: App {
init() {
LinkrunnerClient.shared.configure(token: "YOUR_PROJECT_TOKEN")
Task {
try? await LinkrunnerClient.shared.initialize()
for _ in 0..<5 {
let data = (try? await LinkrunnerClient.shared.attributionData())?["data"] as? [String: Any]
if let campaign = data?["campaign_data"] as? [String: Any] {
print("Campaign: \(campaign)")
break
}
try? await Task.sleep(nanoseconds: 2_000_000_000)
}
}
}
var body: some Scene {
WindowGroup { ContentView() }
}
}
```
To collect the IDFA, call `await LinkrunnerClient.shared.requestTrackingAuthorization()` when the app is active (for example after your first screen appears), not during launch. The IDFA improves matching but is optional.
## 4. Identify the user
Call `signup` when the user signs up or logs in so events and payments are linked to a user.
```swift theme={null}
Task {
try? await LinkrunnerClient.shared.signup(
userData: ["id": "user_123", "email": "user@example.com", "name": "John Doe"]
)
}
```
## 5. Track events and payments
```swift theme={null}
// Custom event
Task {
try? await LinkrunnerClient.shared.trackEvent(
"purchase_initiated",
data: ["product_id": "12345", "amount": 99.99]
)
}
// Payment
Task {
try? await LinkrunnerClient.shared.capturePayment(
amount: 99.99,
userId: "user_123",
paymentId: "payment_123",
type: "FIRST_PAYMENT"
)
}
```
Add `amount` as a number in event or payment data to enable revenue sharing with Google and Meta.
## Other endpoints
```swift theme={null}
try? await LinkrunnerClient.shared.setUserData(["id": "user_123", "phone": "9876543210"])
try? await LinkrunnerClient.shared.setPushToken(apnsOrFcmToken)
try? await LinkrunnerClient.shared.setIntegrationData(clevertapId: "YOUR_CLEVERTAP_ID")
try? await LinkrunnerClient.shared.handleDeeplink("https://app.example.com/product/123")
try? await LinkrunnerClient.shared.removePayment(userId: "user_123", paymentId: "payment_123")
```
## SKAdNetwork
`init`, `signup`, `capture-event`, and `capture-payment` responses can include a conversion value (`fine_conversion_value`, `coarse_conversion_value`, `lock_postback`). The client applies it automatically through `applySKAN`, which calls `SKAdNetwork.updatePostbackConversionValue`. You do not need to manage conversion values yourself. See [Collecting device data](/sdk-less/ios-device-data#skadnetwork) for how this fits with Apple's attribution.
## Retries and idempotency
The client retries failed requests up to 3 times with exponential backoff (1s, 2s, 4s). It retries on network errors and on HTTP `408`, `429`, and `5xx`. Other responses, such as `400` or `401`, are not retried.
A retry can send the same request twice if the first attempt reached the server but the response was lost. How that is handled depends on the endpoint:
| Endpoint | Safe to retry? | Why |
| ----------------- | ---------------------- | --------------------------------------------------------- |
| `init` | Yes | The server deduplicates on `install_instance_id`. |
| `capture-payment` | Yes, with `payment_id` | The server deduplicates on `payment_id`. Always send one. |
| `capture-event` | Not deduplicated | A retried event can be counted twice. |
`paymentId` is required for `capturePayment`, and a unique value ensures retries cannot double count revenue. Events have no server-side deduplication, so if exact event counts matter, disable retries for those calls.
## Troubleshooting
Make sure `initialize` ran on the device, not your backend. For Apple Search Ads, confirm `adservices_attribution_token` is present in `device_data`. The IDFA helps but only after the user grants App Tracking Transparency. See [Collecting device data](/sdk-less/ios-device-data).
The IDFA is only available after the user authorizes tracking. Add `NSUserTrackingUsageDescription` and call `requestTrackingAuthorization()` while the app is active.
Check that you called `signup` and used the same `install_instance_id` everywhere. Events and payments are stored for all users, including organic ones, so a missing campaign does not mean the event was dropped.
You are likely calling from your backend. The server matches on the calling IP, so calls must come from the user's device.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Android SDK
Source: https://docs.linkrunner.io/sdk/android
Complete guide for integrating Linkrunner in native Android apps
**Prefer to let your AI coding agent do this?** Install the Linkrunner skill and Claude Code, Cursor, GitHub Copilot, or Windsurf will wire up the SDK and deep links for you:
```bash theme={null}
npx @linkrunner/skills add android
```
Then ask your agent to "integrate Linkrunner". See [Linkrunner Agent Skills](https://github.com/linkrunner-labs/skills).
## Requirements
* Android 5.0 (API level 21) or higher
* Android Studio Flamingo (2022.2.1) or newer
* Gradle 8.0+
## Installation
### Step 1: Gradle Setup
Add the Linkrunner SDK to your app's `build.gradle` file:
```gradle theme={null}
dependencies {
implementation 'io.linkrunner:android-sdk:4.1.0'
}
```
Make sure you have the Maven Central repository in your project's `settings.gradle` file:
```gradle theme={null}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
```
### Step 2: Required Permissions
Add the following permissions to your `AndroidManifest.xml` file:
```xml theme={null}
```
> **Note:** The `AD_ID` permission (``) is already included in the SDK and is required for collecting device identifiers (GAID). If your app participates in [Designed for Families](https://support.google.com/googleplay/android-developer/topic/9877766?hl=en\&ref_topic=9858052), you should revoke AAID and disable AAID collection. See the [Disabling AAID Collection](#disabling-aaid-collection) section for more details.
### Step 3: Backup Configuration
The SDK provides backup rules to exclude Shared Preferences data from backup. This prevents the retention of the Linkrunner install ID during reinstallation, ensuring accurate detection of new installs and re-installs.
This backup configuration works similarly for all SDKs (React Native, Flutter,
and native Android). The same Android backup rules apply regardless of which
SDK you're using.
#### Adding Backup Rules to Your App
**Add to your `AndroidManifest.xml`**:
```xml theme={null}
```
* `android:fullBackupContent` - Used for Android 6-11
* `android:dataExtractionRules` - Used for Android 12+
#### Merging Backup Rules
If you already have your own backup rules specified (e.g., `android:fullBackupContent="@xml/my_backup_descriptor"` or `android:dataExtractionRules="@xml/my_rules"`), then manually add the following rules to your existing files:
**For legacy backup** (Android 6-11) in `res/xml/my_backup_descriptor`:
```xml theme={null}
```
**For modern backup** (Android 12+) in `res/xml/my_backup_rules.xml`:
```xml theme={null}
```
### Step 4: Encrypted SharedPreferences
**SDK Version Requirement:** Starting from `linkrunner-android` **v3.8.1**, the SDK automatically encrypts the credentials it stores in SharedPreferences. No additional configuration is required — upgrade to v3.8.1 or above to get this behavior by default.
Values written by the SDK are encrypted at rest, with a hardware-protected key generated on the device and stored in the [Android Keystore](https://developer.android.com/training/articles/keystore). The key never leaves the device and is not bundled with the SDK.
If you are upgrading from an earlier version, the SDK will transparently migrate any existing plaintext entries to the encrypted store on the next read after the upgrade — no code changes are needed on your side.
### Step 5: Revoking the AD\_ID Permission (Optional)
According to [Google's Policy](https://support.google.com/googleplay/android-developer/answer/11043825?hl=en), apps that target children must not transmit the Advertising ID.
To revoke the AD\_ID permission, use SDK version 3.5.0 and above. Children apps targeting Android 13 (API 33) and above must prevent the permission from getting merged into their app by adding a revoke declaration to their Manifest. Use the [`setDisableAaidCollection()`](#disabling-aaid-collection) and [`isAaidCollectionDisabled()`](#disabling-aaid-collection) functions to disable AAID collection programmatically:
**AndroidManifest.xml**
```xml theme={null}
```
Make sure to add `xmlns:tools="http://schemas.android.com/tools"` to your manifest tag to use the `tools:node="remove"` attribute. If you disable AAID collection, you should also remove the AD\_ID permission from your manifest to fully comply with Google Play's Family Policy requirements.
For more information, see [Google Play Services documentation](https://developers.google.com/android/reference/com/google/android/gms/ads/identifier/AdvertisingIdClient.Info#public-string-getid).
### Importing in Kotlin/Java
After installation, you can import the SDK in your Kotlin or Java files:
```kotlin theme={null}
// Kotlin
import io.linkrunner.sdk.LinkRunner
```
```java theme={null}
// Java
import io.linkrunner.sdk.LinkRunner;
```
## Initialization (Required)
Initialize the Linkrunner SDK in your application, typically in your `Application` class or main activity:
You can find your project token [here](https://dashboard.linkrunner.io/dashboard?s=members\&m=documentation).
Note: This method returns a void. To get attribution data and deeplink information, use the `getAttributionData` method.
```kotlin theme={null}
import android.app.Application
import io.linkrunner.sdk.LinkRunner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize LinkRunner with SDK signing
CoroutineScope(Dispatchers.IO).launch {
try {
LinkRunner.getInstance().init(
context = applicationContext,
token = "YOUR_PROJECT_TOKEN",
secretKey = "YOUR_SECRET_KEY", // Optional: Required for SDK signing
keyId = "YOUR_KEY_ID", // Optional: Required for SDK signing
debug = true // Optional: Enable debug mode for development (defaults to false)
)
println("LinkRunner initialized successfully")
} catch (e: Exception) {
println("Exception during initialization: ${e.message}")
}
}
}
}
```
## SDK Signing Parameters (Optional)
For enhanced security, the LinkRunner SDK requires the following signing parameters during initialization:
* **`secretKey`**: A unique secret key used for request signing and authentication
* **`keyId`**: A unique identifier for the key pair used in the signing process
* **`debug`** (optional): Boolean flag to enable debug mode for development (defaults to false)
You can find your project token, secret key, and key ID [here](https://dashboard.linkrunner.io/settings?s=sdk-signing).
## Setting the Customer User ID
Use `setCustomerUserId` to attach your own user identifier to the device **right after `init`**. Once set, the identifier is stored securely on-device and automatically included in every event you track, so you never have to pass it on each `trackEvent` call.
Call it as early as the user's ID is available. This guarantees every event carries a `user_id` from the very first event, and is especially useful for existing users who were already onboarded before this feature shipped.
Available from **Android SDK v3.9.1**.
**Best practice:** set the Customer User ID as early as possible. The `user_id` is only attached to events tracked **after** it's set, and is not applied retroactively. Use a stable, unique identifier from your own system (for example your internal user ID or a UUID) rather than an email address or other PII.
```kotlin theme={null}
import io.linkrunner.sdk.LinkRunner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
private fun setCustomerUserId() {
CoroutineScope(Dispatchers.IO).launch {
val result = LinkRunner.getInstance().setCustomerUserId("f47ac10b-58cc-4372-a567-0e02b2c3d479") // Your unique customer user ID (e.g. a UUID)
result
.onSuccess { println("Customer user id set") }
.onFailure { println("Failed to set customer user id: ${it.message}") }
}
}
```
The identifier is stored encrypted on-device and persists across app restarts. Calling `setCustomerUserId` again with a **different** identifier updates the stored value; passing the same identifier is a no-op. `signup()` / `setUserData()` also update it.
## User Identification (Required)
Call the `signup` method as soon as the user is identified — whether through signup or login. This is the moment Linkrunner ties the install (and any future events) to a user identifier.
It is strongly recommended to use the integrated platform's identify function to set a persistent user\_id once it becomes available (typically after signup or login).
* [Mixpanel - ID Management & User Identification](https://docs.mixpanel.com/docs/tracking-methods/id-management/identifying-users-simplified)
* [PostHog - How User Identification Works](https://posthog.com/docs/product-analytics/identify#how-identify-works)
* [Amplitude - Identify Users Documentation](https://amplitude.com/docs/get-started/identify-users)
If the platform's identifier function is not called, you must provide a user identifier for Mixpanel, PostHog, and Amplitude integration.
* mixpanelDistinctId for Mixpanel
* posthogDistinctId for PostHog
* amplitudeDeviceId for Amplitude
```kotlin theme={null}
private fun onSignup() {
CoroutineScope(Dispatchers.IO).launch {
try {
val userData = UserDataRequest(
id = "123", // Required: User ID
name = "John Doe", // Optional
phone = "9876543210", // Optional
email = "user@example.com", // Optional
mixpanelDistinctId = "mixpanel_distinct_id", // Optional - Mixpanel Distinct ID
amplitudeDeviceId = "amplitude_device_id", // Optional - Amplitude User ID
posthogDistinctId = "posthog_distinct_id", // Optional - PostHog Distinct ID
userCreatedAt = "2024-01-01T00:00:00Z", // Optional
isFirstTimeUser = true, // Optional
)
LinkRunner.getInstance().signup(
userData = userData,
additionalData = mapOf("custom_field" to "custom_value") // Optional: Any additional data
)
println("Signup successful")
} catch (e: Exception) {
println("Exception during signup: ${e.message}")
}
}
}
```
## Handle Deeplink
To enable [remarketing and reattribution](/features/remarketing), you need to capture deep links and pass them to the Linkrunner SDK. This allows Linkrunner to detect returning users who open the app via a deep link.
```kotlin theme={null}
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// Cold start deeplink
handleDeeplinkFromIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
// Warm start deeplink
handleDeeplinkFromIntent(intent)
}
private fun handleDeeplinkFromIntent(intent: Intent) {
intent.data?.let { uri ->
lifecycleScope.launch {
LinkRunner.getInstance().handleDeeplink(uri.toString())
}
}
}
}
```
Linkrunner sends the updated deeplink back after processing. For Linkrunner campaign links, use the returned `deeplink` as the resolved destination instead of the original tracking URL.
```json theme={null}
{
"deeplink": "https://app.yourdomain.com/product/123"
}
```
## Getting Attribution Data
To get attribution data and deeplink information for the current installation, use the `getAttributionData` function:
```kotlin theme={null}
val attributionDataResult = LinkRunner.getInstance().getAttributionData()
attributionDataResult.onSuccess { attributionData ->
println("Attribution data: $attributionData")
// Attribution data includes:
// - deeplink: The deep link URL that led to app installation
// - campaignData: Campaign information
}
```
```kotlin theme={null}
data class AttributionData(
val deeplink: String?,
val campaignData: CampaignData
)
data class CampaignData(
val id: String,
val name: String,
val adNetwork: String?,
val type: String,
val installedAt: String,
val storeClickAt: String?,
val groupName: String,
val assetName: String,
val assetGroupName: String,
val adNetworkCampaignId: String?, // Ad network campaign ID
val adSetId: String?, // Ad set ID
val adSetName: String?, // Ad set name
val adCreativeId: String?, // Ad creative ID
val adCreativeName: String? // Ad creative name
)
```
**Example response:**
```json theme={null}
{
"deeplink": "https://app.yourdomain.com/product/123",
"campaignData": {
"id": "camp_123",
"name": "Summer Sale 2024",
"adNetwork": "META",
"type": "INORGANIC",
"installedAt": "2024-03-20T10:30:00Z",
"storeClickAt": "2024-03-20T10:29:45Z",
"groupName": "Android Campaign",
"assetName": "Banner Ad 1",
"assetGroupName": "Product Catalog",
"adNetworkCampaignId": "120214682829390250",
"adSetId": "120214682829640250",
"adSetName": "Productivity",
"adCreativeId": "120214682926100250",
"adCreativeName": "Static_2"
}
}
```
## Setting User Data
Call `setUserData` each time the app opens and the user is logged in:
**`setUserData` is optional and is not a replacement for `signup`.** Always call `signup` first as soon as the user is identified (signup or login). Use `setUserData` afterwards only when additional user details become available later — for example, when the user adds a phone number, email, or completes their profile after identification.
```kotlin theme={null}
private fun setUserData() {
CoroutineScope(Dispatchers.IO).launch {
try {
val userData = UserDataRequest(
id = "123", // Required: User ID
name = "John Doe", // Optional
phone = "9876543210", // Optional
email = "user@example.com", // Optional
mixpanelDistinctId = "mixpanel_distinct_id", // Optional - Mixpanel Distinct ID
amplitudeDeviceId = "amplitude_device_id", // Optional - Amplitude User ID
posthogDistinctId = "posthog_distinct_id" // Optional - PostHog Distinct ID
)
val result = LinkRunner.getInstance().setUserData(userData)
result.onSuccess {
println("User data set successfully")
}.onFailure { error ->
println("Error setting user data: ${error.message}")
}
} catch (e: Exception) {
println("Exception setting user data: ${e.message}")
}
}
}
```
## Setting CleverTap ID
Use the `setAdditionalData` method to set CleverTap ID:
```kotlin theme={null}
private fun setIntegrationData() {
CoroutineScope(Dispatchers.IO).launch {
try {
val integrationData: IntegrationData = IntegrationData(clevertapId="YOUR_CLEVERTAP_ID")
val result = LinkRunner.getInstance().setAdditionalData(integrationData)
result.onSuccess {
println("CleverTap ID set successfully")
}.onFailure { error ->
println("Error setting CleverTap ID: ${error.message}")
}
} catch (e: Exception) {
println("Exception setting CleverTap ID: ${e.message}")
}
}
}
```
## Revenue Tracking
Revenue is stored for all users, including organic ones. Payments from users with no matching click are stored without campaign attribution. Call [`.signup`](#user-registration) so payments are linked to a user. You can verify your events are being captured on the [Events](https://dashboard.linkrunner.io/dashboard/events) page.
### Capturing Payments
Track payment information with the following details:
```kotlin theme={null}
private fun capturePayment() {
CoroutineScope(Dispatchers.IO).launch {
try {
val paymentData = CapturePaymentRequest(
paymentId = "payment_123", // Required: Unique payment identifier
userId = "user123", // Required: User identifier
amount = 99.99, // Required: Payment amount
type = PaymentType.FIRST_PAYMENT, // Optional: Defaults to DEFAULT
// type = PaymentType.SECOND_PAYMENT, // Optional: Defaults to DEFAULT
status = PaymentStatus.PAYMENT_COMPLETED, // Optional: Defaults to PAYMENT_COMPLETED
eventData = mapOf( // Optional: Ecommerce/custom event data
"content_ids" to listOf("product_123"),
"content_type" to "product",
"currency" to "USD",
"value" to 99.99,
"num_items" to 1,
"order_id" to "order_12345",
"contents" to listOf(
mapOf(
"id" to "product_123",
"quantity" to 1,
"item_price" to 99.99
)
)
)
)
val result = LinkRunner.getInstance().capturePayment(paymentData)
result.onSuccess {
println("Payment captured successfully")
}.onFailure { error ->
println("Error capturing payment: ${error.message}")
}
} catch (e: Exception) {
println("Exception capturing payment: ${e.message}")
}
}
}
```
#### Available Payment Types
| Type | Description |
| ---------------------- | ------------------------ |
| `FIRST_PAYMENT` | User's first payment |
| `SECOND_PAYMENT` | User's second payment |
| `WALLET_TOPUP` | Adding funds to wallet |
| `FUNDS_WITHDRAWAL` | Withdrawing funds |
| `SUBSCRIPTION_CREATED` | New subscription created |
| `SUBSCRIPTION_RENEWED` | Subscription renewal |
| `ONE_TIME` | One-time payment |
| `RECURRING` | Recurring payment |
| `DEFAULT` | Default payment type |
#### Available Payment Statuses
| Status | Description |
| ------------------- | ------------------------------ |
| `PAYMENT_INITIATED` | Payment process started |
| `PAYMENT_COMPLETED` | Payment successfully completed |
| `PAYMENT_FAILED` | Payment failed |
| `PAYMENT_CANCELLED` | Payment was cancelled |
### Removing Payments
To remove or refund a payment:
```kotlin theme={null}
private fun removePayment() {
CoroutineScope(Dispatchers.IO).launch {
try {
// Either paymentId or userId must be provided
val removeRequest = RemovePaymentRequest(
paymentId = "payment_123", // Optional: Payment ID to remove
userId = "user123" // Optional: User ID to remove payments for
)
val result = LinkRunner.getInstance().removePayment(removeRequest)
result.onSuccess {
println("Payment removed successfully")
}.onFailure { error ->
println("Error removing payment: ${error.message}")
}
} catch (e: Exception) {
println("Exception removing payment: ${e.message}")
}
}
}
```
## Tracking Custom Events
From **Android SDK v3.9.0**, custom events automatically include the `user_id` you set during `signup()` / `setUserData()`. The SDK stores this identifier securely on-device and attaches it to every `trackEvent` call, so you no longer need to pass it manually. Events tracked before signup are sent without a `user_id`.
Events are stored for all users, including organic ones. Events from users with no matching click are stored without campaign attribution. Call [`.signup`](#user-registration) so events are linked to a user. You can verify your events are being captured on the [Events](https://dashboard.linkrunner.io/dashboard/events) page. For capturing revenue, it is recommended to use the [`.capturePayment`](#revenue-tracking) method instead of `.trackEvent`.
Track custom events in your app:
```kotlin theme={null}
private fun trackEvent() {
CoroutineScope(Dispatchers.IO).launch {
try {
val result = LinkRunner.getInstance().trackEvent(
eventName = "purchase_initiated", // Event name
eventData = mapOf( // Optional: Event data
"product_id" to "12345",
"category" to "electronics",
"amount" to 99.99 // Include amount as a number for revenue sharing with ad networks like Google and Meta
),
eventId = "order_12345" // Optional: Your own unique event identifier
)
result.onSuccess {
println("Event tracked successfully")
}.onFailure { error ->
println("Error tracking event: ${error.message}")
}
} catch (e: Exception) {
println("Exception tracking event: ${e.message}")
}
}
}
```
#### Parameters for `LinkRunner.getInstance().trackEvent`
* `eventName`: `String` (required) - Name of the event to track
* `eventData`: `Map` (optional) - Key-value pairs for additional event data, including Meta ecommerce properties
* `eventId`: `String` (optional) - Your own unique identifier for the event, useful for deduplication and correlating with your backend
### Revenue Sharing with Ad Networks
To enable revenue sharing with ad networks like Google Ads and Meta, include an `amount` parameter as a number in your custom event data. This allows the ad networks to optimize campaigns based on the revenue value of conversions:
```kotlin theme={null}
private fun trackPurchaseEvent() {
CoroutineScope(Dispatchers.IO).launch {
try {
val result = LinkRunner.getInstance().trackEvent(
eventName = "purchase_completed",
eventData = mapOf(
"product_id" to "12345",
"category" to "electronics",
"amount" to 149.99 // Revenue amount as a number
)
)
result.onSuccess {
println("Purchase event with revenue tracked successfully")
}.onFailure { error ->
println("Error tracking purchase event: ${error.message}")
}
} catch (e: Exception) {
println("Exception tracking purchase event: ${e.message}")
}
}
}
```
For revenue sharing with ad networks to work properly, ensure the `amount` parameter is passed as a number (Double
or Int), not as a string.
## Ecommerce Events
> **Minimum SDK Version:** Ecommerce Event Manager requires `linkrunner-android` **v3.6.0** or above. Please ensure your SDK is updated before using this feature.
If you are tracking Ecommerce events to sync with Meta or Google, you must format your `eventData` to include the required fields. **You also need to map your custom event to the standard commerce event in the Linkrunner Dashboard.**
For detailed explanations of the required fields like `content_ids`, `contents`, and `value`, refer to our [Meta Commerce Manager documentation](/ecommerce-manager/meta-commerce-manager#understanding-event_data) or [Google Commerce Manager documentation](/ecommerce-manager/google-commerce-manager#understanding-event_data-for-google).
### Add To Cart Example
Use the `trackEvent` method to send an `AddToCart` event:
```kotlin theme={null}
private fun trackAddToCart() {
CoroutineScope(Dispatchers.IO).launch {
LinkRunner.getInstance().trackEvent(
eventName = "add_to_cart", // Map this custom event to "AddToCart" (Meta) or "add_to_cart" (Google) in the Linkrunner Dashboard. See: /ecommerce-manager/meta-commerce-manager, /ecommerce-manager/google-commerce-manager
eventData = mapOf(
"content_ids" to listOf("product_123"),
"contents" to listOf(
mapOf(
"id" to "product_123", // Matches content_ids
"quantity" to 1,
"item_price" to 49.99
)
),
"content_type" to "product",
"currency" to "USD",
"value" to 49.99,
"num_items" to 1
)
)
}
}
```
### View Content Example
Use the `trackEvent` method to send a `ViewContent` event:
```kotlin theme={null}
private fun trackViewContent() {
CoroutineScope(Dispatchers.IO).launch {
LinkRunner.getInstance().trackEvent(
eventName = "view_item", // Map this custom event to "ViewContent" (Meta) or "view_item" (Google) in the Linkrunner Dashboard. See: /ecommerce-manager/meta-commerce-manager, /ecommerce-manager/google-commerce-manager
eventData = mapOf(
"content_ids" to listOf("product_123"),
"contents" to listOf(
mapOf(
"id" to "product_123", // Matches content_ids
"quantity" to 1,
"item_price" to 49.99
)
),
"content_type" to "product",
"currency" to "USD",
"value" to 49.99,
"num_items" to 1
)
)
}
}
```
### Payment / Purchase Example
Use the `capturePayment` method to send a `Purchase` event containing the ecommerce payload:
```kotlin theme={null}
private fun capturePurchase() {
CoroutineScope(Dispatchers.IO).launch {
try {
val paymentData = CapturePaymentRequest(
paymentId = "payment_456",
userId = "user123",
amount = 49.99,
type = PaymentType.FIRST_PAYMENT, // Map this payment type to "Purchase" (Meta) or "ecommerce_purchase" (Google) in the Linkrunner Dashboard. See: /ecommerce-manager/meta-commerce-manager, /ecommerce-manager/google-commerce-manager
status = PaymentStatus.PAYMENT_COMPLETED,
eventData = mapOf(
"content_ids" to listOf("product_123"),
"contents" to listOf(
mapOf(
"id" to "product_123", // Matches content_ids
"quantity" to 1,
"item_price" to 49.99
)
),
"content_type" to "product",
"currency" to "USD",
"value" to 49.99,
"num_items" to 1,
"order_id" to "order_abc123" // Required for Purchase events
)
)
LinkRunner.getInstance().capturePayment(paymentData)
} catch (e: Exception) {
println("Exception capturing purchase: ${e.message}")
}
}
}
```
> **Note:** For more information on testing and verifying your ecommerce events, please see our [Meta Commerce Manager](/ecommerce-manager/meta-commerce-manager#testing-ecommerce-events) or [Google Commerce Manager](/ecommerce-manager/google-commerce-manager#verifying-events-in-google-ads) guide.
## Google Ads Consent
**Prefer to let your AI coding agent do this?** The Android skill already covers this — wiring `setConsent`:
```bash theme={null}
npx @linkrunner/skills add android
```
See [Linkrunner Agent Skills](/ai-agent-skills).
**SDK Version Requirement:** Requires Android SDK version 4.1.0 or higher.
If you run Google App Campaigns, report your users' consent choices. Google's App Conversion API treats these values as required whenever they are known, and reads them when it matches your installs, so an install with no consent state attached is harder to attribute. This matters most for users in the European Economic Area, the United Kingdom, and Switzerland.
Set the values with `setConsent` before you call `init`, and again whenever the user changes their choice:
```kotlin theme={null}
import io.linkrunner.sdk.models.ConsentStatus
import io.linkrunner.sdk.models.LinkrunnerConsent
LinkRunner.getInstance().setConsent(
LinkrunnerConsent(
isEEA = ConsentStatus.GRANTED,
hasConsentForDataUsage = ConsentStatus.GRANTED,
hasConsentForAdsPersonalization = ConsentStatus.DENIED
)
)
```
| Parameter | Meaning |
| --------------------------------- | ------------------------------------------------------------------------- |
| `isEEA` | European regulations apply to this user (the EEA, the UK, or Switzerland) |
| `hasConsentForDataUsage` | The user agreed to their data being sent to Google for advertising |
| `hasConsentForAdsPersonalization` | The user agreed to their data being used to personalize ads |
Each takes `ConsentStatus.GRANTED`, `ConsentStatus.DENIED`, or `ConsentStatus.UNKNOWN`. Anything left `UNKNOWN` is dropped from the payload rather than reported as a denial, so Linkrunner never reports a choice your user did not make.
Google treats these as required whenever their value is known. `hasConsentForDataUsage` decides whether Google may use the conversion at all, `hasConsentForAdsPersonalization` decides whether it may feed audiences and remarketing, and `isEEA` tells Google which rules apply. Set them from your app's real consent state rather than hardcoding them. **For users outside the EEA, the UK, and Switzerland, report `isEEA` as denied and leave the other two unset.** See [Send Consent](/features/send-consent). A missing advertising ID is not a consent signal and is never treated as one.
Consent is stored between launches. Call `setConsent` again whenever the user's consent state changes, otherwise the previous value keeps being sent after the user has withdrawn it.
There is no extra SDK to add to your Android build. Android installs reach Google through the App Conversion API, which reads the consent signals above.
## Enhanced Privacy Controls
The SDK offers options to enhance user privacy:
```kotlin theme={null}
// Enable PII (Personally Identifiable Information) hashing
LinkRunner.getInstance().enablePIIHashing(true)
// Check if PII hashing is enabled
val isHashingEnabled = LinkRunner.getInstance().isPIIHashingEnabled()
```
When PII hashing is enabled, sensitive user data like name, email, and phone number are hashed using SHA-256 before being sent to Linkrunner servers.
## Disabling AAID Collection
**SDK Version Requirement:** The AAID collection disable functionality
requires Android SDK version 3.5.0 or higher.
The SDK provides options to disable AAID (Google Advertising ID) collection. This is useful for apps targeting children or families to comply with Google Play's Family Policy.
Disabling AAID collection is not recommended unless absolutely necessary. The GAID is a primary signal for Google Ads attribution and install matching, so disabling it reduces attribution accuracy. Only disable it if your app is built for children or families and must comply with Google Play's Family Policy.
### Disable AAID Collection
To disable AAID collection, call `setDisableAaidCollection` before SDK initialization:
```kotlin theme={null}
// Disable AAID collection
LinkRunner.getInstance().setDisableAaidCollection(true)
// Check if AAID collection is disabled
val isDisabled = LinkRunner.getInstance().isAaidCollectionDisabled()
```
When AAID collection is disabled, the SDK will not collect or send the Google Advertising ID (GAID) to Linkrunner servers.
### Removing AD\_ID Permission
If you want to completely remove the `AD_ID` permission from your app's manifest (for example, for apps targeting children), you can override the SDK's permission declaration in your `AndroidManifest.xml`. For detailed instructions on revoking the AD\_ID permission, including Google's policy requirements for apps targeting children and Android 13+ (API 33+), see the [Revoking the AD\_ID Permission](#step-4-revoking-the-ad_id-permission-optional) section above.
## Uninstall Tracking
### Before you begin
Here's what you need to know before getting started:
**Requirements:**
* Android SDK 3.2.1 and later
* [Firebase Cloud Messaging in your Android project](https://firebase.google.com/docs/cloud-messaging/android/client)
### Android
Connect Firebase Cloud Messaging (FCM) with Linkrunner
To configure FCM HTTP V1 for uninstalls:
**Enable the FCM API:**
1. Go to the [FCM console](https://console.firebase.google.com).
2. Select a project.
3. Go to **Project Overview** > **Project settings**.
4. Copy the Project ID. This will be required in a later step.
5. Go to the **Cloud Messaging** tab.
6. Make sure that Firebase Cloud Messaging API (V1) is set to Enabled.
**Create a custom role for Linkrunner Uninstall:**
1. Go to the **Service accounts** tab.
2. Click **Manage service account permissions**.
3. A new browser tab opens in Google Cloud Platform.
4. In the side menu, select **Roles**.
5. Click **+ Create role**.
6. Enter the following details:
* **Title**: Linkrunner Uninstalls
* **ID**: lr\_uninstalls
* **Role launch stage**: General availability
7. Click **+ Add permissions**.
8. In **Enter property name or value** field, enter `cloudmessaging.messages.create` and select it from the search results.
9. Check the **cloudmessaging.messages.create** option and click **Add**.
10. Click **Create**.
**Assign Linkrunner the FCM uninstall role:**
1. In the side menu, select **IAM**.
2. Open the **View by Principals** tab.
3. Click **Grant Access**.
4. In **Add Principals** -> **New principals** field, enter `lr-uninstalls-tracking@lr-uninstalls-tracking.iam.gserviceaccount.com`
5. In **Assign Roles** -> **Select a role** field, enter `Linkrunner Uninstalls` and select it from the search results.
6. Click **Save**.
The Linkrunner service account has been assigned the role of Linkrunner Uninstalls.
1. In Linkrunner, go to **Settings** > **Uninstall Tracking**.
2. Under the **Android** tab, enter the Firebase Project ID that you copied initially and click **Save**.
Follow these instructions to integrate FCM with the Linkrunner SDK:
1. **Set up Firebase Cloud Messaging:**
Set up Firebase Cloud Messaging in your Android app. See the [Firebase Cloud Messaging documentation](https://firebase.google.com/docs/cloud-messaging/android/client) for detailed instructions.
2. Configure your app to provide the device's push token to the Linkrunner SDK.
```kotlin theme={null}
import com.google.firebase.messaging.FirebaseMessaging
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import io.linkrunner.sdk.LinkRunner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MyFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
// Send token to Linkrunner SDK
CoroutineScope(Dispatchers.IO).launch {
LinkRunner.getInstance().setPushToken(token)
}
}
override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
// Check if this is an uninstall tracking notification
if (remoteMessage.data.containsKey("lr-uninstall-tracking")) {
// Silent notification for uninstall tracking, ignore
return
}
// Handle other messages here
}
}
// Initialize token on app start
fun initializePushToken() {
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
CoroutineScope(Dispatchers.IO).launch {
LinkRunner.getInstance().setPushToken(token)
}
}
}
}
```
Don't forget to register your service in `AndroidManifest.xml`:
```xml theme={null}
```
Custom implementations of FCM's `onMessageReceived` method can unintentionally make uninstall push notifications visible to users, disrupting the intended silent experience. To avoid this, ensure your logic checks if the message contains `lr-uninstall-tracking` and handles it accordingly, as shown in the code example above.
## Function Placement Guide
| Function | Where to Place | When to Call |
| --------------------------------------------------- | -------------------------------------- | ---------------------------------------------- |
| `LinkRunner.getInstance().init` | Application class | Once when app starts |
| `LinkRunner.getInstance().getAttributionData` | Attribution data handling flow | Whenever the attribution data is needed |
| `LinkRunner.getInstance().setAdditionalData` | Integration code | When third-party integration IDs are available |
| `LinkRunner.getInstance().signup` | Identification flow (signup or login) | Once when the user is identified |
| `LinkRunner.getInstance().setUserData` | Authentication logic | Every time app opens with logged-in user |
| `LinkRunner.getInstance().trackEvent` | Throughout app | When specific user actions occur |
| `LinkRunner.getInstance().capturePayment` | Payment processing | When user makes a payment |
| `LinkRunner.getInstance().removePayment` | Refund flow | When payment needs to be removed |
| `LinkRunner.getInstance().setPushToken` | Push notification setup | When FCM token is available |
| `LinkRunner.getInstance().handleDeeplink` | Deep link entry points | When app is opened via a deep link |
| `LinkRunner.getInstance().setConsent` | App initialization or consent flow | Before `init`, and again when consent changes |
| `LinkRunner.getInstance().setDisableAaidCollection` | App initialization or privacy settings | When you need to disable AAID collection |
| `LinkRunner.getInstance().isAaidCollectionDisabled` | Privacy settings or compliance checks | When you need to check AAID collection status |
## Complete Example
Here's a simplified example showing how to integrate Linkrunner in a native Android app:
You can find your project token [here](https://dashboard.linkrunner.io/dashboard?s=members\&m=documentation).
```kotlin theme={null}
import android.app.Application
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import io.linkrunner.sdk.LinkRunner
import io.linkrunner.sdk.models.request.UserDataRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize LinkRunner with SDK signing
CoroutineScope(Dispatchers.IO).launch {
try {
LinkRunner.getInstance().init(
context = applicationContext,
token = "YOUR_PROJECT_TOKEN",
secretKey = "YOUR_SECRET_KEY", // Required for SDK signing
keyId = "YOUR_KEY_ID" // Required for SDK signing
)
} catch (e: Exception) {
println("Error initializing LinkRunner: ${e.message}")
}
}
}
}
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// Button to track an event
findViewById(R.id.trackEventButton).setOnClickListener {
trackCustomEvent()
}
}
override fun onResume() {
super.onResume()
// Set user data if user is logged in
if (isUserLoggedIn()) {
setUserData()
}
}
private fun isUserLoggedIn(): Boolean {
// Your login check logic
return true
}
private fun setUserData() {
CoroutineScope(Dispatchers.IO).launch {
try {
val userData = UserDataRequest(
id = "123",
name = "John Doe",
email = "user@example.com"
)
LinkRunner.getInstance().setUserData(userData)
} catch (e: Exception) {
println("Error setting user data: ${e.message}")
}
}
}
private fun trackCustomEvent() {
CoroutineScope(Dispatchers.IO).launch {
try {
LinkRunner.getInstance().trackEvent(
eventName = "button_clicked",
eventData = mapOf("screen" to "main")
)
} catch (e: Exception) {
println("Error tracking event: ${e.message}")
}
}
}
}
```
## Next Steps
Validate your setup end-to-end
Configure deep links for your app
## Support
If you encounter issues during integration, contact us at [support@linkrunner.io](mailto:support@linkrunner.io).
# Capacitor SDK
Source: https://docs.linkrunner.io/sdk/capacitor
Complete guide for integrating Linkrunner in Capacitor apps
**Prefer to let your AI coding agent do this?** Install the Linkrunner skill and Claude Code, Cursor, GitHub Copilot, or Windsurf will wire up the SDK and deep links for you:
```bash theme={null}
npx @linkrunner/skills add capacitor
```
Then ask your agent to "integrate Linkrunner". See [Linkrunner Agent Skills](https://github.com/linkrunner-labs/skills).
## Installation
### Step 1: Install the Package
```bash theme={null}
# Using npm
npm install capacitor-linkrunner
# Using yarn
yarn add capacitor-linkrunner
```
### Step 2: Sync Native Projects
```bash theme={null}
npx cap sync
```
### Step 3: iOS Configuration
If you're developing for iOS, follow these additional steps:
1. Install pods for the package:
```bash theme={null}
cd ios/App && pod install
```
2. Add the following code to your `Info.plist` file:
```xml theme={null}
NSUserTrackingUsageDescription
This identifier will be used to deliver personalized ads and improve your app experience.
```
### Step 4: SKAdNetwork Configuration
To enable SKAdNetwork postback copies to be sent to Linkrunner, add the following keys to your `Info.plist` file:
```xml theme={null}
NSAdvertisingAttributionReportEndpoint
https://linkrunner-skan.com
AttributionCopyEndpoint
https://linkrunner-skan.com
```
For complete SKAdNetwork integration details, see the [SKAdNetwork Integration Guide](/features/skadnetwork-integration).
**Google Integrated Conversion Measurement (ICM)** is not available in the Capacitor plugin yet. `capacitor-linkrunner` 2.1.1 pins LinkrunnerKit 4.0.x and `io.linkrunner:android-sdk:4.0.1`, and ICM needs 4.1.0 on both. If you run Google App Campaigns for your iOS app, contact [support@linkrunner.io](mailto:support@linkrunner.io). See [Google ICM](/features/google-icm) for what it does.
### Step 5: Android Backup Configuration
For Android apps, the SDK provides backup rules to exclude Shared Preferences data from backup. This prevents the retention of the Linkrunner install ID during reinstallation, ensuring accurate detection of new installs and re-installs.
For detailed backup configuration instructions, please refer to the [Android SDK Backup Configuration](/sdk/android#backup-configuration).
### Step 6: Android Kotlin Configuration
The Capacitor Linkrunner SDK requires the Kotlin Gradle plugin. Add the following to your root `build.gradle` file under `dependencies`:
```groovy theme={null}
buildscript {
dependencies {
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
```
For example, with Kotlin version 1.9.22:
```groovy theme={null}
buildscript {
dependencies {
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22"
}
}
```
## Initialization (Required)
To initialize the Linkrunner SDK, add this code to your app's initialization:
You can find your project token [here](https://dashboard.linkrunner.io/dashboard?s=members\&m=documentation).
Note: The initialization method doesn't return any value. To get attribution data and deeplink information, use the `getAttributionData` method.
```typescript theme={null}
import linkrunner from "capacitor-linkrunner";
// Inside your app initialization
const init = async () => {
await linkrunner.init(
"YOUR_PROJECT_TOKEN",
"YOUR_SECRET_KEY", // Optional: Required for SDK signing
"YOUR_KEY_ID", // Optional: Required for SDK signing
false, // Optional: Set to true to disable IDFA collection for iOS devices (defaults to false)
true // Optional: Enable debug mode for development (defaults to false)
);
console.log("Linkrunner initialized");
};
// Call init when app starts
init();
```
## SDK Signing Parameters (Optional)
For enhanced security, the Linkrunner SDK accepts the following signing parameters during initialization:
* **`secretKey`**: A unique secret key used for request signing and authentication
* **`keyId`**: A unique identifier for the key pair used in the signing process
You can find your project token, secret key, and key ID [here](https://dashboard.linkrunner.io/settings?s=sdk-signing).
## Setting the Customer User ID
Use `setCustomerUserId` to attach your own user identifier to the device **right after `init`**. Once set, the identifier is stored securely on-device and automatically included in every event you track, so you never have to pass it on each `trackEvent` call.
Call it as early as the user's ID is available. This guarantees every event carries a `user_id` from the very first event, and is especially useful for existing users who were already onboarded before this feature shipped.
Available from **Capacitor SDK v1.3.0**.
**Best practice:** set the Customer User ID as early as possible. The `user_id` is only attached to events tracked **after** it's set, and is not applied retroactively. Use a stable, unique identifier from your own system (for example your internal user ID or a UUID) rather than an email address or other PII.
```typescript theme={null}
import linkrunner from "capacitor-linkrunner";
try {
await linkrunner.setCustomerUserId("f47ac10b-58cc-4372-a567-0e02b2c3d479"); // Your unique customer user ID (e.g. a UUID)
console.log("Customer user id set");
} catch (error) {
console.error("Error setting customer user id:", error);
}
```
The identifier is stored securely on-device and persists across app restarts. Calling `setCustomerUserId` again with a **different** identifier updates the stored value; passing the same identifier is a no-op. `signup()` / `setUserData()` also update it.
## User Identification (Required)
Call the `signup` method as soon as the user is identified — whether through signup or login. This is the moment Linkrunner ties the install (and any future events) to a user identifier.
It is strongly recommended to use the integrated platform's identify function to set a persistent user\_id once it becomes available (typically after signup or login).
* [Mixpanel - ID Management & User Identification](https://docs.mixpanel.com/docs/tracking-methods/id-management/identifying-users-simplified)
* [PostHog - How User Identification Works](https://posthog.com/docs/product-analytics/identify#how-identify-works)
* [Amplitude - Identify Users Documentation](https://amplitude.com/docs/get-started/identify-users)
If the platform's identifier function is not called, you must provide a user identifier for Mixpanel, PostHog, and Amplitude integration.
* mixpanel\_distinct\_id for Mixpanel
* posthog\_distinct\_id for PostHog
* amplitude\_device\_id for Amplitude
```typescript theme={null}
import linkrunner from "capacitor-linkrunner";
const onSignup = async () => {
try {
await linkrunner.signup({
user_data: {
id: "123", // Required: User ID
name: "John Doe", // Optional
phone: "9876543210", // Optional
email: "user@example.com", // Optional
// These properties are used to track reinstalls
user_created_at: "2024-01-01T00:00:00Z", // Optional
is_first_time_user: true, // Optional
mixpanel_distinct_id: "mixpanel_distinct_id", // Optional - Mixpanel Distinct ID
amplitude_device_id: "amplitude_device_id", // Optional - Amplitude User ID
posthog_distinct_id: "posthog_distinct_id", // Optional - PostHog Distinct ID
},
data: {}, // Optional: Any additional data
});
console.log("Signup successful");
} catch (error) {
console.error("Error during signup:", error);
}
};
```
## Handle Deeplink
To enable [remarketing and reattribution](/features/remarketing), you need to capture deep links and pass them to the Linkrunner SDK. This allows Linkrunner to detect returning users who open the app via a deep link.
```typescript theme={null}
import { App } from '@capacitor/app';
import linkrunner from 'capacitor-linkrunner';
// Init SDK first
await linkrunner.init('your_project_token');
// Cold start — app was launched by a deeplink
const launchUrl = await App.getLaunchUrl();
if (launchUrl?.url) {
linkrunner.handleDeeplink(launchUrl.url);
}
// Warm start — app was in background, deeplink brought it to foreground
App.addListener('appUrlOpen', ({ url }) => {
linkrunner.handleDeeplink(url);
});
```
Linkrunner sends the updated deeplink back after processing. For Linkrunner campaign links, use the returned `deeplink` as the resolved destination instead of the original tracking URL.
```json theme={null}
{
"deeplink": "https://app.yourdomain.com/product/123"
}
```
## Getting Attribution Data
To get attribution data and deeplink information for the current installation, use the `getAttributionData` function:
```typescript theme={null}
import linkrunner from "capacitor-linkrunner";
const getAttributionInfo = async () => {
try {
const attributionData = await linkrunner.getAttributionData();
console.log("Attribution data:", attributionData);
} catch (error) {
console.error("Error getting attribution data:", error);
}
};
```
The `getAttributionData` function returns:
```typescript theme={null}
{
deeplink: string | null;
campaignData: {
id: string;
name: string;
type: string; // "ORGANIC" | "INORGANIC"
adNetwork: string | null; // "META" | "GOOGLE" | null
installedAt: string;
storeClickAt: string | null;
groupName: string;
assetName: string;
assetGroupName: string;
adNetworkCampaignId?: string; // Ad network campaign ID
adSetId?: string; // Ad set ID
adSetName?: string; // Ad set name
adCreativeId?: string; // Ad creative ID
adCreativeName?: string; // Ad creative name
}
}
```
**Example response:**
```json theme={null}
{
"deeplink": "https://app.yourdomain.com/product/123",
"campaignData": {
"id": "camp_123",
"name": "Summer Sale 2024",
"type": "INORGANIC",
"adNetwork": "META",
"installedAt": "2024-03-20T10:30:00Z",
"storeClickAt": "2024-03-20T10:29:45Z",
"groupName": "iOS Campaign",
"assetName": "Banner Ad 1",
"assetGroupName": "Product Catalog",
"adNetworkCampaignId": "120214682829390250",
"adSetId": "120214682829640250",
"adSetName": "Productivity",
"adCreativeId": "120214682926100250",
"adCreativeName": "Static_2"
}
}
```
## Setting User Data
Call `setUserData` each time the app opens and the user is logged in:
**`setUserData` is optional and is not a replacement for `signup`.** Always call `signup` first as soon as the user is identified (signup or login). Use `setUserData` afterwards only when additional user details become available later — for example, when the user adds a phone number, email, or completes their profile after identification.
```typescript theme={null}
import linkrunner from "capacitor-linkrunner";
const setUserData = async () => {
await linkrunner.setUserData({
id: "123", // Required: User ID
name: "John Doe", // Optional
phone: "9876543210", // Optional
email: "user@example.com", // Optional
mixpanel_distinct_id: "mixpanel_distinct_id", // Optional - Mixpanel Distinct ID
amplitude_device_id: "amplitude_device_id", // Optional - Amplitude User ID
posthog_distinct_id: "posthog_distinct_id", // Optional - PostHog Distinct ID
});
};
```
## Setting CleverTap ID
Use the `setAdditionalData` method to set CleverTap ID:
```typescript theme={null}
import linkrunner from "capacitor-linkrunner";
const setIntegrationData = async () => {
await linkrunner.setAdditionalData({
clevertapId: "YOUR_CLEVERTAP_USER_ID", // CleverTap user identifier
});
};
```
### Parameters for `linkrunner.setAdditionalData`
* `clevertapId`: string (optional) - CleverTap user identifier
This method allows you to connect user identities across different analytics and marketing platforms.
## Revenue Tracking
Revenue is stored for all users, including organic ones. Payments from users with no matching click are stored without campaign attribution. Call [`.signup`](#user-registration) so payments are linked to a user. You can verify your events are being captured on the [Events](https://dashboard.linkrunner.io/dashboard/events) page.
### Capture Payment
Use this method to capture payment information:
```typescript theme={null}
import linkrunner from "capacitor-linkrunner";
const capturePayment = async () => {
await linkrunner.capturePayment({
amount: 100, // Payment amount
userId: "user123", // User identifier
paymentId: "payment456", // Required: Unique payment identifier
type: "FIRST_PAYMENT", // Optional: Payment type
// type: "SECOND_PAYMENT", // Optional: Payment type
status: "PAYMENT_COMPLETED", // Optional: Payment status
});
};
```
#### Parameters for `linkrunner.capturePayment`
* `amount`: number (required) - The payment amount
* `userId`: string (required) - Identifier for the user making the payment
* `paymentId`: string (required) - Unique identifier for the payment, used to deduplicate transactions
* `type`: string (optional) - Type of payment. Available options:
* `FIRST_PAYMENT` - First payment made by the user
* `WALLET_TOPUP` - Adding funds to a wallet
* `FUNDS_WITHDRAWAL` - Withdrawing funds
* `SUBSCRIPTION_CREATED` - New subscription created
* `SUBSCRIPTION_RENEWED` - Subscription renewal
* `ONE_TIME` - One-time payment
* `RECURRING` - Recurring payment
* `DEFAULT` - Default type (used if not specified)
* `status`: string (optional) - Status of the payment. Available options:
* `PAYMENT_INITIATED` - Payment has been initiated
* `PAYMENT_COMPLETED` - Payment completed successfully (default if not specified)
* `PAYMENT_FAILED` - Payment attempt failed
* `PAYMENT_CANCELLED` - Payment was cancelled
### Removing Payments
Remove payment records (for refunds or cancellations):
```typescript theme={null}
import linkrunner from "capacitor-linkrunner";
const removePayment = async () => {
await linkrunner.removePayment({
userId: "user123", // User identifier
paymentId: "payment456", // Optional: Unique payment identifier
});
};
```
#### Parameters for `linkrunner.removePayment`
* `userId`: string (optional) - Identifier for the user whose payment is being removed. If only `userId` is provided, all payments for that user will be removed.
* `paymentId`: string (optional) - Unique identifier for the payment to be removed
Note: At least one of `paymentId` or `userId` must be provided when calling `removePayment`.
## Tracking Custom Events
From **Capacitor SDK v1.3.0**, custom events automatically include the `user_id` you set during `signup()` / `setUserData()`. The SDK stores this identifier securely on-device and attaches it to every `trackEvent` call, so you no longer need to pass it manually. Events tracked before signup are sent without a `user_id`.
Events are stored for all users, including organic ones. Events from users with no matching click are stored without campaign attribution. Call [`.signup`](#user-registration) so events are linked to a user. You can verify your events are being captured on the [Events](https://dashboard.linkrunner.io/dashboard/events) page. For capturing revenue, it is recommended to use the [`.capturePayment`](#revenue-tracking) method instead of `.trackEvent`.
Track custom events in your app:
```typescript theme={null}
import linkrunner from "capacitor-linkrunner";
const trackEvent = async () => {
await linkrunner.trackEvent(
"purchase_initiated", // Event name
{ product_id: "12345", category: "electronics", amount: 99.99 }, // Optional: Event data, include amount as a number for revenue sharing with ad networks like Google and Meta
"order_12345" // Optional: Your own unique event identifier (string or number)
);
};
```
#### Parameters for `linkrunner.trackEvent`
* `eventName`: string (required) - Name of the event to track
* `eventData`: object (optional) - Key-value pairs for additional event data, including Meta ecommerce properties
* `eventId`: string | number (optional) - Your own unique identifier for the event, useful for deduplication and correlating with your backend
### Revenue Sharing with Ad Networks
To enable revenue sharing with ad networks like Google Ads and Meta, include an `amount` parameter as a number in your custom event data. This allows the ad networks to optimize campaigns based on the revenue value of conversions:
```typescript theme={null}
const trackPurchaseEvent = async () => {
await linkrunner.trackEvent("purchase_completed", {
product_id: "12345",
category: "electronics",
amount: 149.99, // Revenue amount as a number
});
};
```
For revenue sharing with ad networks to work properly, ensure the `amount` parameter is passed as a number, not as a
string.
## Enhanced Privacy Controls
The SDK offers options to enhance user privacy:
```typescript theme={null}
import linkrunner from "capacitor-linkrunner";
// Enable PII (Personally Identifiable Information) hashing
linkrunner.enablePIIHashing(true);
```
When PII hashing is enabled, sensitive user data like name, email, and phone number are hashed using SHA-256 before being sent to Linkrunner servers.
## Uninstall Tracking
### Before you begin
Here's what you need to know before getting started:
**Requirements:**
* Capacitor SDK 1.0.1 and later
* [Firebase in your Capacitor project (Android)](https://capacitorjs.com/docs/guides/push-notifications-firebase)
* [Registering your app with APNs (iOS)](https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns)
### Android
Connect Firebase Cloud Messaging (FCM) with Linkrunner
To configure FCM HTTP V1 for uninstalls:
**Enable the FCM API:**
1. Go to the [FCM console](https://console.firebase.google.com).
2. Select a project.
3. Go to **Project Overview** > **Project settings**.
4. Copy the Project ID. This will be required in a later step.
5. Go to the **Cloud Messaging** tab.
6. Make sure that Firebase Cloud Messaging API (V1) is set to Enabled.
**Create a custom role for Linkrunner Uninstall:**
1. Go to the **Service accounts** tab.
2. Click **Manage service account permissions**.
3. A new browser tab opens in Google Cloud Platform.
4. In the side menu, select **Roles**.
5. Click **+ Create role**.
6. Enter the following details:
* **Title**: Linkrunner Uninstalls
* **ID**: lr\_uninstalls
* **Role launch stage**: General availability
7. Click **+ Add permissions**.
8. In **Enter property name or value** field, enter `cloudmessaging.messages.create` and select it from the search results.
9. Check the **cloudmessaging.messages.create** option and click **Add**.
10. Click **Create**.
**Assign Linkrunner the FCM uninstall role:**
1. In the side menu, select **IAM**.
2. Open the **View by Principals** tab.
3. Click **Grant Access**.
4. In **Add Principals** -> **New principals** field, enter `lr-uninstalls-tracking@lr-uninstalls-tracking.iam.gserviceaccount.com`
5. In **Assign Roles** -> **Select a role** field, enter `Linkrunner Uninstalls` and select it from the search results.
6. Click **Save**.
The Linkrunner service account has been assigned the role of Linkrunner Uninstalls.
1. In Linkrunner, go to **Settings** > **Uninstall Tracking**.
2. Under the **Android** tab, enter the Firebase Project ID that you copied initially and click **Save**.
Follow these instructions to integrate FCM with the Linkrunner SDK:
1. **Set up Push Notifications:**
Set up push notifications in your Capacitor app. See the [Capacitor Push Notifications documentation](https://capacitorjs.com/docs/guides/push-notifications-firebase) for detailed instructions.
2. Configure your app to provide the device's push token to the Linkrunner SDK.
```typescript theme={null}
import { PushNotifications } from '@capacitor/push-notifications';
import linkrunner from 'capacitor-linkrunner';
// Request permission and get token
const initializePushNotifications = async () => {
const permStatus = await PushNotifications.requestPermissions();
if (permStatus.receive === 'granted') {
await PushNotifications.register();
}
};
// Listen for registration
PushNotifications.addListener('registration', async (token) => {
await linkrunner.setPushToken(token.value);
});
// Handle incoming push notifications
PushNotifications.addListener('pushNotificationReceived', (notification) => {
if (notification.data && notification.data['lr-uninstall-tracking']) {
// Silent notification for uninstall tracking, ignore
return;
}
// Handle other notifications here
});
```
Custom implementations of push notification handlers can unintentionally make uninstall push notifications visible to users, disrupting the intended silent experience. To avoid this, ensure your logic checks if the notification contains `lr-uninstall-tracking` and handles it accordingly, as shown in the code example above.
### iOS
Connect APNs with Linkrunner
Get the required credentials from the Apple Developer Portal:
**APNs Authentication Key (p8) and Key ID:**
* Go to the [Apple Developer Portal](https://developer.apple.com/account).
* Select **Identifiers** under **Certificates, IDs & Profiles**.
* Click on the app you want to track uninstalls for. Then, under **Capabilities**, search for **Push Notifications** and enable it.
* Under **Certificates, IDs & Profiles**, select **Keys** and click on plus (+) icon to create a key. Enable APNs when creating the key and download the key file (p8).
* The Key ID can be found in the **Keys** tab.
**Bundle ID and Team ID:**
* Under **Identifiers**, click on your app and you will see the Bundle ID and Team ID (App ID Prefix).
1. In Linkrunner, go to **Settings** > **Uninstall Tracking**.
2. Under the **iOS** tab, upload the APNs Authentication Key (p8) file and enter the Key ID, Bundle ID and Team ID (App ID Prefix) that you copied from the Apple Developer Portal.
Follow these instructions to integrate APNs with the Linkrunner SDK:
1. **Set up Push Notifications:**
Set up push notifications in your Capacitor app if you haven't already. See the [Capacitor Push Notifications documentation](https://capacitorjs.com/docs/guides/push-notifications-firebase) for detailed instructions.
2. Configure your app to provide the device's APNs token to the Linkrunner SDK.
For iOS, the token received from the `registration` event is the APNs token when running on iOS devices:
```typescript theme={null}
import { PushNotifications } from '@capacitor/push-notifications';
import { Capacitor } from '@capacitor/core';
import linkrunner from 'capacitor-linkrunner';
// Listen for registration - on iOS this provides the APNs token
PushNotifications.addListener('registration', async (token) => {
if (Capacitor.getPlatform() === 'ios') {
await linkrunner.setPushToken(token.value);
}
});
```
## Function Placement Guide
| Function | Where to Place | When to Call |
| ------------------------------- | ------------------------------------- | ---------------------------------------------- |
| `linkrunner.init` | App initialization | Once when app starts |
| `linkrunner.getAttributionData` | Attribution data handling flow | Whenever the attribution data is needed |
| `linkrunner.setAdditionalData` | Integration code | When third-party integration IDs are available |
| `linkrunner.signup` | Identification flow (signup or login) | Once when the user is identified |
| `linkrunner.setUserData` | Authentication logic | Every time app opens with logged-in user |
| `linkrunner.trackEvent` | Throughout app | When specific user actions occur |
| `linkrunner.capturePayment` | Payment processing | When user makes a payment |
| `linkrunner.removePayment` | Refund flow | When payment needs to be removed |
| `linkrunner.setPushToken` | Push notification setup | When push token is available |
| `linkrunner.handleDeeplink` | Deep link entry points | When app is opened via a deep link |
## Next Steps
Validate your setup end-to-end
Configure deep links for your app
## Support
If you encounter issues during integration, contact us at [support@linkrunner.io](mailto:support@linkrunner.io).
# Cordova SDK
Source: https://docs.linkrunner.io/sdk/cordova
Complete guide for integrating Linkrunner in Apache Cordova apps
**Prefer to let your AI coding agent do this?** Install the Linkrunner skill and Claude Code, Cursor, GitHub Copilot, or Windsurf will wire up the SDK and deep links for you:
```bash theme={null}
npx @linkrunner/skills add cordova
```
Then ask your agent to "integrate Linkrunner". See [Linkrunner Agent Skills](https://github.com/linkrunner-labs/skills).
## Installation
### Step 1: Add Prerequisites to config.xml
Before adding platforms or installing the plugin, add the following preferences to your app's `config.xml`:
```xml theme={null}
```
These preferences **must** be set before running `cordova platform add ios` or `cordova platform add android`. If you've already added platforms, remove and re-add them after updating `config.xml`:
```bash theme={null}
cordova platform remove ios android
cordova platform add ios android
```
### Step 2: Install the Plugin
```bash theme={null}
cordova plugin add cordova-linkrunner
```
This single command handles everything automatically:
* Downloads the package from npm
* Copies native Swift/Kotlin bridge code into your project
* Adds iOS pod dependency (LinkrunnerKit)
* Adds Android gradle dependency (Linkrunner android-sdk)
* Injects iOS Info.plist entries (SKAN endpoints, tracking description)
* Registers the JS module globally as `window.linkrunner`
### What the plugin auto-configures
The `cordova plugin add` command automatically configures the following native settings via `plugin.xml`. No manual editing of `Info.plist` or `build.gradle` is required.
**iOS (auto-injected into Info.plist):**
```xml theme={null}
NSUserTrackingUsageDescription
This identifier will be used to deliver personalized ads to you.
NSAdvertisingAttributionReportEndpoint
https://linkrunner-skan.com
AttributionCopyEndpoint
https://linkrunner-skan.com
```
* LinkrunnerKit pod dependency (iOS 15+)
**Android (auto-injected into build.gradle and AndroidManifest.xml):**
* Linkrunner SDK gradle dependency
* Kotlin stdlib and coroutines dependencies
* `android.permission.INTERNET` and `android.permission.ACCESS_NETWORK_STATE` permissions
* Backup rules exclusion for SharedPreferences
For complete SKAdNetwork integration details, see the [SKAdNetwork Integration Guide](/features/skadnetwork-integration).
For detailed Android backup configuration instructions, refer to the [Android SDK Backup Configuration](/sdk/android#backup-configuration).
**Google Integrated Conversion Measurement (ICM)** is not available in the Cordova plugin yet. `cordova-linkrunner` 1.2.0 pins LinkrunnerKit 4.0.x and `io.linkrunner:android-sdk:4.0.1`, and ICM needs 4.1.0 on both. If you run Google App Campaigns for your iOS app, contact [support@linkrunner.io](mailto:support@linkrunner.io). See [Google ICM](/features/google-icm) for what it does.
## Initialization (Required)
To initialize the Linkrunner SDK, add this code inside the `deviceready` event. No import or require is needed — `linkrunner` is globally available after plugin installation.
You can find your project token [here](https://dashboard.linkrunner.io/dashboard/settings/project-details).
Note: The initialization method doesn't return any value. To get attribution data and deeplink information, use the `getAttributionData` method.
```javascript theme={null}
document.addEventListener('deviceready', function () {
linkrunner.init({
token: "YOUR_PROJECT_TOKEN",
secretKey: "YOUR_SECRET_KEY", // Optional: Required for SDK signing
keyId: "YOUR_KEY_ID", // Optional: Required for SDK signing
disableIdfa: false, // Optional: Set to true to disable IDFA collection for iOS (defaults to false)
debug: true // Optional: Enable debug mode for development (defaults to false)
}).then(function () {
console.log("Linkrunner initialized");
}).catch(function (error) {
console.error("Linkrunner init failed:", error);
});
}, false);
```
## SDK Signing Parameters (Optional)
For enhanced security, the Linkrunner SDK accepts the following signing parameters during initialization:
* **`secretKey`**: A unique secret key used for request signing and authentication
* **`keyId`**: A unique identifier for the key pair used in the signing process
You can find your project token, secret key, and key ID [here](https://dashboard.linkrunner.io/settings?s=sdk-signing).
## Setting the Customer User ID
Use `setCustomerUserId` to attach your own user identifier to the device **right after `init`**. Once set, the identifier is stored securely on-device and automatically included in every event you track, so you never have to pass it on each `trackEvent` call.
Call it as early as the user's ID is available. This guarantees every event carries a `user_id` from the very first event, and is especially useful for existing users who were already onboarded before this feature shipped.
Available from **Cordova SDK v1.1.0**.
**Best practice:** set the Customer User ID as early as possible. The `user_id` is only attached to events tracked **after** it's set, and is not applied retroactively. Use a stable, unique identifier from your own system (for example your internal user ID or a UUID) rather than an email address or other PII.
```javascript theme={null}
linkrunner
.setCustomerUserId("f47ac10b-58cc-4372-a567-0e02b2c3d479") // Your unique customer user ID (e.g. a UUID)
.then(function () {
console.log("Customer user id set");
})
.catch(function (error) {
console.error("Error setting customer user id:", error);
});
```
The identifier is stored securely on-device and persists across app restarts. Calling `setCustomerUserId` again with a **different** identifier updates the stored value; passing the same identifier is a no-op. `signup()` / `setUserData()` also update it.
## User Identification (Required)
Call the `signup` method as soon as the user is identified — whether through signup or login. This is the moment Linkrunner ties the install (and any future events) to a user identifier.
It is strongly recommended to use the integrated platform's identify function to set a persistent user\_id once it becomes available (typically after signup or login).
* [Mixpanel - ID Management & User Identification](https://docs.mixpanel.com/docs/tracking-methods/id-management/identifying-users-simplified)
* [PostHog - How User Identification Works](https://posthog.com/docs/product-analytics/identify#how-identify-works)
* [Amplitude - Identify Users Documentation](https://amplitude.com/docs/get-started/identify-users)
If the platform's identifier function is not called, you must provide a user identifier for Mixpanel, PostHog, and Amplitude integration.
* mixpanel\_distinct\_id for Mixpanel
* posthog\_distinct\_id for PostHog
* amplitude\_device\_id for Amplitude
```javascript theme={null}
linkrunner.signup({
user_data: {
id: "123", // Required: User ID
name: "John Doe", // Optional
phone: "9876543210", // Optional
email: "user@example.com", // Optional
// These properties are used to track reinstalls
user_created_at: "2024-01-01T00:00:00Z", // Optional
is_first_time_user: true, // Optional
mixpanel_distinct_id: "mixpanel_distinct_id", // Optional - Mixpanel Distinct ID
amplitude_device_id: "amplitude_device_id", // Optional - Amplitude User ID
posthog_distinct_id: "posthog_distinct_id", // Optional - PostHog Distinct ID
braze_device_id: "braze_device_id", // Optional - Braze Device ID
ga_app_instance_id: "ga_app_instance_id", // Optional - Google Analytics App Instance ID
ga_session_id: "ga_session_id", // Optional - Google Analytics Session ID
netcore_device_guid: "netcore_device_guid" // Optional - Netcore Device GUID
},
data: {} // Optional: Any additional data
}).then(function () {
console.log("Signup successful");
}).catch(function (error) {
console.error("Error during signup:", error);
});
```
## Handle Deeplink
To enable [remarketing and reattribution](/features/remarketing), you need to capture deep links and pass them to the Linkrunner SDK. This allows Linkrunner to detect returning users who open the app via a deep link.
### Cold Start
Cold start deeplinks are automatically captured by the native SDK during `init()`. The deeplink URL is available via `getAttributionData()`.
### Warm Start
For warm start deeplinks (app is already running), use Cordova's `handleOpenURL`:
```javascript theme={null}
// Initialize SDK first
document.addEventListener('deviceready', function () {
linkrunner.init({ token: "YOUR_PROJECT_TOKEN" });
}, false);
// Handle warm start deeplinks
window.handleOpenURL = function (url) {
linkrunner.handleDeeplink(url).then(function (data) {
console.log("Deeplink data:", JSON.stringify(data));
if (data && data.deeplink) {
var deeplink = data.deeplink;
console.log("Navigate to:", deeplink);
}
}).catch(function (error) {
console.error("Deeplink handling failed:", error);
});
};
```
Linkrunner sends the updated deeplink back after processing. For Linkrunner campaign links, use the returned `deeplink` as the resolved destination instead of the original tracking URL.
```json theme={null}
{
"deeplink": "https://app.yourdomain.com/product/123"
}
```
## Getting Attribution Data
To get attribution data and deeplink information for the current installation, use the `getAttributionData` function:
```javascript theme={null}
linkrunner.getAttributionData().then(function (attributionData) {
console.log("Attribution data:", JSON.stringify(attributionData));
}).catch(function (error) {
console.error("Error getting attribution data:", error);
});
```
The `getAttributionData` function returns:
```javascript theme={null}
{
data: {
deeplink: "https://..." | null,
campaignData: {
id: "string",
name: "string",
type: "string", // "ORGANIC" | "INORGANIC"
adNetwork: "string" | null, // "META" | "GOOGLE" | "APPLE_SEARCH_ADS" | "TIKTOK" | "SNAPCHAT" | null
installedAt: "string",
storeClickAt: "string" | null,
groupName: "string",
assetName: "string",
assetGroupName: "string",
adNetworkCampaignId: "string", // Ad network campaign ID
adSetId: "string", // Ad set ID
adSetName: "string", // Ad set name
adCreativeId: "string", // Ad creative ID
adCreativeName: "string" // Ad creative name
}
}
}
```
**Example response:**
```json theme={null}
{
"data": {
"deeplink": "https://app.yourdomain.com/product/123",
"campaignData": {
"id": "camp_123",
"name": "Summer Sale 2024",
"type": "INORGANIC",
"adNetwork": "META",
"installedAt": "2024-03-20T10:30:00Z",
"storeClickAt": "2024-03-20T10:29:45Z",
"groupName": "Cordova Campaign",
"assetName": "Banner Ad 1",
"assetGroupName": "Product Catalog",
"adNetworkCampaignId": "120214682829390250",
"adSetId": "120214682829640250",
"adSetName": "Productivity",
"adCreativeId": "120214682926100250",
"adCreativeName": "Static_2"
}
}
}
```
## Setting User Data
Call `setUserData` each time the app opens and the user is logged in:
**`setUserData` is optional and is not a replacement for `signup`.** Always call `signup` first as soon as the user is identified (signup or login). Use `setUserData` afterwards only when additional user details become available later — for example, when the user adds a phone number, email, or completes their profile after identification.
```javascript theme={null}
linkrunner.setUserData({
id: "123", // Required: User ID
name: "John Doe", // Optional
phone: "9876543210", // Optional
email: "user@example.com", // Optional
mixpanel_distinct_id: "mixpanel_distinct_id", // Optional - Mixpanel Distinct ID
amplitude_device_id: "amplitude_device_id", // Optional - Amplitude User ID
posthog_distinct_id: "posthog_distinct_id" // Optional - PostHog Distinct ID
}).then(function () {
console.log("User data set successfully");
});
```
## Setting CleverTap ID
Use the `setAdditionalData` method to set CleverTap ID:
```javascript theme={null}
linkrunner.setAdditionalData({
clevertapId: "YOUR_CLEVERTAP_USER_ID" // CleverTap user identifier
}).then(function () {
console.log("Additional data set successfully");
});
```
### Parameters for `linkrunner.setAdditionalData`
* `clevertapId`: string (optional) - CleverTap user identifier
This method allows you to connect user identities across different analytics and marketing platforms.
## Revenue Tracking
Revenue is stored for all users, including organic ones. Payments from users with no matching click are stored without campaign attribution. Call [`.signup`](#user-registration) so payments are linked to a user. You can verify your events are being captured on the [Events](https://dashboard.linkrunner.io/dashboard/events) page.
### Capture Payment
Use this method to capture payment information:
```javascript theme={null}
linkrunner.capturePayment({
amount: 100, // Payment amount
userId: "user123", // User identifier
paymentId: "payment456", // Required: Unique payment identifier
type: "FIRST_PAYMENT", // Optional: Payment type
status: "PAYMENT_COMPLETED", // Optional: Payment status
eventData: { // Optional: Ecommerce/custom event data
content_ids: ["product_123"],
content_type: "product",
currency: "USD",
value: 99.99,
num_items: 1,
order_id: "order_12345",
contents: [
{
id: "product_123",
quantity: 1,
item_price: 99.99
}
]
}
}).then(function () {
console.log("Payment captured");
});
```
#### Parameters for `linkrunner.capturePayment`
* `amount`: number (required) - The payment amount
* `userId`: string (required) - Identifier for the user making the payment
* `paymentId`: string (required) - Unique identifier for the payment, used to deduplicate transactions
* `type`: string (optional) - Type of payment. Available options:
* `FIRST_PAYMENT` - First payment made by the user
* `SECOND_PAYMENT` - Second payment made by the user
* `WALLET_TOPUP` - Adding funds to a wallet
* `FUNDS_WITHDRAWAL` - Withdrawing funds
* `SUBSCRIPTION_CREATED` - New subscription created
* `SUBSCRIPTION_RENEWED` - Subscription renewal
* `ONE_TIME` - One-time payment
* `RECURRING` - Recurring payment
* `DEFAULT` - Default type (used if not specified)
* `status`: string (optional) - Status of the payment. Available options:
* `PAYMENT_INITIATED` - Payment has been initiated
* `PAYMENT_COMPLETED` - Payment completed successfully (default if not specified)
* `PAYMENT_FAILED` - Payment attempt failed
* `PAYMENT_CANCELLED` - Payment was cancelled
* `eventData`: object (optional) - Key-value pairs for additional event data, including Meta ecommerce properties.
### Removing Payments
Remove payment records (for refunds or cancellations):
```javascript theme={null}
linkrunner.removePayment({
userId: "user123", // User identifier
paymentId: "payment456" // Optional: Unique payment identifier
}).then(function () {
console.log("Payment removed");
});
```
#### Parameters for `linkrunner.removePayment`
* `userId`: string (optional) - Identifier for the user whose payment is being removed. If only `userId` is provided, all payments for that user will be removed.
* `paymentId`: string (optional) - Unique identifier for the payment to be removed
Note: At least one of `paymentId` or `userId` must be provided when calling `removePayment`.
## Ecommerce Events
If you are tracking Ecommerce events to sync with Meta Catalog Sales, you must format your `eventData` to include Meta's required fields. **You also need to map your custom event to the standard commerce event in the Linkrunner Dashboard.**
For detailed explanations of the required fields like `content_ids`, `contents`, and `value`, refer to our [Meta Commerce Manager documentation](/ecommerce-manager/meta-commerce-manager#understanding-event_data).
### Add To Cart Example
Use the `trackEvent` method to send an `AddToCart` event:
```javascript theme={null}
linkrunner.trackEvent(
"add_to_cart", // Map this custom event to "AddToCart" in the Linkrunner Dashboard
{
content_ids: ["product_123"],
contents: [
{
id: "product_123",
quantity: 1,
item_price: 49.99
}
],
content_type: "product",
currency: "USD",
value: 49.99,
num_items: 1
}
).then(function () {
console.log("Add To Cart event tracked successfully");
}).catch(function (error) {
console.error("Error tracking Add To Cart event:", error);
});
```
### View Content Example
Use the `trackEvent` method to send a `ViewContent` event:
```javascript theme={null}
linkrunner.trackEvent(
"view_item", // Map this custom event to "ViewContent" in the Linkrunner Dashboard
{
content_ids: ["product_123"],
contents: [
{
id: "product_123",
quantity: 1,
item_price: 49.99
}
],
content_type: "product",
currency: "USD",
value: 49.99,
num_items: 1
}
).then(function () {
console.log("View Content event tracked successfully");
}).catch(function (error) {
console.error("Error tracking View Content event:", error);
});
```
### Payment / Purchase Example
Use the `capturePayment` method to send a `Purchase` event containing the ecommerce payload:
```javascript theme={null}
linkrunner.capturePayment({
amount: 49.99,
userId: "user123",
paymentId: "payment_456",
type: "FIRST_PAYMENT", // Map this payment type to "Purchase" in the Linkrunner Dashboard
status: "PAYMENT_COMPLETED",
eventData: {
content_ids: ["product_123"],
contents: [
{
id: "product_123",
quantity: 1,
item_price: 49.99
}
],
content_type: "product",
currency: "USD",
value: 49.99,
num_items: 1,
order_id: "order_abc123" // Required for Purchase events
}
}).then(function () {
console.log("Purchase captured successfully");
}).catch(function (error) {
console.error("Error capturing purchase:", error);
});
```
> **Note:** For more information on testing and verifying your ecommerce events, please see our [Testing Ecommerce Events](/ecommerce-manager/meta-commerce-manager#testing-ecommerce-events) guide.
## Tracking Custom Events
From **Cordova SDK v1.1.0**, custom events automatically include the `user_id` you set during `signup()` / `setUserData()`. The SDK stores this identifier securely on-device and attaches it to every `trackEvent` call, so you no longer need to pass it manually. Events tracked before signup are sent without a `user_id`.
Events are stored for all users, including organic ones. Events from users with no matching click are stored without campaign attribution. Call [`.signup`](#user-registration) so events are linked to a user. You can verify your events are being captured on the [Events](https://dashboard.linkrunner.io/dashboard/events) page. For capturing revenue, it is recommended to use the [`.capturePayment`](#revenue-tracking) method instead of `.trackEvent`.
Track custom events in your app:
```javascript theme={null}
linkrunner.trackEvent(
"purchase_initiated", // Event name
{ product_id: "12345", category: "electronics", amount: 99.99 }, // Optional: Event data, include amount as a number for revenue sharing with ad networks like Google and Meta
"order_12345" // Optional: Your own unique event identifier (string or number)
).then(function () {
console.log("Event tracked");
});
```
#### Parameters for `linkrunner.trackEvent`
* `eventName`: string (required) - Name of the event to track
* `eventData`: object (optional) - Key-value pairs for additional event data, including Meta ecommerce properties
* `eventId`: string | number (optional) - Your own unique identifier for the event, useful for deduplication and correlating with your backend
### Revenue Sharing with Ad Networks
To enable revenue sharing with ad networks like Google Ads and Meta, include an `amount` parameter as a number in your custom event data. This allows the ad networks to optimize campaigns based on the revenue value of conversions:
```javascript theme={null}
linkrunner.trackEvent("purchase_completed", {
product_id: "12345",
category: "electronics",
amount: 149.99 // Revenue amount as a number
}).then(function () {
console.log("Purchase event tracked");
});
```
For revenue sharing with ad networks to work properly, ensure the `amount` parameter is passed as a number, not as a
string.
## Enhanced Privacy Controls
The SDK offers options to enhance user privacy:
```javascript theme={null}
// Enable PII (Personally Identifiable Information) hashing
linkrunner.enablePIIHashing(true);
```
When PII hashing is enabled, sensitive user data like name, email, and phone number are hashed using SHA-256 before being sent to Linkrunner servers.
## Uninstall Tracking
### Before you begin
Here's what you need to know before getting started:
**Requirements:**
* A Cordova push notification plugin (e.g., `cordova-plugin-push`)
* [Firebase in your Cordova project (Android)](https://firebase.google.com/docs/cloud-messaging)
* [Registering your app with APNs (iOS)](https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns)
### Android
Connect Firebase Cloud Messaging (FCM) with Linkrunner
To configure FCM HTTP V1 for uninstalls:
**Enable the FCM API:**
1. Go to the [FCM console](https://console.firebase.google.com).
2. Select a project.
3. Go to **Project Overview** > **Project settings**.
4. Copy the Project ID. This will be required in a later step.
5. Go to the **Cloud Messaging** tab.
6. Make sure that Firebase Cloud Messaging API (V1) is set to Enabled.
**Create a custom role for Linkrunner Uninstall:**
1. Go to the **Service accounts** tab.
2. Click **Manage service account permissions**.
3. A new browser tab opens in Google Cloud Platform.
4. In the side menu, select **Roles**.
5. Click **+ Create role**.
6. Enter the following details:
* **Title**: Linkrunner Uninstalls
* **ID**: lr\_uninstalls
* **Role launch stage**: General availability
7. Click **+ Add permissions**.
8. In **Enter property name or value** field, enter `cloudmessaging.messages.create` and select it from the search results.
9. Check the **cloudmessaging.messages.create** option and click **Add**.
10. Click **Create**.
**Assign Linkrunner the FCM uninstall role:**
1. In the side menu, select **IAM**.
2. Open the **View by Principals** tab.
3. Click **Grant Access**.
4. In **Add Principals** -> **New principals** field, enter `lr-uninstalls-tracking@lr-uninstalls-tracking.iam.gserviceaccount.com`
5. In **Assign Roles** -> **Select a role** field, enter `Linkrunner Uninstalls` and select it from the search results.
6. Click **Save**.
The Linkrunner service account has been assigned the role of Linkrunner Uninstalls.
1. In Linkrunner, go to **Settings** > **Uninstall Tracking**.
2. Under the **Android** tab, enter the Firebase Project ID that you copied initially and click **Save**.
Follow these instructions to integrate FCM with the Linkrunner SDK:
1. **Set up Push Notifications:**
Install a push notification plugin for Cordova. For example, using `cordova-plugin-push`:
```bash theme={null}
cordova plugin add cordova-plugin-push
```
2. Configure your app to provide the device's push token to the Linkrunner SDK.
```javascript theme={null}
var push = PushNotification.init({
android: {},
ios: {
alert: true,
badge: true,
sound: true
}
});
// Send push token to Linkrunner
push.on('registration', function (data) {
linkrunner.setPushToken(data.registrationId).then(function () {
console.log("Push token set successfully");
});
});
// Filter out Linkrunner silent notifications
push.on('notification', function (data) {
if (data.additionalData && data.additionalData['lr-uninstall-tracking']) {
// Silent notification for uninstall tracking, ignore
return;
}
// Handle other notifications here
});
```
Custom implementations of push notification handlers can unintentionally make uninstall push notifications visible to users, disrupting the intended silent experience. To avoid this, ensure your logic checks if the notification contains `lr-uninstall-tracking` and handles it accordingly, as shown in the code example above.
### iOS
Connect APNs with Linkrunner
Get the required credentials from the Apple Developer Portal:
**APNs Authentication Key (p8) and Key ID:**
* Go to the [Apple Developer Portal](https://developer.apple.com/account).
* Select **Identifiers** under **Certificates, IDs & Profiles**.
* Click on the app you want to track uninstalls for. Then, under **Capabilities**, search for **Push Notifications** and enable it.
* Under **Certificates, IDs & Profiles**, select **Keys** and click on plus (+) icon to create a key. Enable APNs when creating the key and download the key file (p8).
* The Key ID can be found in the **Keys** tab.
**Bundle ID and Team ID:**
* Under **Identifiers**, click on your app and you will see the Bundle ID and Team ID (App ID Prefix).
1. In Linkrunner, go to **Settings** > **Uninstall Tracking**.
2. Under the **iOS** tab, upload the APNs Authentication Key (p8) file and enter the Key ID, Bundle ID and Team ID (App ID Prefix) that you copied from the Apple Developer Portal.
Follow these instructions to integrate APNs with the Linkrunner SDK:
1. **Set up Push Notifications:**
Set up push notifications in your Cordova app using a push notification plugin (e.g., `cordova-plugin-push`).
2. Configure your app to provide the device's APNs token to the Linkrunner SDK.
```javascript theme={null}
var push = PushNotification.init({
ios: {
alert: true,
badge: true,
sound: true
}
});
push.on('registration', function (data) {
// On iOS, this provides the APNs token
linkrunner.setPushToken(data.registrationId).then(function () {
console.log("APNs push token set successfully");
});
});
```
## Function Placement Guide
| Function | Where to Place | When to Call |
| ------------------------------- | ------------------------------------- | ---------------------------------------------- |
| `linkrunner.init` | `deviceready` event handler | Once when app starts |
| `linkrunner.getAttributionData` | Attribution data handling flow | Whenever the attribution data is needed |
| `linkrunner.setAdditionalData` | Integration code | When third-party integration IDs are available |
| `linkrunner.signup` | Identification flow (signup or login) | Once when the user is identified |
| `linkrunner.setUserData` | Authentication logic | Every time app opens with logged-in user |
| `linkrunner.trackEvent` | Throughout app | When specific user actions occur |
| `linkrunner.capturePayment` | Payment processing | When user makes a payment |
| `linkrunner.removePayment` | Refund flow | When payment needs to be removed |
| `linkrunner.setPushToken` | Push notification setup | When push token is available |
| `linkrunner.handleDeeplink` | `handleOpenURL` handler | When app is opened via a deep link |
## Next Steps
Validate your setup end-to-end
Configure deep links for your app
## Support
If you encounter issues during integration, contact us at [support@linkrunner.io](mailto:support@linkrunner.io).
# Expo SDK
Source: https://docs.linkrunner.io/sdk/expo
Complete guide for integrating Linkrunner in Expo apps
**Prefer to let your AI coding agent do this?** Install the Linkrunner skill and Claude Code, Cursor, GitHub Copilot, or Windsurf will wire up the SDK and deep links for you:
```bash theme={null}
npx @linkrunner/skills add expo
```
Then ask your agent to "integrate Linkrunner". See [Linkrunner Agent Skills](https://github.com/linkrunner-labs/skills).
This guide will help you install, configure, and use the Linkrunner SDK in your Expo application.
**Migrating from an older version?** For instructions on upgrading from 1.x.x/2.x.x to version 3.x.x, see the
[Migration Guide](#migration-guide) below.
## Requirements
* Expo SDK 52.0 or higher
* Node.js 18.0 or higher
* React Native 0.70 or higher
## Installation
### Step 1: Install Both Packages
Install both the React Native and Expo packages:
```bash theme={null}
npm install rn-linkrunner
npx expo install expo-linkrunner
```
### Step 2: Add Plugin to app.json
**Important**: You must add the expo-linkrunner plugin to your `app.json` plugins array:
```json theme={null}
{
"expo": {
"plugins": [
[
"expo-linkrunner",
{
"userTrackingPermission": "This identifier will be used to deliver personalized ads.",
"debug": true
}
]
]
}
}
```
## What the `expo-linkrunner` Plugin Does
When you add `expo-linkrunner` to your `app.json` plugins and run the Expo prebuild step, the config plugin automatically makes the following changes to your iOS project:
* **Adds `NSUserTrackingUsageDescription`** in your iOS `Info.plist` if not already present (skipped when `disableIdfa` is set).
* **Adds the SKAdNetwork postback keys** `NSAdvertisingAttributionReportEndpoint` and `AttributionCopyEndpoint` (both set to `https://linkrunner-skan.com`) in your iOS `Info.plist` if not already present, so Apple sends SKAN postback copies to Linkrunner.
These changes are required for IDFA (Identifier for Advertisers) tracking, Apple's App Tracking Transparency (ATT) compliance, and SKAN attribution. For complete SKAdNetwork integration details, see the [SKAdNetwork Integration Guide](/features/skadnetwork-integration).
### Step 3: Prebuild Your Project
The `expo-linkrunner` package is a config plugin that automatically configures your Expo project for Linkrunner SDK integration. Once you've completed the installation and configuration steps above:
* **For EAS Build**: If your `android` and `ios` folders are in `.gitignore` (recommended), EAS Build will automatically run the prebuild step during the build process. No manual action required.
* **For local development or custom development clients**:
```bash theme={null}
npx expo prebuild
```
### Step 4: Android Backup Configuration
For Android apps, the SDK provides backup rules to exclude Shared Preferences data from backup. This prevents the retention of the Linkrunner install ID during reinstallation, ensuring accurate detection of new installs and re-installs.
For detailed backup configuration instructions, please refer to the [Android SDK Backup Configuration](/sdk/android/installation#backup-configuration).
### Google Integrated Conversion Measurement (Optional)
Integrated Conversion Measurement (ICM) recovers Google App Campaign installs on iOS that Google cannot attribute because there is no click identifier and no IDFA to match on. Google's On-Device Measurement (ODM) SDK turns the click context into an encrypted signal that never leaves the device, and Linkrunner sends it with the install. See [Google ICM](/features/google-icm) for how it works.
Set this up if you run Google App Campaigns for your iOS app. Requires `rn-linkrunner` 3.1.0 or later.
Google keeps ODM inactive for users in the European Economic Area, the United Kingdom, and Switzerland, so ICM recovers nothing for that traffic. Elsewhere, Google reports improved coverage for iOS 14+ users.
ICM also needs an **iOS link ID** configured in your Google Ads integration. Google has nowhere to send the conversion without one. See [Prerequisites](/features/google-icm#prerequisites).
Already using the Firebase iOS SDK 11.14.0 or later? The `FirebaseAnalytics` pod brings this SDK in for you. Skip this step.
`rn-linkrunner` does not bundle this SDK, so apps that skip ICM carry none of its weight. Expo generates the `Podfile`, so add the pod through `expo-build-properties`:
```bash theme={null}
npx expo install expo-build-properties
```
```json theme={null}
{
"expo": {
"plugins": [
[
"expo-build-properties",
{
"ios": {
"extraPods": [{ "name": "GoogleAdsOnDeviceConversion" }]
}
}
]
]
}
}
```
CocoaPods adds the `-ObjC` and `-lc++` linker flags for you, so there are no Xcode Build Settings to change.
If you manage `ios/` yourself instead of generating it, add `pod 'GoogleAdsOnDeviceConversion'` to `ios/Podfile` and run `pod install`.
```bash theme={null}
npx expo prebuild --clean
```
ICM needs native code, so it does not work in Expo Go. Build a development client or run an EAS build.
Set the values with `setConsent` before you call `init`:
```javascript theme={null}
import linkrunner from "rn-linkrunner";
linkrunner.setConsent({
isEEA: "granted",
hasConsentForDataUsage: "granted",
hasConsentForAdsPersonalization: "denied",
});
await linkrunner.init("YOUR_PROJECT_TOKEN");
```
See [Google Integrated Conversion Measurement](/sdk/react-native#google-integrated-conversion-measurement-optional) in the React Native guide for what each signal means and how to verify the setup. **For users outside the EEA, the UK, and Switzerland, report `isEEA` as denied and leave the other two unset.** See [Send Consent](/features/send-consent).
## SDK Usage
The `expo-linkrunner` plugin handles the native configuration automatically. All SDK methods and APIs are provided
by the `rn-linkrunner` package, which is why you'll use the React Native documentation for implementation details.
Since `expo-linkrunner` is a config plugin that prepares your project, the actual SDK usage is identical to React Native. Please refer to the [React Native SDK Guide](/sdk/react-native) for:
* Initializing the SDK
* User registration
* Handling deep links with `handleDeeplink`
* Getting attribution data
* Setting user data
* Tracking custom events
* Revenue tracking
* Ecommerce Events
* Enhanced privacy controls
## Migration Guide
If you are using expo-linkrunner with v1.x.x or v2.x.x then moving forward you'll need to migrate to a v3.x.x.
### Architecture Changes
Starting with version 3.0.0, the Expo SDK has been redesigned to use a new architecture:
* **Config Plugin Architecture**: The SDK now uses an Expo config plugin for better integration with the Expo ecosystem
* **React Native SDK**: Business logic is now handled by the React Native SDK, ensuring consistency across platforms
* **Improved Developer Experience**: Simplified setup with automatic native configuration
### Breaking Changes
* The package installation process has changed to require both React Native and Expo SDKs
* Plugin configuration is now required in `app.json`
* All SDK methods and APIs are now provided by the `rn-linkrunner` package
### Migration Steps
1. **Uninstall the old package**:
```bash theme={null}
npx expo uninstall expo-linkrunner
```
2. **Install the new version**:
Follow the [Installation](#installation) steps above to install both `rn-linkrunner` and `expo-linkrunner`.
3. **Update your code to use `rn-linkrunner`**:
The `expo-linkrunner` package is now just a config plugin. All SDK methods and APIs are now provided by the `rn-linkrunner` package. You'll need to update your imports and usage accordingly.
For detailed implementation instructions, see the [SDK Usage](#sdk-usage) section above.
## Next Steps
Validate your setup end-to-end
Configure deep links for your app
## Support
If you encounter issues during integration, contact us at [support@linkrunner.io](mailto:support@linkrunner.io).
# Flutter SDK
Source: https://docs.linkrunner.io/sdk/flutter
Complete guide for integrating Linkrunner in Flutter apps
**Prefer to let your AI coding agent do this?** Install the Linkrunner skill and Claude Code, Cursor, GitHub Copilot, or Windsurf will wire up the SDK and deep links for you:
```bash theme={null}
npx @linkrunner/skills add flutter
```
Then ask your agent to "integrate Linkrunner". See [Linkrunner Agent Skills](https://github.com/linkrunner-labs/skills).
## Installation
### Requirements
* Flutter 3.19.0 or higher
* Dart 3.3.0 or higher
* iOS 15.0+ / Android 5.0 (API level 21) and above
### Step 1: Add the Package
Run the following command to add the latest version of the Linkrunner package to your project:
```bash theme={null}
flutter pub add linkrunner
```
This command will automatically:
* Add the latest version of `linkrunner` to your `pubspec.yaml`
* Download and install the package and its dependencies
### Step 2: Platform Specific Setup
#### Android Configuration
1. Ensure your project's `minSdkVersion` is at least 21 in your `android/app/build.gradle` file:
```gradle theme={null}
android {
defaultConfig {
minSdkVersion 21
// other config...
}
}
```
2. Add the following permissions to your `AndroidManifest.xml` file:
```xml theme={null}
```
> **Note:** The `AD_ID` permission (``) is already included in the SDK and is required for collecting device identifiers (GAID). If your app participates in [Designed for Families](https://support.google.com/googleplay/android-developer/topic/9877766?hl=en\&ref_topic=9858052), you should revoke AAID and disable AAID collection. See the [Disabling AAID Collection](#disabling-aaid-collection) section for more details.
#### Revoking the AD\_ID Permission
According to [Google's Policy](https://support.google.com/googleplay/android-developer/answer/11043825?hl=en), apps that target children must not transmit the Advertising ID.
To revoke the AD\_ID permission, use Flutter SDK version 3.5.0 and above. Children apps targeting Android 13 (API 33) and above must prevent the permission from getting merged into their app by adding a revoke declaration to their Manifest. Use the [`setDisableAaidCollection()`](#disabling-aaid-collection) and [`isAaidCollectionDisabled()`](#disabling-aaid-collection) functions to disable AAID collection programmatically:
**android/app/src/main/AndroidManifest.xml**
```xml theme={null}
```
Make sure to add `xmlns:tools="http://schemas.android.com/tools"` to your manifest tag to use the `tools:node="remove"` attribute. If you disable AAID collection, you should also remove the AD\_ID permission from your manifest to fully comply with Google Play's Family Policy requirements.
For more information, see [Google Play Services documentation](https://developers.google.com/android/reference/com/google/android/gms/ads/identifier/AdvertisingIdClient.Info#public-string-getid).
#### Backup Configuration
For Android apps, the SDK provides backup rules to exclude Shared Preferences data from backup. This prevents the retention of the Linkrunner install ID during reinstallation, ensuring accurate detection of new installs and re-installs.
For detailed backup configuration instructions, please refer to the [Android SDK Backup Configuration](/sdk/android/installation#backup-configuration).
#### Encrypted SharedPreferences
**SDK Version Requirement:** Starting from `linkrunner` **v3.9.1**, the SDK automatically encrypts the credentials it stores in Android SharedPreferences (such as the install ID and other persisted SDK state). No additional configuration is required — upgrade to v3.9.1 or above to get this behavior by default.
On Android, values written by the SDK are encrypted at rest, with a hardware-protected key generated on the device and stored in the [Android Keystore](https://developer.android.com/training/articles/keystore). The key never leaves the device and is not bundled with the SDK.
If you are upgrading from an earlier version, the SDK will transparently migrate any existing plaintext entries to the encrypted store on the next read after the upgrade — no code changes are needed on your side.
#### iOS Configuration
1. Update your iOS deployment target to iOS 15.0 or higher in your `ios/Podfile`:
```ruby theme={null}
platform :ios, '15.0'
```
2. Add the following to your `Info.plist` file for App Tracking Transparency:
```xml theme={null}
NSUserTrackingUsageDescription
This identifier will be used to deliver personalized ads and improve your app experience.
```
3. To enable SKAdNetwork postback copies to be sent to Linkrunner, add the following keys to your `Info.plist` file:
```xml theme={null}
NSAdvertisingAttributionReportEndpoint
https://linkrunner-skan.com
AttributionCopyEndpoint
https://linkrunner-skan.com
```
For complete SKAdNetwork integration details, see the [SKAdNetwork Integration Guide](/features/skadnetwork-integration).
### Google Integrated Conversion Measurement (Optional)
**Prefer to let your AI coding agent do this?** The Flutter skill already covers ICM — adding the pod and wiring `setConsent`:
```bash theme={null}
npx @linkrunner/skills add flutter
```
See [Linkrunner Agent Skills](/ai-agent-skills).
Integrated Conversion Measurement (ICM) recovers Google App Campaign installs on iOS that Google cannot attribute because there is no click identifier and no IDFA to match on. Google's On-Device Measurement (ODM) SDK turns the click context into an encrypted signal that never leaves the device, and Linkrunner sends it with the install. See [Google ICM](/features/google-icm) for how it works.
Set this up if you run Google App Campaigns for your iOS app. Requires `linkrunner` 4.1.1 or later.
Google keeps ODM inactive for users in the European Economic Area, the United Kingdom, and Switzerland, so ICM recovers nothing for that traffic. Elsewhere, Google reports improved coverage for iOS 14+ users.
ICM also needs an **iOS link ID** configured in your Google Ads integration. Google has nowhere to send the conversion without one. See [Prerequisites](/features/google-icm#prerequisites).
Already using the Firebase iOS SDK 11.14.0 or later? The `FirebaseAnalytics` pod brings this SDK in for you. Skip this step.
The plugin does not bundle this SDK, so apps that skip ICM carry none of its weight. Add it inside the `Runner` target in `ios/Podfile`:
```ruby theme={null}
target 'Runner' do
# ...your existing config
pod 'GoogleAdsOnDeviceConversion'
end
```
Then install the pods:
```bash theme={null}
cd ios && pod install
```
CocoaPods adds the `-ObjC` and `-lc++` linker flags for you, so there are no Xcode Build Settings to change.
Set the values with `setConsent` before you call `init`, and again whenever the user changes their choice:
```dart theme={null}
import 'package:linkrunner/linkrunner.dart';
import 'package:linkrunner/models/lr_consent.dart';
await LinkRunner().setConsent(
LRConsent(
isEEA: ConsentStatus.GRANTED,
hasConsentForDataUsage: ConsentStatus.GRANTED,
hasConsentForAdsPersonalization: ConsentStatus.DENIED,
),
);
await LinkRunner().init('YOUR_PROJECT_TOKEN');
```
Each signal takes `ConsentStatus.GRANTED`, `ConsentStatus.DENIED`, or `ConsentStatus.UNKNOWN`. Anything omitted or left `UNKNOWN` is dropped from the payload rather than reported as a denial, so Linkrunner never reports a choice your user did not make.
| Parameter | Meaning |
| --------------------------------- | ------------------------------------------------------------------------- |
| `isEEA` | European regulations apply to this user (the EEA, the UK, or Switzerland) |
| `hasConsentForDataUsage` | The user agreed to their data being sent to Google for advertising |
| `hasConsentForAdsPersonalization` | The user agreed to their data being used to personalize ads |
Google treats these as required whenever their value is known. `hasConsentForDataUsage` decides whether Google may use the conversion at all, `hasConsentForAdsPersonalization` decides whether it may feed audiences and remarketing, and `isEEA` tells Google which rules apply. Set them from your app's real consent state rather than hardcoding them. **For users outside the EEA, the UK, and Switzerland, report `isEEA` as denied and leave the other two unset.** See [Send Consent](/features/send-consent).
`setConsent` works on both iOS and Android. Android has no ODM SDK to add, but its installs reach Google through the App Conversion API, which reads the same signals.
That is the whole integration. The native SDK fetches the value at initialization and there is no other Dart API to call. Attribution comes back through [`getAttributionData`](#getting-attribution-data) as usual.
Consent is stored between launches. Call `setConsent` again whenever the user's consent state changes, otherwise the previous value keeps being sent after the user has withdrawn it.
#### Verifying your setup
Initialize with `debug` set to `true` and look for this line in the Xcode console:
```
Linkrunner: odm_available=true odm_fetch_result=success odm_fetch_latency_ms=124
```
`odm_available=false` with `odm_fetch_result=unavailable` means Google's SDK is not linked. Check that `pod install` picked up `GoogleAdsOnDeviceConversion`.
## Initialization (Required)
You'll need your [project token](https://dashboard.linkrunner.io/dashboard?m=documentation) to get started!
Note: The initialization method doesn't return any value. To get attribution data and deeplink information, use the `getAttributionData` method.
```dart theme={null}
import 'package:linkrunner/linkrunner.dart';
Future initLinkrunner() async {
try {
// Initialize with your project token
await LinkRunner().init(
'YOUR_PROJECT_TOKEN',
'YOUR_SECRET_KEY', // Optional: Required for SDK signing
'YOUR_KEY_ID', // Optional: Required for SDK signing
false, // Optional: Set to true to disable IDFA collection for iOS devices (defaults to false)
true // Optional: Enable debug mode for development (defaults to false)
);
print('LinkRunner initialized');
} catch (e) {
print('Error initializing LinkRunner: $e');
}
}
// Call this in your app's initialization
@override
void initState() {
WidgetsFlutterBinding.ensureInitialized(); // Make sure this is added!
super.initState();
initLinkrunner();
}
```
## SDK Signing Parameters (Optional)
For enhanced security, the LinkRunner SDK requires the following signing parameters during initialization:
* **`secretKey`**: A unique secret key used for request signing and authentication
* **`keyId`**: A unique identifier for the key pair used in the signing process
You can find your project token, secret key, and key ID [here](https://dashboard.linkrunner.io/settings?s=sdk-signing).
### Platform-Specific SDK Signing
For applications requiring different signing keys per platform:
```dart theme={null}
import 'dart:io' show Platform;
import 'package:linkrunner/linkrunner.dart';
Future initLinkrunnerWithSigning() async {
try {
// Initialize with your project token and SDK signing parameters
await LinkRunner().init(
'YOUR_PROJECT_TOKEN',
Platform.isIOS ? 'YOUR_IOS_SECRET_KEY' : 'YOUR_ANDROID_SECRET_KEY', // Platform-specific secret key
Platform.isIOS ? 'YOUR_IOS_KEY_ID' : 'YOUR_ANDROID_KEY_ID', // Platform-specific key ID
true, // Optional: Enable debug mode for development (defaults to false)
);
print('LinkRunner initialized with SDK signing');
} catch (e) {
print('Error initializing LinkRunner: $e');
}
}
```
## Setting the Customer User ID
Use `setCustomerUserId` to attach your own user identifier to the device **right after `init`**. Once set, the identifier is stored securely on-device and automatically included in every event you track, so you never have to pass it on each `trackEvent` call.
Call it as early as the user's ID is available. This guarantees every event carries a `user_id` from the very first event, and is especially useful for existing users who were already onboarded before this feature shipped.
Available from **Flutter SDK v3.10.0**.
**Best practice:** set the Customer User ID as early as possible. The `user_id` is only attached to events tracked **after** it's set, and is not applied retroactively. Use a stable, unique identifier from your own system (for example your internal user ID or a UUID) rather than an email address or other PII.
```dart theme={null}
try {
await LinkRunner().setCustomerUserId("f47ac10b-58cc-4372-a567-0e02b2c3d479"); // Your unique customer user ID (e.g. a UUID)
print('Customer user id set');
} catch (e) {
print('Error setting customer user id: $e');
}
```
The identifier is stored securely on-device and persists across app restarts. Calling `setCustomerUserId` again with a **different** identifier updates the stored value; passing the same identifier is a no-op. `signup()` / `setUserData()` also update it.
## User Identification (Required)
Call the `signup` method as soon as the user is identified — whether through signup or login. This is the moment Linkrunner ties the install (and any future events) to a user identifier.
It is strongly recommended to use the integrated platform's identify function to set a persistent user\_id once it becomes available (typically after signup or login).
* [Mixpanel - ID Management & User Identification](https://docs.mixpanel.com/docs/tracking-methods/id-management/identifying-users-simplified)
* [PostHog - How User Identification Works](https://posthog.com/docs/product-analytics/identify#how-identify-works)
* [Amplitude - Identify Users Documentation](https://amplitude.com/docs/get-started/identify-users)
If the platform's identifier function is not called, you must provide a user identifier for Mixpanel, PostHog, and Amplitude integration.
* mixpanelDistinctId for Mixpanel
* amplitudeDeviceId for Amplitude
* posthogDistinctId for PostHog
The model classes (`LRUserData`, `LRCapturePayment`, `LRRemovePayment`, `LRConsent`) are not re-exported by `linkrunner.dart`. Import each one directly, for example `import 'package:linkrunner/models/lr_user_data.dart';`.
```dart theme={null}
Future onSignup() async {
try {
await LinkRunner().signup(
userData: LRUserData(
id: '123', // Required: User ID
name: 'John Doe', // Optional
phone: '9876543210', // Optional
email: 'user@example.com', // Optional
// These properties are used to track reinstalls
userCreatedAt: '2024-01-01T00:00:00Z', // Optional
isFirstTimeUser: true, // Optional
mixpanelDistinctId: 'mixpanelDistinctId', // Optional - Mixpanel Distinct ID
amplitudeDeviceId: 'amplitudeDeviceId', // Optional - Amplitude User ID
posthogDistinctId: 'posthogDistinctId', // Optional - PostHog Distinct ID
),
data: {}, // Optional: Any additional data
);
print('Signup successful');
} catch (e) {
print('Error during signup: $e');
}
}
```
## Handle Deeplink
To enable [remarketing and reattribution](/features/remarketing), you need to capture deep links and pass them to the Linkrunner SDK. This allows Linkrunner to detect returning users who open the app via a deep link.
```dart theme={null}
import 'package:flutter/material.dart';
import 'package:app_links/app_links.dart';
import 'package:linkrunner/linkrunner.dart';
class MyApp extends StatefulWidget {
@override
State createState() => _MyAppState();
}
class _MyAppState extends State {
final _appLinks = AppLinks();
@override
void initState() {
super.initState();
_initLinkRunner();
}
Future _initLinkRunner() async {
// Init SDK first
await LinkRunner().init('your_project_token');
// Cold start — app was launched by a deeplink
final initialLink = await _appLinks.getInitialLink();
if (initialLink != null) {
LinkRunner().handleDeeplink(initialLink.toString());
}
// Warm start — app was in background, deeplink brought it to foreground
_appLinks.uriLinkStream.listen((Uri uri) {
LinkRunner().handleDeeplink(uri.toString());
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
// your app content
);
}
}
```
Linkrunner sends the updated deeplink back after processing. For Linkrunner campaign links, use the returned `deeplink` as the resolved destination instead of the original tracking URL.
```json theme={null}
{
"deeplink": "https://app.yourdomain.com/product/123"
}
```
## Getting Attribution Data
To get attribution data and deeplink information for the current installation, use the `getAttributionData` function:
```dart theme={null}
Future getAttributionInfo() async {
try {
final attributionData = await LinkRunner().getAttributionData();
print('Attribution data: $attributionData');
} catch (e) {
print('Error getting attribution data: $e');
}
}
```
The `getAttributionData` function returns an `AttributionData` object with the following structure:
```dart theme={null}
class AttributionData {
final String? deeplink; // Optional: The deep link URL that led to app installation
final CampaignData campaignData; // Required: Campaign information
}
class CampaignData {
final String id; // Required: Campaign ID
final String name; // Required: Campaign name
final String? adNetwork; // Optional: "META" | "GOOGLE" | null
final String? groupName; // Optional: Campaign group name
final String? assetGroupName; // Optional: Asset group name
final String? adNetworkCampaignId; // Optional: Ad network campaign ID
final String? adSetId; // Optional: Ad set ID
final String? adSetName; // Optional: Ad set name
final String? adCreativeId; // Optional: Ad creative ID
final String? adCreativeName; // Optional: Ad creative name
final String? assetName; // Optional: Asset name
final String type; // Required: Campaign type ("ORGANIC" | "INORGANIC")
final String installedAt; // Required: Installation timestamp
final String storeClickAt; // Required: Store click timestamp
}
```
Example response:
```dart theme={null}
// getAttributionData() returns a typed AttributionData object — access fields in camelCase.
{
"deeplink": "https://app.yourdomain.com/product/123",
"campaignData": {
"id": "camp_123",
"name": "Summer Sale 2024",
"adNetwork": "META",
"groupName": "iOS Campaign",
"assetGroupName": "Product Catalog",
"assetName": "Banner Ad 1",
"adNetworkCampaignId": "120214682829390250",
"adSetId": "120214682829640250",
"adSetName": "Productivity",
"adCreativeId": "120214682926100250",
"adCreativeName": "Static_2",
"type": "INORGANIC",
"installedAt": "2024-03-20T10:30:00Z",
"storeClickAt": "2024-03-20T10:29:45Z"
}
}
```
## Setting User Data
Call `setUserData` each time the app opens and the user is logged in:
**`setUserData` is optional and is not a replacement for `signup`.** Always call `signup` first as soon as the user is identified (signup or login). Use `setUserData` afterwards only when additional user details become available later — for example, when the user adds a phone number, email, or completes their profile after identification.
```dart theme={null}
Future setUserData() async {
try {
await LinkRunner().setUserData(
userData: LRUserData(
id: '123', // Required: User ID
name: 'John Doe', // Optional
phone: '9876543210', // Optional
email: 'user@example.com', // Optional
mixpanelDistinctId: 'mixpanelDistinctId', // Optional - Mixpanel Distinct ID
amplitudeDeviceId: 'amplitudeDeviceId', // Optional - Amplitude User ID
posthogDistinctId: 'posthogDistinctId', // Optional - PostHog Distinct ID
),
);
print('User data set successfully');
} catch (e) {
print('Error setting user data: $e');
}
}
```
## Setting CleverTap ID
Use the `setAdditionalData` method to set CleverTap ID:
```dart theme={null}
Future setIntegrationData() async {
try {
await LinkRunner().setAdditionalData(
integrationData: {
'clevertap_id': 'YOUR_CLEVERTAP_USER_ID', // CleverTap user identifier
},
);
print('CleverTap ID set successfully');
} catch (e) {
print('Error setting CleverTap ID: $e');
}
}
```
### Parameters for `LinkRunner.setAdditionalData`
* `clevertap_id`: String (optional) - CleverTap user identifier
This method allows you to connect user identities across different analytics and marketing platforms.
## Revenue Tracking
Revenue is stored for all users, including organic ones. Payments from users with no matching click are stored without campaign attribution. Call [`.signup`](#user-registration) so payments are linked to a user. You can verify your events are being captured on the [Events](https://dashboard.linkrunner.io/dashboard/events) page.
### Capturing Payments
Track payment information:
```dart theme={null}
Future capturePayment() async {
try {
await LinkRunner().capturePayment(
capturePayment: LRCapturePayment(
amount: 99.99, // Required: Payment amount
userId: 'user123', // Required: User identifier
paymentId: 'payment456', // Required: Unique payment identifier
type: PaymentType.FIRST_PAYMENT, // Optional: Payment type
status: PaymentStatus.PAYMENT_COMPLETED, // Optional: Payment status
eventData: { // Optional: Ecommerce/custom event data
'content_ids': ['product_123'],
'content_type': 'product',
'currency': 'USD',
'value': 99.99,
'num_items': 1,
'order_id': 'order_12345',
'contents': [
{
'id': 'product_123',
'quantity': 1,
'item_price': 99.99
}
]
},
),
);
print('Payment captured successfully');
} catch (e) {
print('Error capturing payment: $e');
}
}
```
#### Parameters for `LRCapturePayment`
* `amount`: double (required) - The payment amount
* `userId`: String (required) - Identifier for the user making the payment
* `paymentId`: String (required) - Unique identifier for the payment, used to deduplicate transactions
* `type`: PaymentType (optional) - Type of payment. Available options:
* `PaymentType.FIRST_PAYMENT` - First payment made by the user
* `PaymentType.WALLET_TOPUP` - Adding funds to a wallet
* `PaymentType.FUNDS_WITHDRAWAL` - Withdrawing funds
* `PaymentType.SUBSCRIPTION_CREATED` - New subscription created
* `PaymentType.SUBSCRIPTION_RENEWED` - Subscription renewal
* `PaymentType.ONE_TIME` - One-time payment
* `PaymentType.RECURRING` - Recurring payment
* `PaymentType.DEFAULT_PAYMENT` - Default type (used if not specified)
* `status`: PaymentStatus (optional) - Status of the payment. Available options:
* `PaymentStatus.PAYMENT_INITIATED` - Payment has been initiated
* `PaymentStatus.PAYMENT_COMPLETED` - Payment completed successfully (default if not specified)
* `PaymentStatus.PAYMENT_FAILED` - Payment attempt failed
* `PaymentStatus.PAYMENT_CANCELLED` - Payment was cancelled
* `eventData`: `Map` (optional) - Key-value pairs for additional event data, including ecommerce properties for Meta and Google.
### Removing Payments
Remove payment records (for refunds or cancellations):
```dart theme={null}
Future removePayment() async {
try {
await LinkRunner().removePayment(
removePayment: LRRemovePayment(
userId: 'user123', // Either userId or paymentId must be provided
paymentId: 'payment456', // Optional: Unique payment identifier
),
);
print('Payment removed successfully');
} catch (e) {
print('Error removing payment: $e');
}
}
```
#### Parameters for `LRRemovePayment`
* `userId`: String (required) - Identifier for the user whose payment is being removed
* `paymentId`: String (optional) - Unique identifier for the payment to be removed
Note: Either `paymentId` or `userId` must be provided when calling `removePayment`. If only `userId` is provided, all payments for that user will be removed.
## Ecommerce Events
> **Minimum SDK Version:** Ecommerce Event Manager requires `linkrunner` **v3.7.0** or above. Please ensure your SDK is updated before using this feature.
If you are tracking Ecommerce events to sync with Meta or Google, you must format your `eventData` to include the required fields. **You also need to map your custom event to the standard commerce event in the Linkrunner Dashboard.**
For detailed explanations of the required fields like `content_ids`, `contents`, and `value`, refer to our [Meta Commerce Manager documentation](/ecommerce-manager/meta-commerce-manager#understanding-event_data) or [Google Commerce Manager documentation](/ecommerce-manager/google-commerce-manager#understanding-event_data-for-google).
### Add To Cart Example
Use the `trackEvent` method to send an `AddToCart` event:
```dart theme={null}
Future trackAddToCart() async {
try {
await LinkRunner().trackEvent(
eventName: 'add_to_cart', // Map this custom event to "AddToCart" (Meta) or "add_to_cart" (Google) in the Linkrunner Dashboard. See: /ecommerce-manager/meta-commerce-manager, /ecommerce-manager/google-commerce-manager
eventData: {
'content_ids': ['product_123'],
'contents': [
{
'id': 'product_123', // Matches content_ids
'quantity': 1,
'item_price': 49.99
}
],
'content_type': 'product',
'currency': 'USD',
'value': 49.99,
'num_items': 1
},
);
print('Add To Cart event tracked successfully');
} catch (e) {
print('Error tracking Add To Cart event: $e');
}
}
```
### View Content Example
Use the `trackEvent` method to send a `ViewContent` event:
```dart theme={null}
Future trackViewContent() async {
try {
await LinkRunner().trackEvent(
eventName: 'view_item', // Map this custom event to "ViewContent" (Meta) or "view_item" (Google) in the Linkrunner Dashboard. See: /ecommerce-manager/meta-commerce-manager, /ecommerce-manager/google-commerce-manager
eventData: {
'content_ids': ['product_123'],
'contents': [
{
'id': 'product_123', // Matches content_ids
'quantity': 1,
'item_price': 49.99
}
],
'content_type': 'product',
'currency': 'USD',
'value': 49.99,
'num_items': 1
},
);
print('View Content event tracked successfully');
} catch (e) {
print('Error tracking View Content event: $e');
}
}
```
### Payment / Purchase Example
Use the `capturePayment` method to send a `Purchase` event containing the ecommerce payload:
```dart theme={null}
Future capturePurchase() async {
try {
await LinkRunner().capturePayment(
capturePayment: LRCapturePayment(
amount: 49.99,
userId: 'user123',
paymentId: 'payment_456',
type: PaymentType.FIRST_PAYMENT, // Map this payment type to "Purchase" (Meta) or "ecommerce_purchase" (Google) in the Linkrunner Dashboard. See: /ecommerce-manager/meta-commerce-manager, /ecommerce-manager/google-commerce-manager
status: PaymentStatus.PAYMENT_COMPLETED,
eventData: {
'content_ids': ['product_123'],
'contents': [
{
'id': 'product_123', // Matches content_ids
'quantity': 1,
'item_price': 49.99
}
],
'content_type': 'product',
'currency': 'USD',
'value': 49.99,
'num_items': 1,
'order_id': 'order_abc123' // Required for Purchase events
},
),
);
print('Purchase captured successfully');
} catch (e) {
print('Error capturing purchase: $e');
}
}
```
> **Note:** For more information on testing and verifying your ecommerce events, please see our [Meta Commerce Manager](/ecommerce-manager/meta-commerce-manager#testing-ecommerce-events) or [Google Commerce Manager](/ecommerce-manager/google-commerce-manager#verifying-events-in-google-ads) guide.
## Tracking Custom Events
From **Flutter SDK v3.10.0**, custom events automatically include the `user_id` you set during `signup()` / `setUserData()`. The SDK stores this identifier securely on-device and attaches it to every `trackEvent` call, so you no longer need to pass it manually. Events tracked before signup are sent without a `user_id`.
Events are stored for all users, including organic ones. Events from users with no matching click are stored without campaign attribution. Call [`.signup`](#user-registration) so events are linked to a user. You can verify your events are being captured on the [Events](https://dashboard.linkrunner.io/dashboard/events) page. For capturing revenue, it is recommended to use the [`.capturePayment`](#revenue-tracking) method instead of `.trackEvent`.
Track custom events in your app:
```dart theme={null}
Future trackEvent() async {
try {
await LinkRunner().trackEvent(
eventName: 'purchase_initiated', // Event name
eventData: { // Optional: Event data
'product_id': '12345',
'category': 'electronics',
'amount': 99.99, // Include amount as a number for revenue sharing with ad networks like Google and Meta
},
eventId: 'order_12345', // Optional: Your own unique event identifier (String or num)
);
print('Event tracked successfully');
} catch (e) {
print('Error tracking event: $e');
}
}
```
#### Parameters for `LinkRunner().trackEvent`
* `eventName`: `String` (required) - Name of the event to track
* `eventData`: `Map` (optional) - Key-value pairs for additional event data, including Meta ecommerce properties
* `eventId`: `Object` (optional) - Your own unique identifier for the event (`String` or `num`), useful for deduplication and correlating with your backend
### Revenue Sharing with Ad Networks
To enable revenue sharing with ad networks like Google Ads and Meta, include an `amount` parameter as a number in your custom event data. This allows the ad networks to optimize campaigns based on the revenue value of conversions:
```dart theme={null}
Future trackPurchaseEvent() async {
try {
await LinkRunner().trackEvent(
eventName: 'purchase_completed',
eventData: {
'product_id': '12345',
'category': 'electronics',
'amount': 149.99, // Revenue amount as a number
},
);
print('Purchase event with revenue tracked successfully');
} catch (e) {
print('Error tracking purchase event: $e');
}
}
```
For revenue sharing with ad networks to work properly, ensure the `amount` parameter is passed as a number (double
or int), not as a string.
## Enhanced Privacy Controls
The SDK offers options to enhance user privacy:
```dart theme={null}
// Enable PII (Personally Identifiable Information) hashing
LinkRunner().enablePIIHashing(true);
```
When PII hashing is enabled, sensitive user data like name, email, and phone number are hashed using SHA-256 before being sent to Linkrunner servers.
## Disabling AAID Collection
**SDK Version Requirement:** The AAID collection disable functionality requires Flutter SDK version 3.5.0 or higher.
The SDK provides options to disable AAID (Google Advertising ID) collection. This is useful for apps targeting children or families to comply with Google Play's Family Policy.
Disabling AAID collection is not recommended unless absolutely necessary. The GAID is a primary signal for Google Ads attribution and install matching, so disabling it reduces attribution accuracy. Only disable it if your app is built for children or families and must comply with Google Play's Family Policy.
### Disable AAID Collection
To disable AAID collection, call `setDisableAaidCollection` before SDK initialization:
```dart theme={null}
// Disable AAID collection
LinkRunner().setDisableAaidCollection(true);
// Check if AAID collection is disabled
bool isDisabled = LinkRunner().isAaidCollectionDisabled();
```
When AAID collection is disabled, the SDK will not collect or send the Google Advertising ID (GAID) to Linkrunner servers.
### Removing AD\_ID Permission
If you want to completely remove the `AD_ID` permission from your app's manifest (for example, for apps targeting children), you can override the SDK's permission declaration in your `android/app/src/main/AndroidManifest.xml`. For detailed instructions on revoking the AD\_ID permission, including Google's policy requirements for apps targeting children and Android 13+ (API 33+), see the [Revoking the AD\_ID Permission](#revoking-the-ad-id-permission) section above.
## Uninstall Tracking
### Before you begin
Here's what you need to know before getting started:
**Requirements:**
* Flutter SDK 3.2.2 and later
* [Firebase in your Flutter project (Android)](https://firebase.google.com/docs/flutter/setup)
* [Registering your app with APNs (iOS)](https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns)
### Android
Connect Firebase Cloud Messaging (FCM) with Linkrunner
To configure FCM HTTP V1 for uninstalls:
**Enable the FCM API:**
1. Go to the [FCM console](https://console.firebase.google.com).
2. Select a project.
3. Go to **Project Overview** > **Project settings**.
4. Copy the Project ID. This will be required in a later step.
5. Go to the **Cloud Messaging** tab.
6. Make sure that Firebase Cloud Messaging API (V1) is set to Enabled.
**Create a custom role for Linkrunner Uninstall:**
1. Go to the **Service accounts** tab.
2. Click **Manage service account permissions**.
3. A new browser tab opens in Google Cloud Platform.
4. In the side menu, select **Roles**.
5. Click **+ Create role**.
6. Enter the following details:
* **Title**: Linkrunner Uninstalls
* **ID**: lr\_uninstalls
* **Role launch stage**: General availability
7. Click **+ Add permissions**.
8. In **Enter property name or value** field, enter `cloudmessaging.messages.create` and select it from the search results.
9. Check the **cloudmessaging.messages.create** option and click **Add**.
10. Click **Create**.
**Assign Linkrunner the FCM uninstall role:**
1. In the side menu, select **IAM**.
2. Open the **View by Principals** tab.
3. Click **Grant Access**.
4. In **Add Principals** -> **New principals** field, enter `lr-uninstalls-tracking@lr-uninstalls-tracking.iam.gserviceaccount.com`
5. In **Assign Roles** -> **Select a role** field, enter `Linkrunner Uninstalls` and select it from the search results.
6. Click **Save**.
The Linkrunner service account has been assigned the role of Linkrunner Uninstalls.
1. In Linkrunner, go to **Settings** > **Uninstall Tracking**.
2. Under the **Android** tab, enter the Firebase Project ID that you copied initially and click **Save**.
Follow these instructions to integrate FCM with the Linkrunner SDK:
1. **Set up Firebase Cloud Messaging:**
Set up Firebase Cloud Messaging in your flutter app. See the [Firebase Cloud Messaging documentation](https://firebase.google.com/docs/cloud-messaging/flutter/client) for detailed instructions.
2. Configure your app to provide the device's push token to the Linkrunner SDK.
```dart theme={null}
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:linkrunner/linkrunner.dart';
class MyFirebaseMessagingService {
static Future initialize() async {
// Fetch FCM token and set in Linkrunner SDK
String? token = await FirebaseMessaging.instance.getToken();
if (token != null) {
await LinkRunner().setPushToken(token);
}
}
static void setupTokenRefresh() {
// Receive new FCM token and set in Linkrunner SDK
FirebaseMessaging.instance.onTokenRefresh
.listen((fcmToken) async {
await LinkRunner().setPushToken(fcmToken);
})
.onError((err) {
// Error getting token.
});
}
static void setupMessageListener() {
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
if (message.data.containsKey("lr-uninstall-tracking")) {
return;
} else {
// Handle other data payloads here
}
});
}
}
```
Custom implementations of FCM's `onMessageReceived` method can unintentionally make uninstall push notifications visible to users, disrupting the intended silent experience. To avoid this, ensure your logic checks if the message contains `lr-uninstall-tracking` and handles it accordingly, as shown in the code example above.
### iOS
Connect APNs with Linkrunner
Get the required credentials from the Apple Developer Portal:
**APNs Authentication Key (p8) and Key ID:**
* Go to the [Apple Developer Portal](https://developer.apple.com/account).
* Select **Identifiers** under **Certificates, IDs & Profiles**.
* Click on the app you want to track uninstalls for. Then, under **Capabilities**, search for **Push Notifications** and enable it.
* Under **Certificates, IDs & Profiles**, select **Keys** and click on plus (+) icon to create a key. Enable APNs when creating the key and download the key file (p8).
* The Key ID can be found in the **Keys** tab.
**Bundle ID and Team ID:**
* Under **Identifiers**, click on your app and you will see the Bundle ID and Team ID (App ID Prefix).
1. In Linkrunner, go to **Settings** > **Uninstall Tracking**.
2. Under the **iOS** tab, upload the APNs Authentication Key (p8) file and enter the Key ID, Bundle ID and Team ID (App ID Prefix) that you copied from the Apple Developer Portal.
Follow these instructions to integrate FCM with the Linkrunner SDK:
1. **Set up Firebase Cloud Messaging:**
Set up Firebase Cloud Messaging in your flutter app if you haven't already. See the [Firebase Cloud Messaging documentation](https://firebase.google.com/docs/cloud-messaging/flutter/client) for detailed instructions.
2. Configure your app to provide the device's APNs token to the Linkrunner SDK.
```dart theme={null}
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:linkrunner/linkrunner.dart';
class MyFirebaseMessagingService {
static Future initialize() async {
// Fetch APNs token and set in Linkrunner SDK
String? token = await FirebaseMessaging.instance.getAPNSToken();
if (token != null) {
await LinkRunner().setPushToken(token);
}
}
}
```
## Function Placement Guide
| Function | Where to Place | When to Call |
| --------------------------------------- | -------------------------------------- | ---------------------------------------------- |
| `LinkRunner().init` | App initialization | Once when app starts |
| `LinkRunner().getAttributionData` | Attribution data handling flow | Whenever the attribution data is needed |
| `LinkRunner().setAdditionalData` | Integration code | When third-party integration IDs are available |
| `LinkRunner().signup` | Identification flow (signup or login) | Once when the user is identified |
| `LinkRunner().setUserData` | Authentication logic | Every time app opens with logged-in user |
| `LinkRunner().trackEvent` | Throughout app | When specific user actions occur |
| `LinkRunner().capturePayment` | Payment processing | When user makes a payment |
| `LinkRunner().removePayment` | Refund flow | When payment needs to be removed |
| `LinkRunner().handleDeeplink` | Deep link entry points | When app is opened via a deep link |
| `LinkRunner().setConsent` | App initialization or consent flow | Before `init`, and again when consent changes |
| `LinkRunner().setDisableAaidCollection` | App initialization or privacy settings | When you need to disable AAID collection |
| `LinkRunner().isAaidCollectionDisabled` | Privacy settings or compliance checks | When you need to check AAID collection status |
## Complete Example
Here's a simplified example showing how to integrate Linkrunner in a Flutter app:
You can find your project token [here](https://dashboard.linkrunner.io/dashboard?s=members\&m=documentation).
```dart theme={null}
import 'package:flutter/material.dart';
import 'package:linkrunner/linkrunner.dart';
final linkrunner = LinkRunner();
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Linkrunner Demo',
home: HomeScreen(),
);
}
}
class HomeScreen extends StatefulWidget {
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State {
bool _initialized = false;
@override
void initState() {
super.initState();
_initializeLinkrunner();
}
Future _initializeLinkrunner() async {
try {
await LinkRunner().init('YOUR_PROJECT_TOKEN');
setState(() {
_initialized = true;
});
} catch (e) {
print('Error initializing LinkRunner: $e');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Linkrunner Demo')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('LinkRunner ${_initialized ? 'Initialized' : 'Initializing...'}'),
SizedBox(height: 20),
ElevatedButton(
onPressed: () async {
await LinkRunner().trackEvent(eventName: 'button_clicked');
},
child: Text('Track Custom Event'),
),
],
),
),
);
}
}
```
## Next Steps
Validate your setup end-to-end
Configure deep links for your app
## Support
If you encounter issues during integration, contact us at [support@linkrunner.io](mailto:support@linkrunner.io).
# iOS SDK
Source: https://docs.linkrunner.io/sdk/ios
Complete guide for integrating Linkrunner in native iOS apps
**Prefer to let your AI coding agent do this?** Install the Linkrunner skill and Claude Code, Cursor, GitHub Copilot, or Windsurf will wire up the SDK and deep links for you:
```bash theme={null}
npx @linkrunner/skills add ios
```
Then ask your agent to "integrate Linkrunner". See [Linkrunner Agent Skills](https://github.com/linkrunner-labs/skills).
## Requirements
* iOS 15.0 or higher
* Swift 5.9 or higher
* Xcode 14.0 or higher
## Installation
### Swift Package Manager
The Linkrunner SDK can be installed via Swift Package Manager (SPM), which is integrated directly into Xcode.
1. In Xcode, select **File** → **Add Package Dependencies...**
2. Enter the following repository URL:
```
https://github.com/linkrunner-labs/linkrunner-ios.git
```
3. Select the version you want to use (we recommend using the latest version)
4. Click **Add Package**
5. Choose the library type **LinkrunnerKitStatic**
Alternatively, you can add the package dependency to your `Package.swift` file:
```swift theme={null}
dependencies: [
.package(url: "https://github.com/linkrunner-labs/linkrunner-ios.git", from: "4.1.0")
]
```
And add the dependency to your target:
```swift theme={null}
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "LinkrunnerKitStatic", package: "linkrunner-ios")
]
)
]
```
### Importing in Swift
After installation, you can import the SDK in your Swift files:
```swift theme={null}
import LinkrunnerKit
```
Use `import LinkrunnerKit` for v3.0.0 and later. The public API remains `LinkrunnerSDK.shared`.
### Required Permissions
#### App Tracking Transparency
If you plan to use IDFA (Identifier for Advertisers), you need to request permission from the user through App Tracking Transparency. Add the following to your `Info.plist` file:
```xml theme={null}
NSUserTrackingUsageDescription
This identifier will be used to deliver personalized ads and improve your app experience.
```
#### SKAdNetwork Configuration
To enable SKAdNetwork postback copies to be sent to Linkrunner, add the following keys to your `Info.plist` file:
```xml theme={null}
NSAdvertisingAttributionReportEndpoint
https://linkrunner-skan.com
AttributionCopyEndpoint
https://linkrunner-skan.com
```
For complete SKAdNetwork integration details, see the [SKAdNetwork Integration Guide](/features/skadnetwork-integration).
#### Network Access
The SDK requires network access to communicate with Linkrunner services. Make sure your app has the appropriate permissions for network access.
### Google Integrated Conversion Measurement (Optional)
**Prefer to let your AI coding agent do this?** The iOS skill already covers ICM — adding Google's ODM SDK and wiring `setConsent`:
```bash theme={null}
npx @linkrunner/skills add ios
```
See [Linkrunner Agent Skills](/ai-agent-skills).
Integrated Conversion Measurement (ICM) recovers Google App Campaign installs that Google cannot attribute because there is no click identifier and no IDFA to match on. Google's On-Device Measurement (ODM) SDK turns the click context into an encrypted signal that never leaves the device, and Linkrunner sends it with the install. See [Google ICM](/features/google-icm) for how it works.
Set this up if you run Google App Campaigns for your iOS app. Requires LinkrunnerKit 4.1.0 or later.
Google keeps ODM inactive for users in the European Economic Area, the United Kingdom, and Switzerland, so ICM recovers nothing for that traffic. Elsewhere, Google reports improved coverage for iOS 14+ users.
ICM also needs an **iOS link ID** configured in your Google Ads integration. Google has nowhere to send the conversion without one. See [Prerequisites](/features/google-icm#prerequisites).
Already using the Firebase iOS SDK 11.14.0 or later? The `FirebaseAnalytics` pod brings this SDK in for you. Skip to the next step.
Linkrunner does not bundle this SDK, so apps that skip ICM carry none of its weight. Add it to your app yourself.
Add `https://github.com/googleads/google-ads-on-device-conversion-ios-sdk` in **File** → **Add Package Dependencies...** and select the `GoogleAdsOnDeviceConversion` product.
Add the pod inside your app target in the `Podfile`:
```ruby theme={null}
target 'YourApp' do
# ...your existing pods
pod 'GoogleAdsOnDeviceConversion'
end
```
Then run `pod install`.
Using CocoaPods? Skip this step. CocoaPods adds `-ObjC` and `-lc++` for you when it links Google's static framework. The one exception is an app target whose **Other Linker Flags** no longer contain `$(inherited)`, in which case add `-ObjC` yourself.
If you added Google's SDK with Swift Package Manager, add this to **Build Settings** → **Other Linker Flags** on your app target:
```
-ObjC
```
Without it, ICM silently does nothing and your build still succeeds. Linkrunner finds Google's class through the Objective-C runtime, so nothing references it at link time, and the linker drops it from Google's static library.
Google reads consent signals when it matches your installs. See [Consent](#consent) below.
That is the whole integration. Linkrunner detects the SDK at runtime and there is no API to call. Attribution comes back through [`getAttributionData`](#getting-attribution-data) as usual.
ODM matches on the time of first launch, which Linkrunner records when you call `initialize`.
ICM complements SKAdNetwork, it does not replace it. Keep your existing [SKAdNetwork integration](/features/skadnetwork-integration) in place.
#### Consent
Google needs to know whether European regulations apply to a user and what that user agreed to. Set the values with `setConsent` before you call `initialize`, and again whenever the user changes their choice.
```swift theme={null}
LinkrunnerSDK.shared.setConsent(
LinkrunnerConsent(
isEEA: .granted,
hasConsentForDataUsage: .granted,
hasConsentForAdsPersonalization: .denied
)
)
await LinkrunnerSDK.shared.initialize(token: "YOUR_PROJECT_TOKEN")
```
| Parameter | Meaning |
| --------------------------------- | ------------------------------------------------------------------------- |
| `isEEA` | European regulations apply to this user (the EEA, the UK, or Switzerland) |
| `hasConsentForDataUsage` | The user agreed to their data being sent to Google for advertising |
| `hasConsentForAdsPersonalization` | The user agreed to their data being used to personalize ads |
Each takes `.granted`, `.denied`, or `.unknown`. Anything left `.unknown` is omitted rather than reported as a denial, so Linkrunner never reports a choice your user did not make.
Google treats these as required whenever their value is known. `hasConsentForDataUsage` decides whether Google may use the conversion at all, `hasConsentForAdsPersonalization` decides whether it may feed audiences and remarketing, and `isEEA` tells Google which rules apply. Set them from your app's real consent state rather than hardcoding them. **For users outside the EEA, the UK, and Switzerland, report `isEEA` as denied and leave the other two unset.** See [Send Consent](/features/send-consent). App Tracking Transparency is not a substitute, because it governs IDFA access only and is reported separately.
Consent is stored between launches. Call `setConsent` again whenever the user's consent state changes, otherwise the previous value keeps being sent after your user has withdrawn it.
#### Verifying your setup
Initialize with `debug: true` and look for this line in the Xcode console:
```
Linkrunner: odm_available=true odm_fetch_result=success odm_fetch_latency_ms=124
```
`odm_available=false` with `odm_fetch_result=unavailable` means Google's SDK is not linked. On Swift Package Manager, check the `-ObjC` flag first, because a missing flag strips the class with no build warning.
## Initialization (Required)
Initialize the Linkrunner SDK in your app's startup code, typically in your `AppDelegate` or `SceneDelegate`:
You can find your project token [here](https://dashboard.linkrunner.io/dashboard?s=members\&m=documentation).
Note: The initialization method doesn't return any value. To get attribution data and deeplink information, use the `getAttributionData` method.
```swift theme={null}
import LinkrunnerKit
import SwiftUI
@main
struct MyApp: App {
init() {
Task {
await LinkrunnerSDK.shared.initialize(
token: "YOUR_PROJECT_TOKEN",
secretKey: "YOUR_SECRET_KEY", // Optional: Required for SDK signing
keyId: "YOUR_KEY_ID", // Optional: Required for SDK signing
debug: true // Optional: Enable debug mode for development (defaults to false)
)
print("Linkrunner initialized successfully")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
```
## SDK Signing Parameters (Optional)
For enhanced security, the LinkRunner SDK requires the following signing parameters during initialization:
* **`secretKey`**: A unique secret key used for request signing and authentication
* **`keyId`**: A unique identifier for the key pair used in the signing process
* **`disableIdfa`** (optional): Boolean flag to disable IDFA collection (defaults to false)
* **`debug`** (optional): Boolean flag to enable debug mode for development (defaults to false)
You can find your project token, secret key, and key ID [here](https://dashboard.linkrunner.io/settings?s=sdk-signing).
## Setting the Customer User ID
Use `setCustomerUserId` to attach your own user identifier to the device **right after `init`**. Once set, the identifier is stored securely on-device and automatically included in every event you track, so you never have to pass it on each `trackEvent` call.
Call it as early as the user's ID is available. This guarantees every event carries a `user_id` from the very first event, and is especially useful for existing users who were already onboarded before this feature shipped.
Available from **iOS SDK v3.11.0**.
**Best practice:** set the Customer User ID as early as possible. The `user_id` is only attached to events tracked **after** it's set, and is not applied retroactively. Use a stable, unique identifier from your own system (for example your internal user ID or a UUID) rather than an email address or other PII.
```swift theme={null}
func setCustomerUserId() async {
await LinkrunnerSDK.shared.setCustomerUserId("f47ac10b-58cc-4372-a567-0e02b2c3d479") // Your unique customer user ID (e.g. a UUID)
}
```
The identifier is stored in the Keychain and persists across app restarts. Calling `setCustomerUserId` again with a **different** identifier updates the stored value; passing the same identifier is a no-op. `signup()` / `setUserData()` also update it.
## User Identification (Required)
Call the `signup` method as soon as the user is identified — whether through signup or login. This is the moment Linkrunner ties the install (and any future events) to a user identifier.
It is strongly recommended to use the integrated platform's identify function to set a persistent user\_id once it becomes available (typically after signup or login).
* [Mixpanel - ID Management & User Identification](https://docs.mixpanel.com/docs/tracking-methods/id-management/identifying-users-simplified)
* [PostHog - How User Identification Works](https://posthog.com/docs/product-analytics/identify#how-identify-works)
* [Amplitude - Identify Users Documentation](https://amplitude.com/docs/get-started/identify-users)
If the platform's identifier function is not called, you must provide a user identifier for Mixpanel, PostHog, and Amplitude integration.
* mixPanelDistinctId for Mixpanel
* amplitudeDeviceId for Amplitude
* posthogDistinctId for PostHog
```swift theme={null}
func onSignup() async {
let userData = UserData(
id: "123", // Required: User ID
name: "John Doe", // Optional
phone: "9876543210", // Optional
email: "user@example.com", // Optional
isFirstTimeUser: isFirstTimeUser,
userCreatedAt: "2022-01-01T00:00:00Z", // Optional
mixPanelDistinctId: "mixpanelDistinctId", // Optional - Mixpanel Distinct ID
amplitudeDeviceId: "amplitudeDeviceId", // Optional - Amplitude Device ID
posthogDistinctId: "posthogDistinctId" // Optional - PostHog Distinct ID
)
await LinkrunnerSDK.shared.signup(
userData: userData,
additionalData: [:] // Optional: Any additional data
)
print("Signup successful")
}
```
## Handle Deeplink
To enable [remarketing and reattribution](/features/remarketing), you need to capture deep links and pass them to the Linkrunner SDK. This allows Linkrunner to detect returning users who open the app via a deep link.
Add the following to your `SceneDelegate.swift`:
```swift theme={null}
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
// Cold start via Universal Link
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
// Check if app was launched via a Universal Link
if let userActivity = connectionOptions.userActivities.first,
userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL {
Task {
await LinkrunnerSDK.shared.handleDeeplink(url: url.absoluteString)
}
}
// Check if app was launched via a custom URL scheme
if let urlContext = connectionOptions.urlContexts.first {
Task {
await LinkrunnerSDK.shared.handleDeeplink(url: urlContext.url.absoluteString)
}
}
}
// Warm start — Universal Links (app already running in background)
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else { return }
Task {
await LinkrunnerSDK.shared.handleDeeplink(url: url.absoluteString)
}
}
// Warm start — Custom URL schemes (app already running in background)
func scene(_ scene: UIScene, openURLContexts URLContexts: Set) {
guard let url = URLContexts.first?.url else { return }
Task {
await LinkrunnerSDK.shared.handleDeeplink(url: url.absoluteString)
}
}
}
```
Linkrunner sends the updated deeplink back after processing. For Linkrunner campaign links, use the returned `deeplink` as the resolved destination instead of the original tracking URL.
```json theme={null}
{
"deeplink": "https://app.yourdomain.com/product/123"
}
```
## Getting Attribution Data
To get attribution data and deeplink information for the current installation, use the `getAttributionData` function:
```swift theme={null}
func getAttributionInfo() async {
let attributionData = await LinkrunnerSDK.shared.getAttributionData()
print("Attribution data:", attributionData)
// Attribution data includes:
// - deeplink: The deep link URL that led to app installation
// - campaignData: Campaign information
if let deeplink = attributionData.deeplink {
print("Deeplink:", deeplink)
// Handle the deeplink in your app
}
}
```
```swift theme={null}
// getAttributionData() returns this type
public struct LRAttributionDataResponse: Codable, Sendable {
public let deeplink: String?
public let campaignData: CampaignData?
public let attributionSource: String
}
public struct CampaignData: Codable, Sendable {
public let id: String
public let name: String
public let type: CampaignType // .organic ("ORGANIC") | .inorganic ("INORGANIC")
public let adNetwork: AdNetwork? // .meta ("META") | .google ("GOOGLE")
public let groupName: String?
public let assetGroupName: String?
public let adNetworkCampaignId: String? // Ad network campaign ID
public let adSetId: String? // Ad set ID
public let adSetName: String? // Ad set name
public let adCreativeId: String? // Ad creative ID
public let adCreativeName: String? // Ad creative name
public let assetName: String?
public let installedAt: Date?
public let storeClickAt: Date?
}
public enum CampaignType: String, Codable, Sendable {
case organic = "ORGANIC"
case inorganic = "INORGANIC"
}
public enum AdNetwork: String, Codable, Sendable {
case meta = "META"
case google = "GOOGLE"
}
```
**Example response** (raw values — `type`/`adNetwork` decode to enums, `installedAt`/`storeClickAt` to `Date`):
```json theme={null}
{
"deeplink": "https://app.yourdomain.com/product/123",
"campaignData": {
"id": "camp_123",
"name": "Summer Sale 2024",
"adNetwork": "META",
"type": "INORGANIC",
"installedAt": "2024-03-20T10:30:00Z",
"storeClickAt": "2024-03-20T10:29:45Z",
"groupName": "iOS Campaign",
"assetName": "Banner Ad 1",
"assetGroupName": "Product Catalog",
"adNetworkCampaignId": "120214682829390250",
"adSetId": "120214682829640250",
"adSetName": "Productivity",
"adCreativeId": "120214682926100250",
"adCreativeName": "Static_2"
},
"attributionSource": "META"
}
```
## Setting User Data
Call `setUserData` each time the app opens and the user is logged in:
**`setUserData` is optional and is not a replacement for `signup`.** Always call `signup` first as soon as the user is identified (signup or login). Use `setUserData` afterwards only when additional user details become available later — for example, when the user adds a phone number, email, or completes their profile after identification.
```swift theme={null}
func setUserData() async {
let userData = UserData(
id: "123", // Required: User ID
name: "John Doe", // Optional
phone: "9876543210", // Optional
email: "user@example.com" // Optional
)
await LinkrunnerSDK.shared.setUserData(userData)
print("User data set successfully")
}
```
## Setting CleverTap ID
Use `setAdditionalData` to add CleverTap ID to the SDK:
```swift theme={null}
func setAdditionalData() async {
let integrationData = IntegrationData(
clevertapId:clevertapId
)
await LinkrunnerSDK.shared.setAdditionalData(integrationData)
print("CleverTap ID set successfully")
}
```
## Revenue Tracking
Revenue is stored for all users, including organic ones. Payments from users with no matching click are stored without campaign attribution. Call [`.signup`](#user-registration) so payments are linked to a user. You can verify your events are being captured on the [Events](https://dashboard.linkrunner.io/dashboard/events) page.
### Capturing Payments
Track payment information:
```swift theme={null}
func capturePayment() async {
await LinkrunnerSDK.shared.capturePayment(
amount: 99.99, // Payment amount
userId: "user123", // User identifier
paymentId: "payment456", // Required: Unique payment identifier
type: .firstPayment, // optional
status: .completed, // optional
eventData: [ // Optional: Ecommerce/custom event data
"content_ids": ["product_123"],
"content_type": "product",
"currency": "USD",
"value": 99.99,
"num_items": 1,
"order_id": "order_12345",
"contents": [
[
"id": "product_123",
"quantity": 1,
"item_price": 99.99
]
]
]
)
print("Payment captured successfully")
}
```
#### Available Payment Types
```swift theme={null}
public enum PaymentType: String, Sendable {
case firstPayment = "FIRST_PAYMENT"
case secondPayment = "SECOND_PAYMENT"
case walletTopup = "WALLET_TOPUP"
case fundsWithdrawal = "FUNDS_WITHDRAWAL"
case subscriptionCreated = "SUBSCRIPTION_CREATED"
case subscriptionRenewed = "SUBSCRIPTION_RENEWED"
case oneTime = "ONE_TIME"
case recurring = "RECURRING"
case `default` = "DEFAULT"
}
```
#### Available Payment Statuses
```swift theme={null}
public enum PaymentStatus: String, Sendable {
case initiated = "PAYMENT_INITIATED"
case completed = "PAYMENT_COMPLETED"
case failed = "PAYMENT_FAILED"
case cancelled = "PAYMENT_CANCELLED"
}
```
### Removing Payments
Remove payment records (for refunds or cancellations):
```swift theme={null}
func removePayment() async {
await LinkrunnerSDK.shared.removePayment(
userId: "user123", // User identifier
paymentId: "payment456" // Optional: Unique payment identifier
)
print("Payment removed successfully")
}
```
## Tracking Custom Events
From **iOS SDK v3.11.0**, custom events automatically include the `user_id` you set during `signup()` / `setUserData()`. The SDK stores this identifier securely on-device (Keychain) and attaches it to every `trackEvent` call, so you no longer need to pass it manually. Events tracked before signup are sent without a `user_id`.
Events are stored for all users, including organic ones. Events from users with no matching click are stored without campaign attribution. Call [`.signup`](#user-registration) so events are linked to a user. You can verify your events are being captured on the [Events](https://dashboard.linkrunner.io/dashboard/events) page. For capturing revenue, it is recommended to use the [`.capturePayment`](#revenue-tracking) method instead of `.trackEvent`.
Track custom events in your app:
```swift theme={null}
func trackEvent() async {
await LinkrunnerSDK.shared.trackEvent(
eventName: "purchase_initiated", // Event name
eventData: [ // Optional: Event data
"product_id": "12345",
"category": "electronics",
"amount": 99.99 // Include amount as a number for revenue sharing with ad networks like Google and Meta
],
eventId: "order_12345" // Optional: Your own unique event identifier
)
print("Event tracked successfully")
}
```
#### Parameters for `LinkrunnerSDK.shared.trackEvent`
* `eventName`: `String` (required) - Name of the event to track
* `eventData`: `[String: Any]` (optional) - Key-value pairs for additional event data, including Meta ecommerce properties
* `eventId`: `String` (optional) - Your own unique identifier for the event, useful for deduplication and correlating with your backend
### Revenue Sharing with Ad Networks
To enable revenue sharing with ad networks like Google Ads and Meta, include an `amount` parameter as a number in your custom event data. This allows the ad networks to optimize campaigns based on the revenue value of conversions:
```swift theme={null}
func trackPurchaseEvent() async {
await LinkrunnerSDK.shared.trackEvent(
eventName: "purchase_completed",
eventData: [
"product_id": "12345",
"category": "electronics",
"amount": 149.99 // Revenue amount as a number
]
)
print("Purchase event with revenue tracked successfully")
}
```
For revenue sharing with ad networks to work properly, ensure the `amount` parameter is passed as a number (Double
or Int), not as a string.
## Ecommerce Events
> **Minimum SDK Version:** Ecommerce Event Manager requires `linkrunner-ios` **v3.8.0** or above. Please ensure your SDK is updated before using this feature.
If you are tracking Ecommerce events to sync with Meta or Google, you must format your `eventData` to include the required fields. **You also need to map your custom event to the standard commerce event in the Linkrunner Dashboard.**
For detailed explanations of the required fields like `content_ids`, `contents`, and `value`, refer to our [Meta Commerce Manager documentation](/ecommerce-manager/meta-commerce-manager#understanding-event_data) or [Google Commerce Manager documentation](/ecommerce-manager/google-commerce-manager#understanding-event_data-for-google).
### Add To Cart Example
Use the `trackEvent` method to send an `AddToCart` event:
```swift theme={null}
func trackAddToCart() async {
await LinkrunnerSDK.shared.trackEvent(
eventName: "add_to_cart", // Map this custom event to "AddToCart" (Meta) or "add_to_cart" (Google) in the Linkrunner Dashboard
eventData: [
"content_ids": ["product_123"],
"contents": [
[
"id": "product_123", // Matches content_ids
"quantity": 1,
"item_price": 49.99
]
],
"content_type": "product",
"currency": "USD",
"value": 49.99,
"num_items": 1
]
)
print("Add To Cart event tracked successfully")
}
```
### View Content Example
Use the `trackEvent` method to send a `ViewContent` event:
```swift theme={null}
func trackViewContent() async {
await LinkrunnerSDK.shared.trackEvent(
eventName: "view_item", // Map this custom event to "ViewContent" (Meta) or "view_item" (Google) in the Linkrunner Dashboard
eventData: [
"content_ids": ["product_123"],
"contents": [
[
"id": "product_123", // Matches content_ids
"quantity": 1,
"item_price": 49.99
]
],
"content_type": "product",
"currency": "USD",
"value": 49.99,
"num_items": 1
]
)
print("View Content event tracked successfully")
}
```
### Payment / Purchase Example
Use the `capturePayment` method to send a `Purchase` event containing the ecommerce payload:
```swift theme={null}
func capturePurchase() async {
await LinkrunnerSDK.shared.capturePayment(
amount: 49.99,
userId: "user123",
paymentId: "payment_456",
type: .firstPayment, // Map this payment type to "Purchase" (Meta) or "ecommerce_purchase" (Google) in the Linkrunner Dashboard
status: .completed,
eventData: [
"content_ids": ["product_123"],
"contents": [
[
"id": "product_123", // Matches content_ids
"quantity": 1,
"item_price": 49.99
]
],
"content_type": "product",
"currency": "USD",
"value": 49.99,
"num_items": 1,
"order_id": "order_abc123" // Required for Purchase events
]
)
print("Purchase captured successfully")
}
```
> **Note:** For more information on testing and verifying your ecommerce events, please see our [Meta Commerce Manager](/ecommerce-manager/meta-commerce-manager#testing-ecommerce-events) or [Google Commerce Manager](/ecommerce-manager/google-commerce-manager#verifying-events-in-google-ads) guide.
## Enhanced Privacy Controls
The SDK offers options to enhance user privacy:
```swift theme={null}
// Enable PII (Personally Identifiable Information) hashing
LinkrunnerSDK.shared.enablePIIHashing(true)
// Check if PII hashing is enabled
let isHashingEnabled = LinkrunnerSDK.shared.isPIIHashingEnabled()
```
When PII hashing is enabled, sensitive user data like name, email, and phone number are hashed using SHA-256 before being sent to Linkrunner servers.
## Uninstall Tracking
### Before you begin
Here's what you need to know before getting started:
**Requirements:**
* iOS SDK 3.4.0 and later
* [Registering your app with APNs](https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns)
### iOS
Connect APNs with Linkrunner
Get the required credentials from the Apple Developer Portal:
**APNs Authentication Key (p8) and Key ID:**
* Go to the [Apple Developer Portal](https://developer.apple.com/account).
* Select **Identifiers** under **Certificates, IDs & Profiles**.
* Click on the app you want to track uninstalls for. Then, under **Capabilities**, search for **Push Notifications** and enable it.
* Under **Certificates, IDs & Profiles**, select **Keys** and click on plus (+) icon to create a key. Enable APNs when creating the key and download the key file (p8).
* The Key ID can be found in the **Keys** tab.
**Bundle ID and Team ID:**
* Under **Identifiers**, click on your app and you will see the Bundle ID and Team ID (App ID Prefix).
1. In Linkrunner, go to **Settings** > **Uninstall Tracking**.
2. Under the **iOS** tab, upload the APNs Authentication Key (p8) file and enter the Key ID, Bundle ID and Team ID (App ID Prefix) that you copied from the Apple Developer Portal.
Follow these instructions to integrate APNs with the Linkrunner SDK:
1. **Set up Push Notifications:**
Enable push notifications in your Xcode project by adding the Push Notifications capability.
2. Configure your app to provide the device's APNs token to the Linkrunner SDK.
```swift theme={null}
import UIKit
import LinkrunnerKit
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Request push notification permissions
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
if granted {
DispatchQueue.main.async {
application.registerForRemoteNotifications()
}
}
}
return true
}
// Called when APNs token is received
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
Task {
try? await LinkrunnerSDK.shared.setPushToken(tokenString)
}
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
print("Failed to register for remote notifications: \(error)")
}
}
```
For SwiftUI apps using the new app lifecycle:
```swift theme={null}
import SwiftUI
import LinkrunnerKit
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
if granted {
DispatchQueue.main.async {
application.registerForRemoteNotifications()
}
}
}
return true
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
Task {
try? await LinkrunnerSDK.shared.setPushToken(tokenString)
}
}
}
```
## Function Placement Guide
| Function | Where to Place | When to Call |
| ----------------------------------------- | ------------------------------------- | --------------------------------------------------- |
| `LinkrunnerSDK.shared.initialize` | App initialization | Once when app starts |
| `LinkrunnerSDK.shared.getAttributionData` | Attribution data handling flow | Whenever the attribution data is needed |
| `LinkrunnerSDK.shared.setAdditionalData` | Integration code | When third-party integration IDs are available |
| `LinkrunnerSDK.shared.signup` | Identification flow (signup or login) | Once when the user is identified |
| `LinkrunnerSDK.shared.setUserData` | Authentication logic | Every time app opens with logged-in user |
| `LinkrunnerSDK.shared.trackEvent` | Throughout app | When specific user actions occur |
| `LinkrunnerSDK.shared.capturePayment` | Payment processing | When user makes a payment |
| `LinkrunnerSDK.shared.removePayment` | Refund flow | When payment needs to be removed |
| `LinkrunnerSDK.shared.setPushToken` | Push notification setup | When APNs token is available |
| `LinkrunnerSDK.shared.handleDeeplink` | SceneDelegate deep link entry points | When app is opened via a deep link |
| `LinkrunnerSDK.shared.setConsent` | App initialization or consent flow | Before `initialize`, and again when consent changes |
## Complete Example
Here's a simplified example showing how to integrate Linkrunner in a SwiftUI iOS app:
You can find your project token [here](https://dashboard.linkrunner.io/dashboard?s=members\&m=documentation).
```swift theme={null}
import SwiftUI
import LinkrunnerKit
@main
struct MyApp: App {
init() {
Task {
await initializeLinkrunner()
}
}
func initializeLinkrunner() async {
await LinkrunnerSDK.shared.initialize(token: "YOUR_PROJECT_TOKEN")
print("Linkrunner initialized successfully")
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
var body: some View {
NavigationView {
VStack(spacing: 20) {
Text("Linkrunner Demo")
.font(.largeTitle)
Button("Track Event") {
Task {
await trackCustomEvent()
}
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}
func trackCustomEvent() async {
await LinkrunnerSDK.shared.trackEvent(
eventName: "button_clicked",
eventData: ["screen": "home"]
)
print("Event tracked successfully")
}
}
```
## Next Steps
Validate your setup end-to-end
Configure deep links for your app
## Support
If you encounter issues during integration, contact us at [support@linkrunner.io](mailto:support@linkrunner.io).
# React Native SDK
Source: https://docs.linkrunner.io/sdk/react-native
Complete guide for integrating Linkrunner in React Native apps
**Prefer to let your AI coding agent do this?** Install the Linkrunner skill and Claude Code, Cursor, GitHub Copilot, or Windsurf will wire up the SDK and deep links for you:
```bash theme={null}
npx @linkrunner/skills add react-native
```
Then ask your agent to "integrate Linkrunner". See [Linkrunner Agent Skills](https://github.com/linkrunner-labs/skills).
## Installation
### Step 1: Install the Package
```bash theme={null}
# Using npm
npm install rn-linkrunner
# Using yarn
yarn add rn-linkrunner
```
### Step 2: iOS Configuration
If you're developing for iOS, follow these additional steps:
1. Install pods for the package:
```bash theme={null}
cd ios && pod install
```
2. Add the following code to your `info.plist` file:
```xml theme={null}
NSUserTrackingUsageDescription
This identifier will be used to deliver personalized ads and improve your app experience.
```
### Step 3: SKAdNetwork Configuration
To enable SKAdNetwork postback copies to be sent to Linkrunner, add the following keys to your `Info.plist` file:
```xml theme={null}
NSAdvertisingAttributionReportEndpoint
https://linkrunner-skan.com
AttributionCopyEndpoint
https://linkrunner-skan.com
```
For complete SKAdNetwork integration details, see the [SKAdNetwork Integration Guide](/features/skadnetwork-integration).
### Step 4: Android Backup Configuration
For Android apps, the SDK provides backup rules to exclude Shared Preferences data from backup. This prevents the retention of the Linkrunner install ID during reinstallation, ensuring accurate detection of new installs and re-installs.
For detailed backup configuration instructions, please refer to the [Android SDK Backup Configuration](/sdk/android#backup-configuration).
### Step 5: Encrypted SharedPreferences (Android)
**SDK Version Requirement:** Starting from `rn-linkrunner` **v2.10.1**, the SDK automatically encrypts the credentials it stores in Android SharedPreferences (such as the install ID and other persisted SDK state). No additional configuration is required — upgrade to v2.10.1 or above to get this behavior by default.
On Android, values written by the SDK are encrypted at rest, with a hardware-protected key generated on the device and stored in the [Android Keystore](https://developer.android.com/training/articles/keystore). The key never leaves the device and is not bundled with the SDK.
If you are upgrading from an earlier version, the SDK will transparently migrate any existing plaintext entries to the encrypted store on the next read after the upgrade — no code changes are needed on your side.
### Expo Support
If you are using Expo, follow the above steps to install the required packages. After this, you will need to use development builds since the Linkrunner SDK relies on native libraries. Follow the [Expo Development Builds Documentation](https://docs.expo.dev/develop/development-builds/introduction/) to get started.
### Google Integrated Conversion Measurement (Optional)
**Prefer to let your AI coding agent do this?** The React Native skill already covers ICM — adding the pod and wiring `setConsent`:
```bash theme={null}
npx @linkrunner/skills add react-native
```
See [Linkrunner Agent Skills](/ai-agent-skills).
Integrated Conversion Measurement (ICM) recovers Google App Campaign installs on iOS that Google cannot attribute because there is no click identifier and no IDFA to match on. Google's On-Device Measurement (ODM) SDK turns the click context into an encrypted signal that never leaves the device, and Linkrunner sends it with the install. See [Google ICM](/features/google-icm) for how it works.
Set this up if you run Google App Campaigns for your iOS app. Requires `rn-linkrunner` 3.1.0 or later.
Google keeps ODM inactive for users in the European Economic Area, the United Kingdom, and Switzerland, so ICM recovers nothing for that traffic. Elsewhere, Google reports improved coverage for iOS 14+ users.
ICM also needs an **iOS link ID** configured in your Google Ads integration. Google has nowhere to send the conversion without one. See [Prerequisites](/features/google-icm#prerequisites).
Already using the Firebase iOS SDK 11.14.0 or later? The `FirebaseAnalytics` pod brings this SDK in for you. Skip this step.
`rn-linkrunner` does not bundle this SDK, so apps that skip ICM carry none of its weight. Add it inside your app target in `ios/Podfile`:
```ruby theme={null}
target 'YourApp' do
# ...your existing config
pod 'GoogleAdsOnDeviceConversion'
end
```
Then install the pods:
```bash theme={null}
cd ios && pod install
```
CocoaPods adds the `-ObjC` and `-lc++` linker flags for you, so there are no Build Settings to change.
Set the values with `setConsent` before you call `init`, and again whenever the user changes their choice:
```javascript theme={null}
import linkrunner from "rn-linkrunner";
linkrunner.setConsent({
isEEA: "granted",
hasConsentForDataUsage: "granted",
hasConsentForAdsPersonalization: "denied",
});
await linkrunner.init("YOUR_PROJECT_TOKEN");
```
Each signal takes `"granted"`, `"denied"`, or `"unknown"`. Anything omitted or left `"unknown"` is dropped from the payload rather than reported as a denial, so Linkrunner never reports a choice your user did not make.
| Parameter | Meaning |
| --------------------------------- | ------------------------------------------------------------------------- |
| `isEEA` | European regulations apply to this user (the EEA, the UK, or Switzerland) |
| `hasConsentForDataUsage` | The user agreed to their data being sent to Google for advertising |
| `hasConsentForAdsPersonalization` | The user agreed to their data being used to personalize ads |
Google treats these as required whenever their value is known. `hasConsentForDataUsage` decides whether Google may use the conversion at all, `hasConsentForAdsPersonalization` decides whether it may feed audiences and remarketing, and `isEEA` tells Google which rules apply. Set them from your app's real consent state rather than hardcoding them. **For users outside the EEA, the UK, and Switzerland, report `isEEA` as denied and leave the other two unset.** See [Send Consent](/features/send-consent).
`setConsent` works on both iOS and Android. Android has no ODM SDK to add, but its installs reach Google through the App Conversion API, which reads the same signals.
That is the whole integration. The native SDK fetches the value at initialization and there is no other API to call. Attribution comes back through [`getAttributionData`](#getting-attribution-data) as usual.
Consent is stored between launches. Call `setConsent` again whenever the user's consent state changes, otherwise the previous value keeps being sent after the user has withdrawn it.
#### Verifying your setup
Initialize with debug mode on and look for this line in the Xcode console:
```
Linkrunner: odm_available=true odm_fetch_result=success odm_fetch_latency_ms=124
```
`odm_available=false` with `odm_fetch_result=unavailable` means Google's SDK is not linked. Check that `pod install` picked up `GoogleAdsOnDeviceConversion`.
## Initialization (Required)
To initialize the Linkrunner SDK, add this code to your `App.tsx` component:
You can find your project token [here](https://dashboard.linkrunner.io/dashboard?s=members\&m=documentation).
Note: The initialization method doesn't return any value. To get attribution data and deeplink information, use the `getAttributionData` method.
```javascript theme={null}
import linkrunner from "rn-linkrunner";
// Inside your React component
useEffect(() => {
init();
}, []); // Empty dependency array ensures it runs only once
const init = async () => {
await linkrunner.init(
"YOUR_PROJECT_TOKEN",
"YOUR_SECRET_KEY", // Optional: Required for SDK signing
"YOUR_KEY_ID", // Optional: Required for SDK signing
false, // Optional: Set to true to disable IDFA collection for iOS devices (defaults to false)
true // Optional: Enable debug mode for development (defaults to false)
);
console.log("Linkrunner initialized");
};
```
## SDK Signing Parameters (Optional)
For enhanced security, the LinkRunner SDK requires the following signing parameters during initialization:
* **`secretKey`**: A unique secret key used for request signing and authentication
* **`keyId`**: A unique identifier for the key pair used in the signing process
You can find your project token, secret key, and key ID [here](https://dashboard.linkrunner.io/settings?s=sdk-signing).
## Setting the Customer User ID
Use `setCustomerUserId` to attach your own user identifier to the device **right after `init`**. Once set, the identifier is stored securely on-device and automatically included in every event you track, so you never have to pass it on each `trackEvent` call.
Call it as early as the user's ID is available. This guarantees every event carries a `user_id` from the very first event, and is especially useful for existing users who were already onboarded before this feature shipped.
Available from **React Native SDK v2.11.0**.
**Best practice:** set the Customer User ID as early as possible. The `user_id` is only attached to events tracked **after** it's set, and is not applied retroactively. Use a stable, unique identifier from your own system (for example your internal user ID or a UUID) rather than an email address or other PII.
```javascript theme={null}
const setCustomerUserId = async () => {
try {
await linkrunner.setCustomerUserId("f47ac10b-58cc-4372-a567-0e02b2c3d479"); // Your unique customer user ID (e.g. a UUID)
console.log("Customer user id set");
} catch (error) {
console.error("Error setting customer user id:", error);
}
};
```
The identifier is stored securely on-device and persists across app restarts. Calling `setCustomerUserId` again with a **different** identifier updates the stored value; passing the same identifier is a no-op. `signup()` / `setUserData()` also update it.
## User Identification (Required)
Call the `signup` method as soon as the user is identified — whether through signup or login. This is the moment Linkrunner ties the install (and any future events) to a user identifier.
It is strongly recommended to use the integrated platform's identify function to set a persistent user\_id once it becomes available (typically after signup or login).
* [Mixpanel - ID Management & User Identification](https://docs.mixpanel.com/docs/tracking-methods/id-management/identifying-users-simplified)
* [PostHog - How User Identification Works](https://posthog.com/docs/product-analytics/identify#how-identify-works)
* [Amplitude - Identify Users Documentation](https://amplitude.com/docs/get-started/identify-users)
If the platform's identifier function is not called, you must provide a user identifier for Mixpanel, PostHog, and Amplitude integration.
* mixpanel\_distinct\_id for Mixpanel
* posthog\_distinct\_id for PostHog
* amplitude\_device\_id for Amplitude
```javascript theme={null}
const onSignup = async () => {
try {
await linkrunner.signup({
user_data: {
id: "123", // Required: User ID
name: "John Doe", // Optional
phone: "9876543210", // Optional
email: "user@example.com", // Optional
// These properties are used to track reinstalls
user_created_at: "2024-01-01T00:00:00Z", // Optional
is_first_time_user: true, // Optional
mixpanel_distinct_id: "mixpanel_distinct_id", // Optional - Mixpanel Distinct ID
amplitude_device_id: "amplitude_device_id", // Optional - Amplitude User ID
posthog_distinct_id: "posthog_distinct_id", // Optional - PostHog Distinct ID
},
data: {}, // Optional: Any additional data
});
console.log("Signup successful");
} catch (error) {
console.error("Error during signup:", error);
}
};
```
## Handle Deeplink
To enable [remarketing and reattribution](/features/remarketing), you need to capture deep links and pass them to the Linkrunner SDK. This allows Linkrunner to detect returning users who open the app via a deep link.
```javascript theme={null}
import { useEffect } from 'react';
import { Linking } from 'react-native';
import linkrunner from 'rn-linkrunner';
function App() {
useEffect(() => {
// Cold start — app was launched by a deeplink
Linking.getInitialURL().then((url) => {
if (url) {
linkrunner.handleDeeplink(url);
}
});
// Warm start — app was in background, deeplink brought it to foreground
const subscription = Linking.addEventListener('url', ({ url }) => {
linkrunner.handleDeeplink(url);
});
return () => subscription.remove();
}, []);
return (
// your app content
);
}
```
Linkrunner sends the updated deeplink back after processing. For Linkrunner campaign links, use the returned `deeplink` as the resolved destination instead of the original tracking URL.
```json theme={null}
{
"deeplink": "https://app.yourdomain.com/product/123"
}
```
## Getting Attribution Data
To get attribution data and deeplink information for the current installation, use the `getAttributionData` function:
```javascript theme={null}
const getAttributionInfo = async () => {
try {
const attributionData = await linkrunner.getAttributionData();
console.log("Attribution data:", attributionData);
} catch (error) {
console.error("Error getting attribution data:", error);
}
};
```
The `getAttributionData` function returns:
```typescript theme={null}
{
deeplink?: string;
campaignData?: {
id: string;
name: string;
type: string; // "ORGANIC" | "INORGANIC"
adNetwork?: string | null; // "META" | "GOOGLE" | "APPLE_SEARCH_ADS" | "TIKTOK" | "SNAPCHAT" | null
installedAt: string;
storeClickAt?: string | null;
groupName?: string;
assetName?: string;
assetGroupName?: string;
adNetworkCampaignId?: string | null; // Ad network campaign ID
adSetId?: string | null; // Ad set ID
adSetName?: string | null; // Ad set name
adCreativeId?: string | null; // Ad creative ID
adCreativeName?: string | null; // Ad creative name
}
}
```
**Example response:**
```json theme={null}
{
"deeplink": "https://app.yourdomain.com/product/123",
"campaignData": {
"id": "camp_123",
"name": "Summer Sale 2024",
"type": "INORGANIC",
"adNetwork": "META",
"installedAt": "2024-03-20T10:30:00Z",
"storeClickAt": "2024-03-20T10:29:45Z",
"groupName": "iOS Campaign",
"assetName": "Banner Ad 1",
"assetGroupName": "Product Catalog",
"adNetworkCampaignId": "120214682829390250",
"adSetId": "120214682829640250",
"adSetName": "Productivity",
"adCreativeId": "120214682926100250",
"adCreativeName": "Static_2"
}
}
```
## Setting User Data
Call `setUserData` each time the app opens and the user is logged in:
**`setUserData` is optional and is not a replacement for `signup`.** Always call `signup` first as soon as the user is identified (signup or login). Use `setUserData` afterwards only when additional user details become available later — for example, when the user adds a phone number, email, or completes their profile after identification.
```javascript theme={null}
const setUserData = async () => {
await linkrunner.setUserData({
id: "123", // Required: User ID
name: "John Doe", // Optional
phone: "9876543210", // Optional
email: "user@example.com", // Optional
mixpanel_distinct_id: "mixpanel_distinct_id", // Optional - Mixpanel Distinct ID
amplitude_device_id: "amplitude_device_id", // Optional - Amplitude User ID
posthog_distinct_id: "posthog_distinct_id", // Optional - PostHog Distinct ID
});
};
```
## Setting CleverTap ID
Use the `setAdditionalData` method to set CleverTap ID:
```javascript theme={null}
const setIntegrationData = async () => {
await linkrunner.setAdditionalData({
clevertapId: "YOUR_CLEVERTAP_USER_ID", // CleverTap user identifier
});
};
```
### Parameters for `linkrunner.setAdditionalData`
* `clevertapId`: string (optional) - CleverTap user identifier
This method allows you to connect user identities across different analytics and marketing platforms.
## Revenue Tracking
Revenue is stored for all users, including organic ones. Payments from users with no matching click are stored without campaign attribution. Call [`.signup`](#user-registration) so payments are linked to a user. You can verify your events are being captured on the [Events](https://dashboard.linkrunner.io/dashboard/events) page.
### Capture Payment
Use this method to capture payment information:
```js theme={null}
const capturePayment = async () => {
await linkrunner.capturePayment({
amount: 100, // Payment amount
userId: "user123", // User identifier
paymentId: "payment456", // Required: Unique payment identifier
type: "FIRST_PAYMENT", // Optional: Payment type
status: "PAYMENT_COMPLETED", // Optional: Payment status
eventData: {
// Optional: Ecommerce/custom event data
content_ids: ["product_123"],
content_type: "product",
currency: "USD",
value: 99.99,
num_items: 1,
order_id: "order_12345",
contents: [
{
id: "product_123",
quantity: 1,
item_price: 99.99,
},
],
},
});
};
```
#### Parameters for `linkrunner.capturePayment`
* `amount`: number (required) - The payment amount
* `userId`: string (required) - Identifier for the user making the payment
* `paymentId`: string (required) - Unique identifier for the payment, used to deduplicate transactions
* `type`: string (optional) - Type of payment. Available options:
* `FIRST_PAYMENT` - First payment made by the user
* `WALLET_TOPUP` - Adding funds to a wallet
* `FUNDS_WITHDRAWAL` - Withdrawing funds
* `SUBSCRIPTION_CREATED` - New subscription created
* `SUBSCRIPTION_RENEWED` - Subscription renewal
* `ONE_TIME` - One-time payment
* `RECURRING` - Recurring payment
* `DEFAULT` - Default type (used if not specified)
* `status`: string (optional) - Status of the payment. Available options:
* `PAYMENT_INITIATED` - Payment has been initiated
* `PAYMENT_COMPLETED` - Payment completed successfully (default if not specified)
* `PAYMENT_FAILED` - Payment attempt failed
* `PAYMENT_CANCELLED` - Payment was cancelled
* `eventData`: object (optional) - Key-value pairs for additional event data, including ecommerce properties for Meta and Google.
### Removing Payments
Remove payment records (for refunds or cancellations):
```javascript theme={null}
const removePayment = async () => {
await linkrunner.removePayment({
userId: "user123", // User identifier
paymentId: "payment456", // Optional: Unique payment identifier
});
};
```
#### Parameters for `Linkrunner.removePayment`
* `userId`: String (required) - Identifier for the user whose payment is being removed
* `paymentId`: String (optional) - Unique identifier for the payment to be removed
Note: `userId` is always required. `paymentId` is optional, and if you omit it all payments for that user are removed.
## Ecommerce Events
> **Minimum SDK Version:** Ecommerce Event Manager requires `rn-linkrunner` **v2.7.0** or above. Please ensure your SDK is updated before using this feature.
If you are tracking Ecommerce events to sync with Meta or Google, you must format your `eventData` to include the required fields. **You also need to map your custom event to the standard commerce event in the Linkrunner Dashboard.**
For detailed explanations of the required fields like `content_ids`, `contents`, and `value`, refer to our [Meta Commerce Manager documentation](/ecommerce-manager/meta-commerce-manager#understanding-event_data) or [Google Commerce Manager documentation](/ecommerce-manager/google-commerce-manager#understanding-event_data-for-google).
### Add To Cart Example
Use the `trackEvent` method to send an `AddToCart` event:
```javascript theme={null}
const trackAddToCart = async () => {
try {
await linkrunner.trackEvent(
"add_to_cart", // Map this custom event to "AddToCart" (Meta) or "add_to_cart" (Google) in the Linkrunner Dashboard. See: /ecommerce-manager/meta-commerce-manager, /ecommerce-manager/google-commerce-manager
{
content_ids: ["product_123"],
contents: [
{
id: "product_123", // Matches content_ids
quantity: 1,
item_price: 49.99,
},
],
content_type: "product",
currency: "USD",
value: 49.99,
num_items: 1,
},
);
console.log("Add To Cart event tracked successfully");
} catch (error) {
console.error("Error tracking Add To Cart event:", error);
}
};
```
### View Content Example
Use the `trackEvent` method to send a `ViewContent` event:
```javascript theme={null}
const trackViewContent = async () => {
try {
await linkrunner.trackEvent(
"view_item", // Map this custom event to "ViewContent" (Meta) or "view_item" (Google) in the Linkrunner Dashboard. See: /ecommerce-manager/meta-commerce-manager, /ecommerce-manager/google-commerce-manager
{
content_ids: ["product_123"],
contents: [
{
id: "product_123", // Matches content_ids
quantity: 1,
item_price: 49.99,
},
],
content_type: "product",
currency: "USD",
value: 49.99,
num_items: 1,
},
);
console.log("View Content event tracked successfully");
} catch (error) {
console.error("Error tracking View Content event:", error);
}
};
```
### Payment / Purchase Example
Use the `capturePayment` method to send a `Purchase` event containing the ecommerce payload:
```javascript theme={null}
const capturePurchase = async () => {
try {
await linkrunner.capturePayment({
amount: 49.99,
userId: "user123",
paymentId: "payment_456",
type: "FIRST_PAYMENT", // Map this payment type to "Purchase" (Meta) or "ecommerce_purchase" (Google) in the Linkrunner Dashboard. See: /ecommerce-manager/meta-commerce-manager, /ecommerce-manager/google-commerce-manager
status: "PAYMENT_COMPLETED",
eventData: {
content_ids: ["product_123"],
contents: [
{
id: "product_123", // Matches content_ids
quantity: 1,
item_price: 49.99,
},
],
content_type: "product",
currency: "USD",
value: 49.99,
num_items: 1,
order_id: "order_abc123", // Required for Purchase events
},
});
console.log("Purchase captured successfully");
} catch (error) {
console.error("Error capturing purchase:", error);
}
};
```
> **Note:** For more information on testing and verifying your ecommerce events, please see our [Meta Commerce Manager](/ecommerce-manager/meta-commerce-manager#testing-ecommerce-events) or [Google Commerce Manager](/ecommerce-manager/google-commerce-manager#verifying-events-in-google-ads) guide.
## Tracking Custom Events
From **React Native SDK v2.11.0**, custom events automatically include the `user_id` you set during `signup()` / `setUserData()`. The SDK stores this identifier securely on-device and attaches it to every `trackEvent` call, so you no longer need to pass it manually. Events tracked before signup are sent without a `user_id`.
Events are stored for all users, including organic ones. Events from users with no matching click are stored without campaign attribution. Call [`.signup`](#user-registration) so events are linked to a user. You can verify your events are being captured on the [Events](https://dashboard.linkrunner.io/dashboard/events) page. For capturing revenue, it is recommended to use the [`.capturePayment`](#revenue-tracking) method instead of `.trackEvent`.
Track custom events in your app:
```javascript theme={null}
const trackEvent = async () => {
await linkrunner.trackEvent(
"purchase_initiated", // Event name
{ product_id: "12345", category: "electronics", amount: 99.99 }, // Optional: Event data, include amount as a number for revenue sharing with ad networks like Google and Meta
"order_12345" // Optional: Your own unique event identifier (string or number)
);
};
```
#### Parameters for `linkrunner.trackEvent`
* `eventName`: string (required) - Name of the event to track
* `eventData`: object (optional) - Key-value pairs for additional event data, including Meta ecommerce properties
* `eventId`: string | number (optional) - Your own unique identifier for the event, useful for deduplication and correlating with your backend
### Revenue Sharing with Ad Networks
To enable revenue sharing with ad networks like Google Ads and Meta, include an `amount` parameter as a number in your custom event data. This allows the ad networks to optimize campaigns based on the revenue value of conversions:
```javascript theme={null}
const trackPurchaseEvent = async () => {
await linkrunner.trackEvent("purchase_completed", {
product_id: "12345",
category: "electronics",
amount: 149.99, // Revenue amount as a number
});
};
```
For revenue sharing with ad networks to work properly, ensure the `amount` parameter is passed as a number, not as a
string.
## Enhanced Privacy Controls
The SDK offers options to enhance user privacy:
```javascript theme={null}
// Enable PII (Personally Identifiable Information) hashing
linkrunner.enablePIIHashing(true);
```
When PII hashing is enabled, sensitive user data like name, email, and phone number are hashed using SHA-256 before being sent to Linkrunner servers.
## Uninstall Tracking
### Before you begin
Here's what you need to know before getting started:
**Requirements:**
* React Native SDK 2.8.0 and later
* [Firebase in your React Native project (Android)](https://rnfirebase.io/)
* [Registering your app with APNs (iOS)](https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns)
### Android
Connect Firebase Cloud Messaging (FCM) with Linkrunner
To configure FCM HTTP V1 for uninstalls:
**Enable the FCM API:**
1. Go to the [FCM console](https://console.firebase.google.com).
2. Select a project.
3. Go to **Project Overview** > **Project settings**.
4. Copy the Project ID. This will be required in a later step.
5. Go to the **Cloud Messaging** tab.
6. Make sure that Firebase Cloud Messaging API (V1) is set to Enabled.
**Create a custom role for Linkrunner Uninstall:**
1. Go to the **Service accounts** tab.
2. Click **Manage service account permissions**.
3. A new browser tab opens in Google Cloud Platform.
4. In the side menu, select **Roles**.
5. Click **+ Create role**.
6. Enter the following details:
* **Title**: Linkrunner Uninstalls
* **ID**: lr\_uninstalls
* **Role launch stage**: General availability
7. Click **+ Add permissions**.
8. In **Enter property name or value** field, enter `cloudmessaging.messages.create` and select it from the search results.
9. Check the **cloudmessaging.messages.create** option and click **Add**.
10. Click **Create**.
**Assign Linkrunner the FCM uninstall role:**
1. In the side menu, select **IAM**.
2. Open the **View by Principals** tab.
3. Click **Grant Access**.
4. In **Add Principals** -> **New principals** field, enter `lr-uninstalls-tracking@lr-uninstalls-tracking.iam.gserviceaccount.com`
5. In **Assign Roles** -> **Select a role** field, enter `Linkrunner Uninstalls` and select it from the search results.
6. Click **Save**.
The Linkrunner service account has been assigned the role of Linkrunner Uninstalls.
1. In Linkrunner, go to **Settings** > **Uninstall Tracking**.
2. Under the **Android** tab, enter the Firebase Project ID that you copied initially and click **Save**.
Follow these instructions to integrate FCM with the Linkrunner SDK:
1. **Set up Firebase Cloud Messaging:**
Set up Firebase Cloud Messaging in your React Native app. See the [React Native Firebase documentation](https://rnfirebase.io/messaging/usage) for detailed instructions.
2. Configure your app to provide the device's push token to the Linkrunner SDK.
```javascript theme={null}
import messaging from '@react-native-firebase/messaging';
import linkrunner from 'rn-linkrunner';
// Fetch FCM token and set in Linkrunner SDK
const initializePushToken = async () => {
const token = await messaging().getToken();
if (token) {
await linkrunner.setPushToken(token);
}
};
// Listen for token refresh
const setupTokenRefresh = () => {
messaging().onTokenRefresh(async (token) => {
await linkrunner.setPushToken(token);
});
};
// Handle incoming messages
const setupMessageListener = () => {
messaging().onMessage(async (remoteMessage) => {
if (remoteMessage.data && remoteMessage.data['lr-uninstall-tracking']) {
// Silent notification for uninstall tracking, ignore
return;
}
// Handle other messages here
});
};
```
Custom implementations of FCM's message handler can unintentionally make uninstall push notifications visible to users, disrupting the intended silent experience. To avoid this, ensure your logic checks if the message contains `lr-uninstall-tracking` and handles it accordingly, as shown in the code example above.
### iOS
Connect APNs with Linkrunner
Get the required credentials from the Apple Developer Portal:
**APNs Authentication Key (p8) and Key ID:**
* Go to the [Apple Developer Portal](https://developer.apple.com/account).
* Select **Identifiers** under **Certificates, IDs & Profiles**.
* Click on the app you want to track uninstalls for. Then, under **Capabilities**, search for **Push Notifications** and enable it.
* Under **Certificates, IDs & Profiles**, select **Keys** and click on plus (+) icon to create a key. Enable APNs when creating the key and download the key file (p8).
* The Key ID can be found in the **Keys** tab.
**Bundle ID and Team ID:**
* Under **Identifiers**, click on your app and you will see the Bundle ID and Team ID (App ID Prefix).
1. In Linkrunner, go to **Settings** > **Uninstall Tracking**.
2. Under the **iOS** tab, upload the APNs Authentication Key (p8) file and enter the Key ID, Bundle ID and Team ID (App ID Prefix) that you copied from the Apple Developer Portal.
Follow these instructions to integrate APNs with the Linkrunner SDK:
1. **Set up Firebase Cloud Messaging:**
Set up Firebase Cloud Messaging in your React Native app if you haven't already. See the [React Native Firebase documentation](https://rnfirebase.io/messaging/usage) for detailed instructions.
2. Configure your app to provide the device's APNs token to the Linkrunner SDK.
```javascript theme={null}
import messaging from '@react-native-firebase/messaging';
import linkrunner from 'rn-linkrunner';
import { Platform } from 'react-native';
const initializeAPNsToken = async () => {
if (Platform.OS === 'ios') {
const apnsToken = await messaging().getAPNSToken();
if (apnsToken) {
await linkrunner.setPushToken(apnsToken);
}
}
};
```
## Function Placement Guide
| Function | Where to Place | When to Call |
| ------------------------------- | ------------------------------------- | ---------------------------------------------- |
| `linkrunner.init` | App.tsx within useEffect | Once when app starts |
| `linkrunner.getAttributionData` | Attribution data handling flow | Whenever the attribution data is needed |
| `linkrunner.setAdditionalData` | Integration code | When third-party integration IDs are available |
| `linkrunner.signup` | Identification flow (signup or login) | Once when the user is identified |
| `linkrunner.setUserData` | Authentication logic | Every time app opens with logged-in user |
| `linkrunner.trackEvent` | Throughout app | When specific user actions occur |
| `linkrunner.capturePayment` | Payment processing | When user makes a payment |
| `linkrunner.removePayment` | Refund flow | When payment needs to be removed |
| `linkrunner.setPushToken` | Push notification setup | When FCM/APNs token is available |
| `linkrunner.handleDeeplink` | Deep link entry points | When app is opened via a deep link |
| `linkrunner.setConsent` | App initialization or consent flow | Before `init`, and again when consent changes |
## Next Steps
Validate your setup end-to-end
Configure deep links for your app
## Support
If you encounter issues during integration, contact us at [support@linkrunner.io](mailto:support@linkrunner.io).
# Shopify
Source: https://docs.linkrunner.io/sdk/shopify
Track visitors, campaigns, and purchases on a Shopify store with the Linkrunner Web SDK
Shopify stores need the Web SDK installed in **two places**, because Shopify does not
allow scripts on the checkout and thank-you pages. Your storefront gets a script tag;
your checkout gets a custom pixel.
Once both are in, every purchase is linked back to the ad, campaign, or link that
brought the shopper in.
This takes about 30 minutes and needs no developer access to your servers. Everything
is done from your Shopify admin.
## Before you start
Go to **Settings → Domains** in your Shopify admin. Your checkout must run on your
own domain (for example `checkout` pages under `yourstore.com`) for the purchase to
be linked to the shopper's browsing session.
Most Shopify stores are already set up this way. If your checkout runs on
`yourstore.myshopify.com` while your storefront is on `yourstore.com`, Step 3 below
is required rather than optional.
Web attribution is in beta, so tokens are issued by us. Email
[support@linkrunner.io](mailto:support@linkrunner.io) with your project name and
store domain, and we'll send you a Web SDK token.
Step 2 edits your theme's code. In **Online Store → Themes**, use **Actions →
Duplicate** on your live theme first, so you can roll back instantly.
## Installation
Go to **Online Store → Themes → ⋯ → Edit code** and open `layout/theme.liquid`.
Paste this immediately before the closing `` tag, replacing
`YOUR_WEB_SDK_TOKEN`:
```html theme={null}
```
Click **Save**. This tracks page views, traffic sources, campaigns, and ad clicks
across your storefront.
In the same `layout/theme.liquid` file, paste this directly below the script you
just added:
```html theme={null}
```
This attaches the visitor's ID to their cart so the purchase can still be matched
if checkout happens on a different domain, which is what Shop Pay does. Click
**Save**.
Strictly optional if your checkout is on your own domain and you don't use Shop
Pay. We recommend adding it anyway: it costs nothing and removes a whole class
of missing-attribution problems.
Go to **Settings → Customer events → Add custom pixel**. Name it `Linkrunner`.
This is a **new, separate pixel**. Do not paste this code into a pixel you already
have. Each custom pixel runs in its own sandbox, so any existing pixels (Google
Analytics, Meta, and so on) keep working and should be left untouched.
The code box arrives pre-filled with Shopify's commented placeholder starting
`// Step 1. Initialize the JavaScript pixel SDK`. Select all of it and delete it, then
paste the code below and replace `YOUR_WEB_SDK_TOKEN` with the token from Step 1.
```js theme={null}
var LR_TOKEN = "YOUR_WEB_SDK_TOKEN";
var LR_ENDPOINT = "https://api.linkrunner.io/web/collect";
var UTM_KEYS = ["utm_source", "utm_medium", "utm_campaign", "utm_id", "utm_term", "utm_content"];
var CLICK_ID_KEYS = ["gclid", "gbraid", "wbraid", "fbclid", "fbc", "fbp",
"ttclid", "twclid", "msclkid", "li_fat_id", "dclid", "irclickid"];
async function local(key) {
try { return (await browser.localStorage.getItem(key)) || ""; } catch (e) { return ""; }
}
async function session(key) {
try { return (await browser.sessionStorage.getItem(key)) || ""; } catch (e) { return ""; }
}
async function lastTouchUtms() {
try {
var record = JSON.parse(await local("lr_lt"));
if (record && record.u && Date.now() - record.t < 24 * 60 * 60 * 1000) return record.u;
} catch (e) {}
return null;
}
async function clickId(name) {
try { return JSON.parse(await local("lr_" + name)).v || ""; } catch (e) { return ""; }
}
analytics.subscribe("checkout_completed", async (event) => {
try {
var checkout = event.data.checkout || {};
var payload = {
token: LR_TOKEN,
event_id: "lr-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10),
event_type: "custom",
event_name: "purchase",
event_data: {
value: Number(checkout.totalPrice && checkout.totalPrice.amount) || 0,
currency: checkout.currencyCode || "",
order_id: (checkout.order && checkout.order.id) || "",
email: checkout.email || "",
phone: checkout.phone || "",
},
visitor_id: await local("lr_vid"),
session_id: await session("lr_sid"),
user_id: await local("lr_uid"),
page_url: event.context.document.location.href,
client_timestamp: new Date().toISOString(),
};
if (!payload.visitor_id) {
(checkout.attributes || []).forEach(function (a) {
if (a.key === "lr_vid") payload.visitor_id = a.value || "";
});
}
var lastTouch = await lastTouchUtms();
for (var k of UTM_KEYS) {
payload[k] = lastTouch ? lastTouch[k] || "" : await session("lr_" + k);
payload["ft_" + k] = await local("lr_ft_" + k);
}
for (var c of CLICK_ID_KEYS) {
payload[c] = await clickId(c);
payload["ft_" + c] = await local("lr_ft_" + c);
}
payload.ft_traffic_source_type = await local("lr_ft_traffic_source_type");
payload.ft_traffic_source_name = await local("lr_ft_traffic_source_name");
payload.traffic_source_type = (await session("lr_ts_type")) || payload.ft_traffic_source_type;
payload.traffic_source_name = (await session("lr_ts_name")) || payload.ft_traffic_source_name;
await fetch(LR_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
keepalive: true,
});
} catch (e) {}
});
```
The empty `catch` at the end is deliberate. A custom pixel must never throw, because
an error there can interrupt the checkout. Failures are swallowed silently, which is
why you verify with the steps below rather than by watching for errors.
**This sends your shoppers' email and phone to Linkrunner.** They are what link a
purchase to an identifiable person, so audience exports and person-level reporting
depend on them.
Make sure your privacy notice covers sharing customer contact details with
Linkrunner and your data processing agreement with us is in place. If you would
rather not send them, comment out the `email` and `phone` lines in `event_data`.
Attribution, campaign reporting, and revenue all work without them; you lose
person-level audiences.
Above the code box, check the two **Customer privacy** settings. The defaults are
already right for Linkrunner, so in most cases you are confirming rather than changing:
* **Permission**: leave **Required** selected, with **Marketing** and **Analytics**
ticked. Analytics covers page views and sessions; Marketing covers the ad click IDs
and campaign data that attribution depends on. **Preferences** is unused, leave it
unticked.
* **Data sale**: leave **Data collected qualifies as data sale** selected. The pixel
then stops collecting for shoppers who opt out of having their data sold.
Choosing **Not required** makes the pixel collect regardless of consent. That is a
legal decision about the markets you sell in, not a technical one. Check with whoever
handles privacy at your company before changing it.
Click **Save**, then click **Connect**.
**Save and Connect are two separate actions.** A saved but unconnected pixel looks
installed and never runs. This is the single most common reason purchases don't
appear.
1. Visit your store with test campaign parameters, for example
`https://yourstore.com/?utm_source=meta&utm_medium=cpc&utm_campaign=test`
2. Browse a product, add it to the cart, and complete a real order
3. Open [Web Events](https://dashboard.linkrunner.io/dashboard/web-events) in your Linkrunner dashboard
You should see page views for the visit and a `purchase` event carrying the order
value, all attributed to `utm_campaign=test`.
Refund the test order afterwards. Refunding does not remove the tracked event, which
is what you want, because you are confirming tracking, not revenue.
## Limitations and constraints
Please read these before going live. Most are Shopify platform behaviour, not Linkrunner
settings, and cannot be worked around.
When a shopper checks out with Shop Pay, the checkout is served by `shop.app`, not
your store. Browser security prevents the pixel from reading anything your storefront
saved.
The cart-attribute snippet in **Step 2** is what keeps these purchases attributed. If
you use Shop Pay, Step 2 is **required**.
Custom pixels do not run in theme preview links. Test on the published theme, or your
purchase events will never fire.
If your store uses Shopify's customer privacy controls, a custom pixel will not run
until the visitor grants the consent category the pixel is assigned to.
If you see page views but no purchases, check the pixel's **Permission** setting under
**Settings → Customer events** first.
Development stores and stores behind a password page keep the storefront gated. Enter
the password first, then navigate to your campaign URL. Otherwise Shopify strips the
campaign parameters during the redirect and the visit records with no campaign.
Safari deletes browser storage written by scripts after 7 days of inactivity. A Safari
visitor who first arrives from an ad and returns more than a week later will be counted
as a new visitor.
This affects all web analytics tools equally and is not specific to Linkrunner.
Some browser extensions block analytics requests. Expect a small gap between Shopify's
own order count and the purchases recorded here. Shopify's admin remains the source of
truth for revenue.
The `cdn.linkrunner.io` URL in Step 1 always serves the current version, so there is
nothing to install or keep updated. If you load the SDK from somewhere else, use
v0.1.15 or later for full campaign attribution at checkout.
## Troubleshooting
Open your storefront, press F12, and check the **Network** tab for requests
to `api.linkrunner.io`.
* No requests: the script tag is missing or the theme wasn't saved
* `401` responses: wrong token. Confirm you used the **Web SDK** token
In order of likelihood:
1. The pixel was saved but never **Connected**
2. Customer privacy settings are blocking it (see Limitations)
3. You tested on a theme preview instead of the published theme
4. The token in the pixel doesn't match the one in the theme
Usually Shop Pay. Confirm the **Step 2** cart snippet is installed and saved, then place
a fresh test order, because existing carts won't have the attribute attached.
Check that your products have prices set. The pixel reads the order total directly from
Shopify's checkout data, so a zero total means a zero-priced order.
## What gets tracked
| Where | What |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| Storefront pages | Page views, traffic source, campaign, ad click IDs, first and last touch |
| Cart | Visitor ID attached for checkout matching |
| Checkout | `purchase` event with order value, currency, order ID, item count, and the customer's email and phone (used for person-level audiences) |
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Unity
Source: https://docs.linkrunner.io/sdk/unity
Guide for integrating Linkrunner in Unity apps using native Android and iOS SDKs
**Prefer to let your AI coding agent do this?** Install the Linkrunner skill and Claude Code, Cursor, GitHub Copilot, or Windsurf will wire up the native bridges and deep links for you:
```bash theme={null}
npx @linkrunner/skills add unity
```
Then ask your agent to "integrate Linkrunner". See [Linkrunner Agent Skills](https://github.com/linkrunner-labs/skills).
Linkrunner does not have a dedicated Unity SDK. This guide walks you through integrating the native Android and iOS SDKs into your Unity project using platform bridge plugins.
## Prerequisites
* **Unity 2021.3 LTS** or newer
* **Android**: Android 5.0+ (API 21), Gradle 8.0+
* **iOS**: iOS 15.0+, Xcode 14.0+, Swift 5.9+
* A Linkrunner project token — get it from [Dashboard → Documentation](https://dashboard.linkrunner.io/dashboard?s=members\&m=documentation)
**Important assumptions in this guide:**
1. **iOS requires a Swift bridging layer.** The Linkrunner iOS SDK (`LinkrunnerKit`) is a pure Swift module — it does not inherit from `NSObject` and has no `@objc` annotations. Since Unity's native plugin system uses C/Objective-C, we provide a **Swift wrapper file** (`LinkrunnerUnityBridge.swift`) that exposes `@_cdecl` C-callable functions which internally call the Swift SDK's `async` methods.
2. **Android uses a Java-friendly callback wrapper.** The Linkrunner Android SDK provides `LinkrunnerJava`, a callback-based wrapper around the Kotlin SDK. This avoids Kotlin coroutine interop issues (Result\ mangling, Continuation type mismatches, Kotlin version conflicts) that prevent Java callers from using the suspend-based API directly.
3. **iOS SPM dependency must be re-added** each time Unity regenerates the Xcode project. See the [Automation Tips](#automating-ios-dependency-with-a-post-build-script) section for a partial workaround.
4. **The bridge code is reference code**, not a drop-in package. You may need to adjust method signatures or imports based on your Unity version, Xcode version, and SDK version updates.
## Architecture Overview
The integration uses Unity's native plugin system:
* **Android**: A Java bridge class calls `LinkrunnerJava` (the SDK's callback-based Java API) and communicates results back to Unity via `UnitySendMessage`.
* **iOS**: A Swift bridge file wraps the Linkrunner iOS SDK's `async` methods into C-callable functions (via `@_cdecl`), which Unity calls through `[DllImport("__Internal")]`.
* **C#**: A `LinkrunnerSDK.cs` wrapper provides a unified cross-platform API.
```
C# (LinkrunnerSDK.cs)
├── Android → Java Bridge → io.linkrunner.sdk.LinkrunnerJava (callback API)
└── iOS → Swift Bridge (@_cdecl) → LinkrunnerSDK.shared (Swift async)
```
***
## Android Setup
### Step 1: Add the SDK Dependency
In your Unity project, create or edit the file `Assets/Plugins/Android/mainTemplate.gradle` (or your custom Gradle template) and add:
```gradle theme={null}
dependencies {
implementation 'io.linkrunner:android-sdk:4.1.0'
}
```
Make sure Maven Central is included in your repositories. In `Assets/Plugins/Android/settingsTemplate.gradle`:
```gradle theme={null}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
```
If you're using Unity 2022.2+ with the Gradle template system, you may need to add the dependency in `unityLibrary/build.gradle` instead. Check Unity's documentation for your version.
### Step 2: Permissions
Add the following to your `Assets/Plugins/Android/AndroidManifest.xml`:
```xml theme={null}
```
If your app targets children and you need to disable advertising ID collection, remove the `AD_ID` permission:
Disabling AAID collection is not recommended unless absolutely necessary. The GAID is a primary signal for Google Ads attribution and install matching, so disabling it reduces attribution accuracy. Only disable it if your app is built for children or families and must comply with Google Play's Family Policy.
```xml theme={null}
```
### Step 3: Create the Java Bridge
Create `Assets/Plugins/Android/LinkrunnerBridge.java`:
```java theme={null}
package com.linkrunner.unity;
import android.app.Activity;
import android.util.Log;
import com.unity3d.player.UnityPlayer;
import io.linkrunner.sdk.LinkRunner;
import io.linkrunner.sdk.LinkrunnerJava;
import io.linkrunner.sdk.LinkRunnerCallback;
import io.linkrunner.sdk.models.ConsentStatus;
import io.linkrunner.sdk.models.LinkrunnerConsent;
import io.linkrunner.sdk.models.request.UserDataRequest;
import io.linkrunner.sdk.models.request.CapturePaymentRequest;
import io.linkrunner.sdk.models.request.RemovePaymentRequest;
import io.linkrunner.sdk.models.IntegrationData;
import io.linkrunner.sdk.models.PaymentType;
import io.linkrunner.sdk.models.PaymentStatus;
import io.linkrunner.sdk.models.response.AttributionData;
import org.json.JSONObject;
import org.json.JSONException;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
public class LinkrunnerBridge {
private static final String TAG = "LinkrunnerBridge";
private static final String UNITY_GAME_OBJECT = "LinkrunnerCallbackHandler";
private static void sendToUnity(String method, String message) {
UnityPlayer.UnitySendMessage(UNITY_GAME_OBJECT, method, message);
}
private static Map jsonToMap(String json) {
Map map = new HashMap<>();
if (json == null || json.isEmpty()) return map;
try {
JSONObject obj = new JSONObject(json);
Iterator keys = obj.keys();
while (keys.hasNext()) {
String key = keys.next();
Object value = obj.get(key);
map.put(key, value);
}
} catch (JSONException e) {
Log.e(TAG, "Failed to parse JSON: " + e.getMessage());
}
return map;
}
private static PaymentType parsePaymentType(String type) {
if (type == null) return PaymentType.DEFAULT;
switch (type) {
case "FIRST_PAYMENT": return PaymentType.FIRST_PAYMENT;
case "SECOND_PAYMENT": return PaymentType.SECOND_PAYMENT;
case "WALLET_TOPUP": return PaymentType.WALLET_TOPUP;
case "FUNDS_WITHDRAWAL": return PaymentType.FUNDS_WITHDRAWAL;
case "SUBSCRIPTION_CREATED": return PaymentType.SUBSCRIPTION_CREATED;
case "SUBSCRIPTION_RENEWED": return PaymentType.SUBSCRIPTION_RENEWED;
case "ONE_TIME": return PaymentType.ONE_TIME;
case "RECURRING": return PaymentType.RECURRING;
default: return PaymentType.DEFAULT;
}
}
private static PaymentStatus parsePaymentStatus(String status) {
if (status == null) return PaymentStatus.PAYMENT_COMPLETED;
switch (status) {
case "PAYMENT_INITIATED": return PaymentStatus.PAYMENT_INITIATED;
case "PAYMENT_COMPLETED": return PaymentStatus.PAYMENT_COMPLETED;
case "PAYMENT_FAILED": return PaymentStatus.PAYMENT_FAILED;
case "PAYMENT_CANCELLED": return PaymentStatus.PAYMENT_CANCELLED;
default: return PaymentStatus.PAYMENT_COMPLETED;
}
}
// ---- Public API called from Unity C# ----
public static void initialize(String token, String secretKey, String keyId, boolean debug) {
Activity activity = UnityPlayer.currentActivity;
LinkrunnerJava lr = LinkrunnerJava.getInstance();
lr.init(
activity.getApplicationContext(),
token,
null, // link
null, // source
secretKey.isEmpty() ? null : secretKey,
keyId.isEmpty() ? null : keyId,
debug,
new LinkRunnerCallback() {
public void onSuccess(Void result) {
sendToUnity("OnInitComplete", "success");
}
public void onError(String error) {
Log.e(TAG, "Init failed: " + error);
sendToUnity("OnInitComplete", "error:" + error);
}
}
);
}
public static void signup(String userDataJson, String additionalDataJson) {
try {
JSONObject obj = new JSONObject(userDataJson);
UserDataRequest userData = new UserDataRequest(
obj.optString("id", ""),
obj.optString("name", null),
obj.optString("phone", null),
obj.optString("email", null),
obj.optString("mixpanelDistinctId", null),
obj.optString("amplitudeDeviceId", null),
obj.optString("posthogDistinctId", null),
obj.optString("brazeDeviceId", null),
obj.optString("gaAppInstanceId", null),
obj.optString("gaSessionId", null),
obj.optString("userCreatedAt", null),
obj.has("isFirstTimeUser") ? obj.optBoolean("isFirstTimeUser") : null
);
Map additionalData = jsonToMap(additionalDataJson);
LinkrunnerJava lr = LinkrunnerJava.getInstance();
lr.signup(
userData,
additionalData.isEmpty() ? null : additionalData,
new LinkRunnerCallback() {
public void onSuccess(Void result) {
sendToUnity("OnSignupComplete", "success");
}
public void onError(String error) {
Log.e(TAG, "Signup failed: " + error);
sendToUnity("OnSignupComplete", "error:" + error);
}
}
);
} catch (JSONException e) {
Log.e(TAG, "Signup JSON parse failed: " + e.getMessage());
sendToUnity("OnSignupComplete", "error:" + e.getMessage());
}
}
public static void setUserData(String userDataJson) {
try {
JSONObject obj = new JSONObject(userDataJson);
UserDataRequest userData = new UserDataRequest(
obj.optString("id", ""),
obj.optString("name", null),
obj.optString("phone", null),
obj.optString("email", null),
obj.optString("mixpanelDistinctId", null),
obj.optString("amplitudeDeviceId", null),
obj.optString("posthogDistinctId", null),
obj.optString("brazeDeviceId", null),
obj.optString("gaAppInstanceId", null),
obj.optString("gaSessionId", null),
obj.optString("userCreatedAt", null),
obj.has("isFirstTimeUser") ? obj.optBoolean("isFirstTimeUser") : null
);
LinkrunnerJava lr = LinkrunnerJava.getInstance();
lr.setUserData(
userData,
new LinkRunnerCallback() {
public void onSuccess(Void result) {
sendToUnity("OnSetUserDataComplete", "success");
}
public void onError(String error) {
Log.e(TAG, "SetUserData failed: " + error);
sendToUnity("OnSetUserDataComplete", "error:" + error);
}
}
);
} catch (JSONException e) {
Log.e(TAG, "SetUserData JSON parse failed: " + e.getMessage());
sendToUnity("OnSetUserDataComplete", "error:" + e.getMessage());
}
}
public static void getAttributionData() {
LinkrunnerJava lr = LinkrunnerJava.getInstance();
lr.getAttributionData(
new LinkRunnerCallback() {
public void onSuccess(AttributionData result) {
try {
JSONObject json = new JSONObject();
if (result != null) {
json.put("raw", result.toString());
}
sendToUnity("OnAttributionDataReceived", json.toString());
} catch (JSONException e) {
sendToUnity("OnAttributionDataReceived", "error:" + e.getMessage());
}
}
public void onError(String error) {
Log.e(TAG, "GetAttributionData failed: " + error);
sendToUnity("OnAttributionDataReceived", "error:" + error);
}
}
);
}
public static void trackEvent(String eventName, String eventDataJson, String eventId) {
Map eventData = jsonToMap(eventDataJson);
LinkrunnerJava lr = LinkrunnerJava.getInstance();
lr.trackEvent(
eventName,
eventData.isEmpty() ? null : eventData,
eventId.isEmpty() ? null : eventId,
new LinkRunnerCallback() {
public void onSuccess(Void result) {
sendToUnity("OnTrackEventComplete", "success");
}
public void onError(String error) {
Log.e(TAG, "TrackEvent failed: " + error);
sendToUnity("OnTrackEventComplete", "error:" + error);
}
}
);
}
public static void capturePayment(String userId, double amount, String paymentId,
String type, String status, String eventDataJson) {
Map eventData = jsonToMap(eventDataJson);
CapturePaymentRequest request = new CapturePaymentRequest(
paymentId.isEmpty() ? null : paymentId,
userId,
amount,
parsePaymentType(type),
parsePaymentStatus(status),
eventData.isEmpty() ? null : eventData
);
LinkrunnerJava lr = LinkrunnerJava.getInstance();
lr.capturePayment(
request,
new LinkRunnerCallback() {
public void onSuccess(Void result) {
sendToUnity("OnCapturePaymentComplete", "success");
}
public void onError(String error) {
Log.e(TAG, "CapturePayment failed: " + error);
sendToUnity("OnCapturePaymentComplete", "error:" + error);
}
}
);
}
public static void removePayment(String userId, String paymentId) {
RemovePaymentRequest request = new RemovePaymentRequest(
paymentId.isEmpty() ? null : paymentId,
userId.isEmpty() ? null : userId
);
LinkrunnerJava lr = LinkrunnerJava.getInstance();
lr.removePayment(
request,
new LinkRunnerCallback() {
public void onSuccess(Void result) {
sendToUnity("OnRemovePaymentComplete", "success");
}
public void onError(String error) {
Log.e(TAG, "RemovePayment failed: " + error);
sendToUnity("OnRemovePaymentComplete", "error:" + error);
}
}
);
}
public static void setPushToken(String pushToken) {
LinkrunnerJava lr = LinkrunnerJava.getInstance();
lr.setPushToken(pushToken, null);
}
public static void setAdditionalData(String clevertapId) {
IntegrationData data = new IntegrationData(
clevertapId.isEmpty() ? null : clevertapId
);
LinkrunnerJava lr = LinkrunnerJava.getInstance();
lr.setAdditionalData(data, null);
}
public static void enablePIIHashing(boolean enabled) {
LinkrunnerJava.getInstance().enablePIIHashing(enabled);
}
public static void setDisableAaidCollection(boolean disabled) {
LinkrunnerJava.getInstance().setDisableAaidCollection(disabled);
}
private static ConsentStatus toConsentStatus(String value) {
if (value == null) return ConsentStatus.UNKNOWN;
try {
return ConsentStatus.valueOf(value.toUpperCase());
} catch (IllegalArgumentException e) {
return ConsentStatus.UNKNOWN;
}
}
public static void setConsent(String isEEA, String dataUsage, String adsPersonalization) {
LinkRunner.getInstance().setConsent(
new LinkrunnerConsent(
toConsentStatus(isEEA),
toConsentStatus(dataUsage),
toConsentStatus(adsPersonalization)
)
);
}
public static void handleDeeplink(String deeplinkUrl) {
if (deeplinkUrl == null || deeplinkUrl.isEmpty()) {
Log.d(TAG, "handleDeeplink called with null or empty URL, ignoring");
sendToUnity("OnHandleDeeplinkComplete", "{\"deeplink\":null,\"is_linkrunner\":false}");
return;
}
LinkrunnerJava lr = LinkrunnerJava.getInstance();
lr.handleDeeplink(
deeplinkUrl,
new LinkRunnerCallback() {
public void onSuccess(io.linkrunner.sdk.models.response.DeeplinkData result) {
try {
JSONObject json = new JSONObject();
// Always include keys with defaults for stable payload shape
json.put("deeplink", result != null ? result.getDeeplink() : JSONObject.NULL);
json.put("is_linkrunner", result != null && result.isLinkrunner());
if (result != null && result.getProcessing() != null) {
json.put("processing", result.getProcessing());
}
sendToUnity("OnHandleDeeplinkComplete", json.toString());
} catch (JSONException e) {
sendToUnity("OnHandleDeeplinkComplete", "error:" + e.getMessage());
}
}
public void onError(String error) {
Log.e(TAG, "HandleDeeplink failed: " + error);
sendToUnity("OnHandleDeeplinkComplete", "error:" + error);
}
}
);
}
}
```
The bridge uses `LinkrunnerJava`, a callback-based wrapper included in the Android SDK. It handles all Kotlin coroutine execution internally — no Kotlin imports, no `BuildersKt.runBlocking`, and no background thread management needed in your bridge code. Callbacks are invoked on a background IO thread, which is safe for `UnitySendMessage`.
***
## iOS Setup
### Step 1: Add the SDK
After building your Unity project for iOS and opening the generated Xcode project:
1. In Xcode, select **File** → **Add Package Dependencies...**
2. Enter the repository URL:
```
https://github.com/linkrunner-labs/linkrunner-ios.git
```
3. Select the latest version (4.1.0+)
4. Click **Add Package** and choose the **LinkrunnerKitStatic** library
You'll need to add the SPM dependency each time you regenerate the Xcode project from Unity. Consider using a post-build script to automate this — see the [Automation Tips](#automating-ios-dependency-with-a-post-build-script) section.
### Step 2: Info.plist Configuration
Add to your `Info.plist` (can also be done via Unity's `Assets/Plugins/iOS/Info.plist` additions):
```xml theme={null}
NSUserTrackingUsageDescription
This identifier will be used to deliver personalized ads and improve your app experience.
NSAdvertisingAttributionReportEndpoint
https://linkrunner-skan.com
AttributionCopyEndpoint
https://linkrunner-skan.com
```
### Step 3: Create the Swift Bridge
The Linkrunner iOS SDK is a **pure Swift module** — it does not inherit from `NSObject` and has no `@objc` annotations, so it cannot be called directly from Objective-C. We use a Swift bridge file with `@_cdecl` to expose C-callable functions that Unity can invoke via `[DllImport("__Internal")]`.
Create `Assets/Plugins/iOS/LinkrunnerUnityBridge.swift`:
```swift theme={null}
import Foundation
import LinkrunnerKit
// MARK: - Unity interop
// UnitySendMessage is a C function provided by Unity's runtime.
// We declare it here so Swift can call it.
@_silgen_name("UnitySendMessage")
func UnitySendMessage(_ obj: UnsafePointer,
_ method: UnsafePointer,
_ msg: UnsafePointer)
private let unityGameObject = "LinkrunnerCallbackHandler"
private func sendToUnity(_ method: String, _ message: String) {
method.withCString { m in
message.withCString { msg in
unityGameObject.withCString { obj in
UnitySendMessage(obj, m, msg)
}
}
}
}
private func toDict(_ cString: UnsafePointer?) -> [String: Any] {
guard let cString = cString else { return [:] }
let str = String(cString: cString)
guard !str.isEmpty,
let data = str.data(using: .utf8),
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return [:] }
return dict
}
private func toOptionalString(_ cString: UnsafePointer?) -> String? {
guard let cString = cString else { return nil }
let str = String(cString: cString)
return str.isEmpty ? nil : str
}
private func parseUserData(_ dict: [String: Any]) -> UserData {
return UserData(
id: dict["id"] as? String ?? "",
name: dict["name"] as? String,
phone: dict["phone"] as? String,
email: dict["email"] as? String,
isFirstTimeUser: dict["isFirstTimeUser"] as? Bool,
userCreatedAt: dict["userCreatedAt"] as? String,
mixPanelDistinctId: dict["mixpanelDistinctId"] as? String,
amplitudeDeviceId: dict["amplitudeDeviceId"] as? String,
posthogDistinctId: dict["posthogDistinctId"] as? String,
brazeDeviceId: dict["brazeDeviceId"] as? String,
gaAppInstanceId: dict["gaAppInstanceId"] as? String,
gaSessionId: dict["gaSessionId"] as? String
)
}
private func parsePaymentType(_ str: String?) -> PaymentType {
switch str {
case "FIRST_PAYMENT": return .firstPayment
case "SECOND_PAYMENT": return .secondPayment
case "WALLET_TOPUP": return .walletTopup
case "FUNDS_WITHDRAWAL": return .fundsWithdrawal
case "SUBSCRIPTION_CREATED": return .subscriptionCreated
case "SUBSCRIPTION_RENEWED": return .subscriptionRenewed
case "ONE_TIME": return .oneTime
case "RECURRING": return .recurring
default: return .default
}
}
private func parsePaymentStatus(_ str: String?) -> PaymentStatus {
switch str {
case "PAYMENT_INITIATED": return .initiated
case "PAYMENT_COMPLETED": return .completed
case "PAYMENT_FAILED": return .failed
case "PAYMENT_CANCELLED": return .cancelled
default: return .completed
}
}
// MARK: - C-callable functions exposed to Unity
@_cdecl("_LinkrunnerInitialize")
func linkrunnerInitialize(_ token: UnsafePointer,
_ secretKey: UnsafePointer?,
_ keyId: UnsafePointer?,
_ disableIdfa: Bool,
_ debug: Bool) {
let tokenStr = String(cString: token)
let secretKeyStr = toOptionalString(secretKey)
let keyIdStr = toOptionalString(keyId)
Task {
await LinkrunnerSDK.shared.initialize(
token: tokenStr,
secretKey: secretKeyStr,
keyId: keyIdStr,
disableIdfa: disableIdfa,
debug: debug
)
sendToUnity("OnInitComplete", "success")
}
}
@_cdecl("_LinkrunnerSignup")
func linkrunnerSignup(_ userDataJson: UnsafePointer?,
_ additionalDataJson: UnsafePointer?) {
let userDict = toDict(userDataJson)
let additionalDict = toDict(additionalDataJson)
let userData = parseUserData(userDict)
Task {
await LinkrunnerSDK.shared.signup(
userData: userData,
additionalData: additionalDict.isEmpty ? nil : additionalDict
)
sendToUnity("OnSignupComplete", "success")
}
}
@_cdecl("_LinkrunnerSetUserData")
func linkrunnerSetUserData(_ userDataJson: UnsafePointer?) {
let userDict = toDict(userDataJson)
let userData = parseUserData(userDict)
Task {
await LinkrunnerSDK.shared.setUserData(userData)
sendToUnity("OnSetUserDataComplete", "success")
}
}
@_cdecl("_LinkrunnerGetAttributionData")
func linkrunnerGetAttributionData() {
Task {
let response = await LinkrunnerSDK.shared.getAttributionData()
var result: [String: Any] = [:]
if let deeplink = response.deeplink { result["deeplink"] = deeplink }
result["attributionSource"] = response.attributionSource
if let campaign = response.campaignData {
var campaignDict: [String: Any] = [:]
campaignDict["id"] = campaign.id
campaignDict["name"] = campaign.name
if let groupName = campaign.groupName { campaignDict["groupName"] = groupName }
if let assetName = campaign.assetName { campaignDict["assetName"] = assetName }
if let assetGroupName = campaign.assetGroupName { campaignDict["assetGroupName"] = assetGroupName }
result["campaignData"] = campaignDict
}
if let jsonData = try? JSONSerialization.data(withJSONObject: result),
let jsonStr = String(data: jsonData, encoding: .utf8) {
sendToUnity("OnAttributionDataReceived", jsonStr)
} else {
sendToUnity("OnAttributionDataReceived", "error:serialization_failed")
}
}
}
@_cdecl("_LinkrunnerTrackEvent")
func linkrunnerTrackEvent(_ eventName: UnsafePointer,
_ eventDataJson: UnsafePointer?,
_ eventId: UnsafePointer?) {
let name = String(cString: eventName)
let eventData = toDict(eventDataJson)
let eid = toOptionalString(eventId)
Task {
await LinkrunnerSDK.shared.trackEvent(
eventName: name,
eventData: eventData.isEmpty ? nil : eventData,
eventId: eid
)
sendToUnity("OnTrackEventComplete", "success")
}
}
@_cdecl("_LinkrunnerCapturePayment")
func linkrunnerCapturePayment(_ userId: UnsafePointer,
_ amount: Double,
_ paymentId: UnsafePointer?,
_ type: UnsafePointer?,
_ status: UnsafePointer?,
_ eventDataJson: UnsafePointer?) {
let uid = String(cString: userId)
let pid = toOptionalString(paymentId)
let paymentType = parsePaymentType(toOptionalString(type))
let paymentStatus = parsePaymentStatus(toOptionalString(status))
let eventData = toDict(eventDataJson)
Task {
await LinkrunnerSDK.shared.capturePayment(
amount: amount,
userId: uid,
// LinkrunnerKit 4.x requires a payment id. An empty value is rejected by the SDK.
paymentId: pid ?? "",
type: paymentType,
status: paymentStatus,
eventData: eventData.isEmpty ? nil : eventData
)
sendToUnity("OnCapturePaymentComplete", "success")
}
}
@_cdecl("_LinkrunnerRemovePayment")
func linkrunnerRemovePayment(_ userId: UnsafePointer,
_ paymentId: UnsafePointer?) {
let uid = String(cString: userId)
let pid = toOptionalString(paymentId)
Task {
await LinkrunnerSDK.shared.removePayment(userId: uid, paymentId: pid)
sendToUnity("OnRemovePaymentComplete", "success")
}
}
@_cdecl("_LinkrunnerSetPushToken")
func linkrunnerSetPushToken(_ pushToken: UnsafePointer) {
let token = String(cString: pushToken)
Task {
await LinkrunnerSDK.shared.setPushToken(token)
}
}
@_cdecl("_LinkrunnerSetAdditionalData")
func linkrunnerSetAdditionalData(_ clevertapId: UnsafePointer?) {
let ctId = toOptionalString(clevertapId)
let data = IntegrationData(clevertapId: ctId)
Task {
await LinkrunnerSDK.shared.setAdditionalData(data)
}
}
@_cdecl("_LinkrunnerEnablePIIHashing")
func linkrunnerEnablePIIHashing(_ enabled: Bool) {
LinkrunnerSDK.shared.enablePIIHashing(enabled)
}
@_cdecl("_LinkrunnerSetConsent")
func linkrunnerSetConsent(_ isEEA: UnsafePointer?,
_ dataUsage: UnsafePointer?,
_ adsPersonalization: UnsafePointer?) {
func status(_ value: UnsafePointer?) -> ConsentStatus {
guard let raw = toOptionalString(value) else { return .unknown }
return ConsentStatus(rawValue: raw.lowercased()) ?? .unknown
}
LinkrunnerSDK.shared.setConsent(
LinkrunnerConsent(
isEEA: status(isEEA),
hasConsentForDataUsage: status(dataUsage),
hasConsentForAdsPersonalization: status(adsPersonalization)
)
)
}
@_cdecl("_LinkrunnerHandleDeeplink")
func linkrunnerHandleDeeplink(_ deeplinkUrl: UnsafePointer?) {
let url = toOptionalString(deeplinkUrl)
Task {
let response = await LinkrunnerSDK.shared.handleDeeplink(url: url)
var result: [String: Any] = [:]
result["deeplink"] = response.deeplink as Any
result["is_linkrunner"] = response.isLinkrunner
if let processing = response.processing {
result["processing"] = processing
}
if let jsonData = try? JSONSerialization.data(withJSONObject: result),
let jsonStr = String(data: jsonData, encoding: .utf8) {
sendToUnity("OnHandleDeeplinkComplete", jsonStr)
} else {
sendToUnity("OnHandleDeeplinkComplete", "error:serialization_failed")
}
}
}
```
**Swift files in Unity iOS plugins:** Unity does not natively compile `.swift` files in `Assets/Plugins/iOS/`. After Unity generates the Xcode project, you must manually add `LinkrunnerUnityBridge.swift` to the Xcode project's **UnityFramework** target and ensure:
1. The file is added to the **Compile Sources** build phase of the UnityFramework target
2. A **bridging header** exists (Xcode usually prompts to create one when adding the first Swift file)
3. **SWIFT\_VERSION** is set to 5.0+ in the UnityFramework build settings
Alternatively, you can automate this with a Unity `PostProcessBuild` script — see the [Automation Tips](#automating-ios-dependency-with-a-post-build-script) section.
### Google Integrated Conversion Measurement (Optional)
Integrated Conversion Measurement (ICM) recovers Google App Campaign installs on iOS that Google cannot attribute because there is no click identifier and no IDFA to match on. Google's On-Device Measurement (ODM) SDK turns the click context into an encrypted signal that never leaves the device, and Linkrunner sends it with the install. See [Google ICM](/features/google-icm) for how it works.
Set this up if you run Google App Campaigns for your iOS app. Requires LinkrunnerKit 4.1.0 or later and `io.linkrunner:android-sdk:4.1.0` or later.
Google keeps ODM inactive for users in the European Economic Area, the United Kingdom, and Switzerland, so ICM recovers nothing for that traffic. Elsewhere, Google reports improved coverage for iOS 14+ users.
ICM also needs an **iOS link ID** configured in your Google Ads integration. Google has nowhere to send the conversion without one. See [Prerequisites](/features/google-icm#prerequisites).
Linkrunner does not bundle this SDK, so apps that skip ICM carry none of its weight. In the generated Xcode project, select **File** → **Add Package Dependencies...**, enter:
```
https://github.com/googleads/google-ads-on-device-conversion-ios-sdk
```
Select the `GoogleAdsOnDeviceConversion` product and add it to the same target you added **LinkrunnerKitStatic** to (**UnityFramework**).
Like the LinkrunnerKit dependency, this has to be re-added each time Unity regenerates the Xcode project. Handle it in the same post-build script.
In **Build Settings** → **Other Linker Flags** on the target that links Google's SDK (**UnityFramework**), add:
```
-ObjC
```
Without it, ICM silently does nothing and your build still succeeds. Linkrunner finds Google's class through the Objective-C runtime, so nothing references it at link time, and the linker drops it from Google's static library. `-lc++` is applied for you by Google's `Package.swift`.
Call `SetConsent` with your consent values before `Initialize`, and again whenever the user changes their choice:
```csharp theme={null}
LinkrunnerSDK.SetConsent(
isEEA: "granted",
hasConsentForDataUsage: "granted",
hasConsentForAdsPersonalization: "denied"
);
LinkrunnerSDK.Initialize("YOUR_PROJECT_TOKEN");
```
Each signal takes `"granted"`, `"denied"`, or `"unknown"`. Anything left `"unknown"` is dropped from the payload rather than reported as a denial. `SetConsent` works on both iOS and Android, using the bridge methods added in the [Java bridge](#step-3-create-the-java-bridge) and [Swift bridge](#step-3-create-the-swift-bridge) above.
| Parameter | Meaning |
| --------------------------------- | ------------------------------------------------------------------------- |
| `isEEA` | European regulations apply to this user (the EEA, the UK, or Switzerland) |
| `hasConsentForDataUsage` | The user agreed to their data being sent to Google for advertising |
| `hasConsentForAdsPersonalization` | The user agreed to their data being used to personalize ads |
Google treats these as required whenever their value is known. `hasConsentForDataUsage` decides whether Google may use the conversion at all, `hasConsentForAdsPersonalization` decides whether it may feed audiences and remarketing, and `isEEA` tells Google which rules apply. Set them from your app's real consent state rather than hardcoding them. **For users outside the EEA, the UK, and Switzerland, report `isEEA` as denied and leave the other two unset.** See [Send Consent](/features/send-consent).
#### Verifying your setup
Initialize with debug mode on and look for this line in the Xcode console:
```
Linkrunner: odm_available=true odm_fetch_result=success odm_fetch_latency_ms=124
```
`odm_available=false` with `odm_fetch_result=unavailable` means Google's SDK is not linked. Check the `-ObjC` flag first, because a missing flag strips the class with no build warning.
***
## C# Wrapper
Create `Assets/Scripts/LinkrunnerSDK.cs` in your Unity project:
```csharp theme={null}
using System;
using System.Runtime.InteropServices;
using UnityEngine;
///
/// Cross-platform wrapper for Linkrunner native SDKs.
/// Attach this script to a GameObject named "LinkrunnerCallbackHandler" in your first scene.
///
public class LinkrunnerSDK : MonoBehaviour
{
public static LinkrunnerSDK Instance { get; private set; }
// Callbacks
public static event Action OnInitialized;
public static event Action OnSignedUp;
public static event Action OnUserDataSet;
public static event Action OnAttributionData;
public static event Action OnEventTracked;
public static event Action OnPaymentCaptured;
public static event Action OnPaymentRemoved;
public static event Action OnDeeplinkHandled;
#if UNITY_IOS && !UNITY_EDITOR
[DllImport("__Internal")]
private static extern void _LinkrunnerInitialize(string token, string secretKey,
string keyId, bool disableIdfa, bool debug);
[DllImport("__Internal")]
private static extern void _LinkrunnerSignup(string userDataJson, string additionalDataJson);
[DllImport("__Internal")]
private static extern void _LinkrunnerSetUserData(string userDataJson);
[DllImport("__Internal")]
private static extern void _LinkrunnerGetAttributionData();
[DllImport("__Internal")]
private static extern void _LinkrunnerTrackEvent(string eventName, string eventDataJson,
string eventId);
[DllImport("__Internal")]
private static extern void _LinkrunnerCapturePayment(string userId, double amount,
string paymentId, string type, string status, string eventDataJson);
[DllImport("__Internal")]
private static extern void _LinkrunnerRemovePayment(string userId, string paymentId);
[DllImport("__Internal")]
private static extern void _LinkrunnerSetPushToken(string pushToken);
[DllImport("__Internal")]
private static extern void _LinkrunnerSetAdditionalData(string clevertapId);
[DllImport("__Internal")]
private static extern void _LinkrunnerEnablePIIHashing(bool enabled);
[DllImport("__Internal")]
private static extern void _LinkrunnerSetConsent(string isEEA, string dataUsage,
string adsPersonalization);
[DllImport("__Internal")]
private static extern void _LinkrunnerHandleDeeplink(string deeplinkUrl);
#endif
#if UNITY_ANDROID && !UNITY_EDITOR
private static AndroidJavaClass _bridge;
private static AndroidJavaClass Bridge
{
get
{
if (_bridge == null)
_bridge = new AndroidJavaClass("com.linkrunner.unity.LinkrunnerBridge");
return _bridge;
}
}
#endif
void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
// ---- Public API ----
///
/// Initialize the Linkrunner SDK. Call once at app startup.
/// Get your token from: https://dashboard.linkrunner.io/dashboard?s=members&m=documentation
///
public static void Initialize(string token, string secretKey = "", string keyId = "",
bool disableIdfa = false, bool debug = false)
{
#if UNITY_ANDROID && !UNITY_EDITOR
Bridge.CallStatic("initialize", token, secretKey ?? "", keyId ?? "", debug);
#elif UNITY_IOS && !UNITY_EDITOR
_LinkrunnerInitialize(token, secretKey, keyId, disableIdfa, debug);
#else
Debug.Log("[Linkrunner] Initialize called (Editor — no-op)");
OnInitialized?.Invoke(true, "editor");
#endif
}
///
/// Identify the user. Call once as soon as the user is identified (signup or login).
///
public static void Signup(LinkrunnerUserData userData, string additionalDataJson = "")
{
string json = JsonUtility.ToJson(userData);
#if UNITY_ANDROID && !UNITY_EDITOR
Bridge.CallStatic("signup", json, additionalDataJson ?? "");
#elif UNITY_IOS && !UNITY_EDITOR
_LinkrunnerSignup(json, additionalDataJson);
#else
Debug.Log("[Linkrunner] Signup: " + json);
#endif
}
///
/// Set user data. Call on each app open when the user is logged in.
///
public static void SetUserData(LinkrunnerUserData userData)
{
string json = JsonUtility.ToJson(userData);
#if UNITY_ANDROID && !UNITY_EDITOR
Bridge.CallStatic("setUserData", json);
#elif UNITY_IOS && !UNITY_EDITOR
_LinkrunnerSetUserData(json);
#else
Debug.Log("[Linkrunner] SetUserData: " + json);
#endif
}
///
/// Get attribution data (deeplink, campaign info).
/// Result is delivered via the OnAttributionData event.
///
public static void GetAttributionData()
{
#if UNITY_ANDROID && !UNITY_EDITOR
Bridge.CallStatic("getAttributionData");
#elif UNITY_IOS && !UNITY_EDITOR
_LinkrunnerGetAttributionData();
#else
Debug.Log("[Linkrunner] GetAttributionData called (Editor — no-op)");
#endif
}
///
/// Track a custom event.
///
public static void TrackEvent(string eventName, string eventDataJson = "", string eventId = "")
{
#if UNITY_ANDROID && !UNITY_EDITOR
Bridge.CallStatic("trackEvent", eventName, eventDataJson ?? "", eventId ?? "");
#elif UNITY_IOS && !UNITY_EDITOR
_LinkrunnerTrackEvent(eventName, eventDataJson, eventId);
#else
Debug.Log("[Linkrunner] TrackEvent: " + eventName);
#endif
}
///
/// Track a payment/revenue event.
///
public static void CapturePayment(string userId, double amount, string paymentId,
string type = "DEFAULT", string status = "PAYMENT_COMPLETED", string eventDataJson = "")
{
#if UNITY_ANDROID && !UNITY_EDITOR
Bridge.CallStatic("capturePayment", userId, amount, paymentId ?? "", type, status, eventDataJson ?? "");
#elif UNITY_IOS && !UNITY_EDITOR
_LinkrunnerCapturePayment(userId, amount, paymentId, type, status, eventDataJson);
#else
Debug.Log($"[Linkrunner] CapturePayment: {userId}, {amount}");
#endif
}
///
/// Remove a previously tracked payment (e.g. refund).
///
public static void RemovePayment(string userId, string paymentId = "")
{
#if UNITY_ANDROID && !UNITY_EDITOR
Bridge.CallStatic("removePayment", userId, paymentId ?? "");
#elif UNITY_IOS && !UNITY_EDITOR
_LinkrunnerRemovePayment(userId, paymentId);
#else
Debug.Log($"[Linkrunner] RemovePayment: {userId}");
#endif
}
///
/// Set the push notification token (Firebase/APNs).
///
public static void SetPushToken(string pushToken)
{
#if UNITY_ANDROID && !UNITY_EDITOR
Bridge.CallStatic("setPushToken", pushToken);
#elif UNITY_IOS && !UNITY_EDITOR
_LinkrunnerSetPushToken(pushToken);
#else
Debug.Log("[Linkrunner] SetPushToken: " + pushToken);
#endif
}
///
/// Set additional integration data (e.g. CleverTap ID).
///
public static void SetAdditionalData(string clevertapId)
{
#if UNITY_ANDROID && !UNITY_EDITOR
Bridge.CallStatic("setAdditionalData", clevertapId ?? "");
#elif UNITY_IOS && !UNITY_EDITOR
_LinkrunnerSetAdditionalData(clevertapId);
#else
Debug.Log("[Linkrunner] SetAdditionalData: " + clevertapId);
#endif
}
///
/// Enable or disable PII hashing.
///
public static void EnablePIIHashing(bool enabled)
{
#if UNITY_ANDROID && !UNITY_EDITOR
Bridge.CallStatic("enablePIIHashing", enabled);
#elif UNITY_IOS && !UNITY_EDITOR
_LinkrunnerEnablePIIHashing(enabled);
#else
Debug.Log("[Linkrunner] EnablePIIHashing: " + enabled);
#endif
}
///
/// Report Google Ads consent. Each value is "granted", "denied", or "unknown".
/// Call before Initialize(), and again whenever the user's consent state changes.
///
public static void SetConsent(string isEEA, string hasConsentForDataUsage,
string hasConsentForAdsPersonalization)
{
#if UNITY_ANDROID && !UNITY_EDITOR
Bridge.CallStatic("setConsent", isEEA ?? "unknown",
hasConsentForDataUsage ?? "unknown", hasConsentForAdsPersonalization ?? "unknown");
#elif UNITY_IOS && !UNITY_EDITOR
_LinkrunnerSetConsent(isEEA, hasConsentForDataUsage, hasConsentForAdsPersonalization);
#else
Debug.Log("[Linkrunner] SetConsent: " + isEEA + ", " + hasConsentForDataUsage + ", "
+ hasConsentForAdsPersonalization);
#endif
}
///
/// Handle a deeplink for re-engagement attribution.
/// Call this when the app is opened via a deeplink (cold start or warm start).
/// Result is delivered via the OnDeeplinkHandled event.
///
public static void HandleDeeplink(string deeplinkUrl)
{
#if UNITY_ANDROID && !UNITY_EDITOR
Bridge.CallStatic("handleDeeplink", deeplinkUrl ?? "");
#elif UNITY_IOS && !UNITY_EDITOR
_LinkrunnerHandleDeeplink(deeplinkUrl);
#else
Debug.Log("[Linkrunner] HandleDeeplink: " + deeplinkUrl);
OnDeeplinkHandled?.Invoke("{\"deeplink\":\"" + deeplinkUrl + "\",\"is_linkrunner\":false}");
#endif
}
///
/// Disable Google Advertising ID collection (Android only, for COPPA compliance).
/// Must be called before Initialize().
///
public static void SetDisableAaidCollection(bool disabled)
{
#if UNITY_ANDROID && !UNITY_EDITOR
Bridge.CallStatic("setDisableAaidCollection", disabled);
#else
Debug.Log("[Linkrunner] SetDisableAaidCollection: " + disabled);
#endif
}
// ---- Native Callbacks (called via UnitySendMessage) ----
void OnInitComplete(string message)
{
bool success = message == "success";
OnInitialized?.Invoke(success, message);
}
void OnSignupComplete(string message)
{
bool success = message == "success";
OnSignedUp?.Invoke(success, message);
}
void OnSetUserDataComplete(string message)
{
bool success = message == "success";
OnUserDataSet?.Invoke(success, message);
}
void OnAttributionDataReceived(string message)
{
OnAttributionData?.Invoke(message);
}
void OnTrackEventComplete(string message)
{
bool success = message == "success";
OnEventTracked?.Invoke(success, message);
}
void OnCapturePaymentComplete(string message)
{
bool success = message == "success";
OnPaymentCaptured?.Invoke(success, message);
}
void OnRemovePaymentComplete(string message)
{
bool success = message == "success";
OnPaymentRemoved?.Invoke(success, message);
}
void OnHandleDeeplinkComplete(string message)
{
OnDeeplinkHandled?.Invoke(message);
}
///
/// Called by native code (DeeplinkActivity on Android, AppDelegate on iOS) when the app
/// is opened via a deeplink. This method forwards the URL to HandleDeeplink for processing.
///
void OnDeeplinkReceived(string url)
{
Debug.Log($"[Linkrunner] Deeplink received from native: {url}");
HandleDeeplink(url);
}
}
///
/// User data model. All fields except id are optional.
///
[Serializable]
public class LinkrunnerUserData
{
public string id;
public string name;
public string phone;
public string email;
public string mixpanelDistinctId;
public string amplitudeDeviceId;
public string posthogDistinctId;
public string brazeDeviceId;
public string gaAppInstanceId;
public string gaSessionId;
public string userCreatedAt;
public bool isFirstTimeUser;
}
```
***
## Usage
### Scene Setup
1. Create an empty `GameObject` in your first scene
2. Name it **`LinkrunnerCallbackHandler`** (must match exactly)
3. Attach the `LinkrunnerSDK.cs` script to it
### Initialization (Required)
Call `Initialize` as early as possible — typically in your startup scene:
```csharp theme={null}
void Start()
{
LinkrunnerSDK.OnInitialized += (success, message) =>
{
Debug.Log($"Linkrunner initialized: {success}");
};
LinkrunnerSDK.Initialize(
token: "YOUR_PROJECT_TOKEN",
debug: true // set false in production
);
}
```
Find your project token at: [Dashboard → Documentation](https://dashboard.linkrunner.io/dashboard?s=members\&m=documentation)
### User Identification (Required)
Call `Signup` as soon as the user is identified — whether through signup or login. This is the moment Linkrunner ties the install (and any future events) to a user identifier.
```csharp theme={null}
var userData = new LinkrunnerUserData
{
id = "user_123",
name = "Jane Doe",
email = "jane@example.com",
isFirstTimeUser = true
};
LinkrunnerSDK.Signup(userData);
```
### Handle Deeplinks
Call `HandleDeeplink` when your app is opened via a deeplink — both cold start (app was closed) and warm start (app was in background):
```csharp theme={null}
void Start()
{
// Subscribe to deeplink results
LinkrunnerSDK.OnDeeplinkHandled += (jsonString) =>
{
Debug.Log($"Deeplink handled: {jsonString}");
// Parse jsonString to access deeplink URL
// Example response: {"deeplink":"https://yourapp.link/promo"}
};
}
// Call when app receives a deeplink
public void OnDeeplinkReceived(string url)
{
LinkrunnerSDK.HandleDeeplink(url);
}
```
Linkrunner sends the updated deeplink back after processing. For Linkrunner campaign links, use `deeplink` from `OnDeeplinkHandled` as the resolved destination instead of the original tracking URL.
```json theme={null}
{
"deeplink": "https://app.yourdomain.com/product/123"
}
```
The `is_linkrunner` field indicates whether the deeplink was created through Linkrunner. Use this to determine if you should apply Linkrunner-specific attribution logic.
For Unity deeplink handling, you'll need to implement platform-specific code to capture the deeplink URL:
Create a custom `UnityPlayerActivity` to capture deeplinks:
```java theme={null}
// Assets/Plugins/Android/DeeplinkActivity.java
package com.yourcompany.yourapp;
import android.content.Intent;
import android.os.Bundle;
import com.unity3d.player.UnityPlayerActivity;
import com.unity3d.player.UnityPlayer;
public class DeeplinkActivity extends UnityPlayerActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
handleDeeplink(getIntent());
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleDeeplink(intent);
}
private void handleDeeplink(Intent intent) {
if (intent != null && intent.getData() != null) {
String url = intent.getData().toString();
// Send to Unity - LinkrunnerCallbackHandler must exist in scene
UnityPlayer.UnitySendMessage("LinkrunnerCallbackHandler", "OnDeeplinkReceived", url);
}
}
}
```
Update your `AndroidManifest.xml` to use this activity and add intent filters for your deeplink scheme.
Deeplinks on iOS are handled through the `AppDelegate`. After Unity generates the Xcode project, modify `UnityAppController.mm` or use a native plugin to forward deeplink URLs to Unity via `UnitySendMessage`.
### Set User Data on App Open
Call every time the app opens and the user is logged in:
**`SetUserData` is optional and is not a replacement for `Signup`.** Always call `Signup` first as soon as the user is identified (signup or login). Use `SetUserData` afterwards only when additional user details become available later — for example, when the user adds a phone number, email, or completes their profile after identification.
```csharp theme={null}
var userData = new LinkrunnerUserData
{
id = "user_123",
name = "Jane Doe",
email = "jane@example.com"
};
LinkrunnerSDK.SetUserData(userData);
```
### Get Attribution Data
```csharp theme={null}
LinkrunnerSDK.OnAttributionData += (jsonString) =>
{
Debug.Log($"Attribution data: {jsonString}");
// Parse jsonString to access deeplink, campaignData, etc.
};
LinkrunnerSDK.GetAttributionData();
```
The result arrives as a JSON string on the `OnAttributionData` event. With the reference bridge code in this guide, the shape differs by platform.
**iOS**
```json theme={null}
{
"deeplink": "https://...", // optional
"attributionSource": "string", // "ORGANIC", "META", "GOOGLE", etc.
"campaignData": {
"id": "string",
"name": "string",
"groupName": "string",
"assetName": "string",
"assetGroupName": "string"
}
}
```
**Android**
```json theme={null}
{
"raw": "AttributionData(deeplink=..., campaignData=CampaignData(...))"
}
```
### Track Events
```csharp theme={null}
// Simple event
LinkrunnerSDK.TrackEvent("level_complete");
// Event with data (pass as JSON string)
string eventData = JsonUtility.ToJson(new { level = 5, score = 1200, time_seconds = 45 });
LinkrunnerSDK.TrackEvent("level_complete", eventData);
// Event with deduplication ID
LinkrunnerSDK.TrackEvent("purchase", eventData, eventId: "purchase_abc123");
```
When tracking revenue-related events, the `amount` field must be a number (not a string) for ad network revenue optimization to work correctly.
### Capture Payment
```csharp theme={null}
LinkrunnerSDK.CapturePayment(
userId: "user_123",
amount: 9.99,
paymentId: "pay_abc123",
type: "FIRST_PAYMENT",
status: "PAYMENT_COMPLETED"
);
// With event data
string eventData = "{\"product_id\": \"prod_456\", \"currency\": \"USD\"}";
LinkrunnerSDK.CapturePayment(
userId: "user_123",
amount: 49.99,
paymentId: "pay_xyz789",
type: "ONE_TIME",
status: "PAYMENT_COMPLETED",
eventDataJson: eventData
);
```
**`paymentId`** is required and must be unique for each payment. It is used for idempotent deduplication, so retries cannot double count revenue.
**Payment types:** `FIRST_PAYMENT`, `SECOND_PAYMENT`, `WALLET_TOPUP`, `FUNDS_WITHDRAWAL`, `SUBSCRIPTION_CREATED`, `SUBSCRIPTION_RENEWED`, `ONE_TIME`, `RECURRING`, `DEFAULT`
**Payment statuses:** `PAYMENT_INITIATED`, `PAYMENT_COMPLETED`, `PAYMENT_FAILED`, `PAYMENT_CANCELLED`
### Remove Payment (Refunds)
```csharp theme={null}
LinkrunnerSDK.RemovePayment(userId: "user_123", paymentId: "pay_abc123");
```
### Push Token
```csharp theme={null}
LinkrunnerSDK.SetPushToken("your_firebase_or_apns_token");
```
### Privacy Controls
```csharp theme={null}
// Enable PII hashing (hashes email, phone, etc. before sending)
LinkrunnerSDK.EnablePIIHashing(true);
// Disable Google Advertising ID collection (Android, call BEFORE Initialize)
LinkrunnerSDK.SetDisableAaidCollection(true);
```
***
## Function Placement Guide
| Function | Where to Call | When |
| -------------------- | ------------------------- | -------------------------------------- |
| `Initialize` | First scene / App startup | Once at launch |
| `GetAttributionData` | After init completes | When you need campaign/deeplink info |
| `Signup` | After onboarding | Once per user |
| `SetUserData` | Auth/session logic | Every app open with logged-in user |
| `TrackEvent` | Throughout the app | On user actions |
| `CapturePayment` | Payment flow | When payment succeeds |
| `RemovePayment` | Refund flow | When payment is reversed |
| `SetPushToken` | After token refresh | When push token changes |
| `SetConsent` | Before `Initialize` | At launch and whenever consent changes |
| `HandleDeeplink` | Deeplink entry points | When app is opened via a deeplink |
***
## Automating iOS Dependency with a Post-Build Script
Since Unity regenerates the Xcode project on each build, you can automate the SPM dependency addition using a Unity Editor script:
Create `Assets/Editor/LinkrunnerPostBuild.cs`:
```csharp theme={null}
#if UNITY_IOS
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEditor.iOS.Xcode;
using System.IO;
public class LinkrunnerPostBuild
{
[PostProcessBuild(1)]
public static void OnPostProcessBuild(BuildTarget target, string path)
{
if (target != BuildTarget.iOS) return;
// Add Info.plist entries
string plistPath = Path.Combine(path, "Info.plist");
PlistDocument plist = new PlistDocument();
plist.ReadFromFile(plistPath);
plist.root.SetString("NSUserTrackingUsageDescription",
"This identifier will be used to deliver personalized ads and improve your app experience.");
plist.root.SetString("NSAdvertisingAttributionReportEndpoint",
"https://linkrunner-skan.com");
plist.root.SetString("AttributionCopyEndpoint",
"https://linkrunner-skan.com");
plist.WriteToFile(plistPath);
}
}
#endif
```
Unity's `PBXProject` API does not natively support adding SPM packages. After the Xcode project is generated, you will need to add the LinkrunnerKit SPM dependency manually in Xcode, or automate it with a post-build script.
***
## Testing
1. Enable debug mode: `LinkrunnerSDK.Initialize(token, debug: true)`
2. Create a test campaign at [Dashboard → Create Campaign](https://dashboard.linkrunner.io/dashboard?m=create-campaign)
3. Uninstall the app from your test device
4. Click the campaign link on the device (opens in browser)
5. Install and open the app build (the click must come before the first app open)
6. Verify in the dashboard that click count, install count, and signup count increase
7. Call `GetAttributionData()` to confirm attribution is returned
For detailed testing steps, see the [Integration Testing Guide](/testing/integration-testing).
***
## Troubleshooting
### Android
* **ProGuard/R8 issues**: Add to your ProGuard rules:
```
-keep class io.linkrunner.sdk.** { *; }
-keep class com.linkrunner.unity.** { *; }
```
* **Duplicate classes**: If another plugin includes Kotlin stdlib, use Gradle's `exclude` to avoid conflicts.
### iOS
* **Swift file not compiling**: Unity does not automatically compile `.swift` files. After generating the Xcode project, add `LinkrunnerUnityBridge.swift` to the **UnityFramework** target's **Compile Sources** build phase manually.
* **"Use of unresolved identifier 'LinkrunnerSDK'"**: The LinkrunnerKit SPM package hasn't been added to the Xcode project. Add it via File → Add Package Dependencies.
* **Bridging header issues**: When Xcode prompts to create a bridging header after adding the first Swift file, accept it. Ensure `SWIFT_VERSION` is set to `5.0` or higher in UnityFramework build settings.
* **Linker errors**: Ensure you selected **LinkrunnerKitStatic** (not the dynamic variant) when adding the SPM package.
* **ATT dialog not showing**: The `NSUserTrackingUsageDescription` key must be present in Info.plist. The SDK handles requesting permission automatically during initialization.
### General
* **Callbacks not received**: Verify the `GameObject` is named exactly `LinkrunnerCallbackHandler` and has the `LinkrunnerSDK.cs` script attached. It must exist in the scene when native code calls `UnitySendMessage`.
* **Editor testing**: All SDK calls are no-ops in the Unity Editor and log to the console instead. Test on actual Android/iOS devices.
# Web SDK
Source: https://docs.linkrunner.io/sdk/web
Set up Linkrunner's beta Web Attribution SDK for page views, users, events, and traffic-source attribution
The Linkrunner Web SDK tracks page views, known users, custom events, and the traffic sources that brought visitors to your website.
Web Attribution is currently in beta. To request access, email
[support@linkrunner.io](mailto:support@linkrunner.io) with your project name and website domain.
We will enable Web Attribution and send you a Web SDK token.
The SDK automatically captures:
* Page views, including single-page app navigation
* First-touch and last-touch UTM attribution
* Ad click IDs such as `gclid`, `fbclid`, and `ttclid`
* Paid, organic, social, AI search, referral, and direct traffic
* Browser, device, geography, and performance data
## 1. Add the SDK
We recommend loading the browser SDK from the Linkrunner CDN. This lets Linkrunner ship fixes and updates without requiring you to change or redeploy your integration.
The direct script tag and the Next.js helper both load `https://cdn.linkrunner.io/web/v1/lr.js` by default. The [npm package](https://www.npmjs.com/package/@linkrunner/web) provides the typed Next.js component and event methods, while the browser SDK still stays current through the CDN.
Add this script before the closing `` tag. Replace `YOUR_WEB_SDK_TOKEN` with the token provided by Linkrunner.
```html theme={null}
```
Install the package:
```bash theme={null}
npm install @linkrunner/web
```
Add `LinkrunnerScript` to your root layout so it loads once and stays active across navigation:
```tsx theme={null}
// app/layout.tsx
import { LinkrunnerScript } from '@linkrunner/web/next'
export default function RootLayout({ children }) {
return (
{children}
)
}
```
`LinkrunnerScript` loads the browser SDK from the Linkrunner CDN by default.
Install the package:
```bash theme={null}
npm install @linkrunner/web
```
Add `LinkrunnerScript` to `_app.tsx`, not `_document.tsx` or an individual page:
```tsx theme={null}
// pages/_app.tsx
import { LinkrunnerScript } from '@linkrunner/web/next'
export default function App({ Component, pageProps }) {
return (
<>
>
)
}
```
`LinkrunnerScript` loads the browser SDK from the Linkrunner CDN by default.
The SDK tracks the first page view when it loads. Single-page app navigation is tracked by default.
## 2. Identify users and track events
Call `identify` after a user signs in or when you otherwise know their identity. Use a stable internal user ID rather than an email address or phone number.
```js theme={null}
import { lr } from '@linkrunner/web'
lr.identify(String(user.id))
```
With the script tag, use the global object:
```js theme={null}
window.lr.identify(String(user.id))
```
The SDK saves this ID in `localStorage` and includes it as `user_id` on later events.
Track a custom event with `track`:
```js theme={null}
lr.track('purchase', {
amount: 49.99,
currency: 'USD',
})
```
To show a signed-up user's details in the Web Events dashboard, identify the user and then send a `signup` event:
```js theme={null}
lr.identify(String(user.id))
lr.track('signup', {
name: user.name,
email: user.email,
phone: user.phone,
})
```
Only send personal data when you have permission to do so. Calling `identify` does not add the user ID to events that were already captured.
Calls made before the SDK finishes loading are queued and replayed after initialization.
## 3. Verify the integration
Set `data-debug="true"` while testing:
```html theme={null}
```
Then open your browser's developer tools:
1. In **Console**, confirm that messages start with `[Linkrunner]` and include `Initialized`.
2. In **Network**, confirm that page views and events send a `POST` request to `/web/ingest`.
3. Trigger a test event and confirm the console reports `Sent via fetch`.
Debug logging turns on automatically on `localhost`, `127.0.0.1`, and `[::1]`. Remove `data-debug="true"` after testing.
## Configuration
### Script tag attributes
| Attribute | Required | Description | Default |
| --------------- | -------- | -------------------------------------------------------------- | ------------------------ |
| `data-token` | Yes | Your Web SDK token | None |
| `data-domain` | No | Your first-party collection hostname, such as `lr.example.com` | None |
| `data-endpoint` | No | A full URL or same-origin path for a proxy you operate | None |
| `data-spa` | No | Set to `"false"` to disable automatic SPA page views | `true` |
| `data-debug` | No | Set to `"true"` or `"false"` to control console logging | On for local development |
You can also set the same options before the script loads:
```html theme={null}
```
Without `data-domain` or `data-endpoint`, events go to `https://api.linkrunner.io/web/ingest`.
## First-party collection
Some ad blockers stop requests to analytics domains. First-party collection sends events through your own domain instead.
### Proxy through your website
This is the most reliable option because both the SDK and event endpoint use paths on your website. For Next.js, add two rewrites:
```js theme={null}
// next.config.js
module.exports = {
async rewrites() {
return [
{
source: '/lr/lr.js',
destination: 'https://cdn.linkrunner.io/web/v1/lr.js',
},
{
source: '/lr/ingest',
destination: 'https://api.linkrunner.io/web/ingest',
},
]
},
}
```
Point `LinkrunnerScript` at those routes:
```tsx theme={null}
```
For a plain script tag:
```html theme={null}
```
Your proxy must preserve the visitor's IP address. Forward `X-Forwarded-For` with the visitor's address first, or set `X-Linkrunner-Visitor-IP` explicitly. If the proxy drops it, geographic data will identify your proxy instead of the visitor.
### Point a subdomain at Linkrunner
Use this option when you cannot add proxy routes to your website.
In the Linkrunner dashboard, open **Settings → Manage Domains** and add the collection subdomain you want to use, such as `lr.example.com`.
Add a CNAME record with your DNS provider:
```text theme={null}
lr.example.com. CNAME api.linkrunner.io.
```
Linkrunner issues the TLS certificate on the first request for a registered subdomain.
Add `data-domain` to the script tag:
```html theme={null}
```
For Next.js, use the `domain` prop:
```tsx theme={null}
```
Run this request before relying on the subdomain:
```bash theme={null}
curl -i -X OPTIONS \
-H 'Origin: https://example.com' \
-H 'Access-Control-Request-Method: POST' \
https://lr.example.com/web/ingest
```
Expect a `204` response with an `access-control-allow-origin` header. Then confirm in your browser's **Network** tab that event requests go to `https://lr.example.com/web/ingest`.
Set `data-domain` to a hostname, not a URL. The SDK accepts a scheme or trailing slash, but it always normalizes the value to `https://HOST/web/ingest`. Use `data-endpoint` only when you control the full proxy path.
If each event creates one request to your subdomain and another to `api.linkrunner.io`, the first-party endpoint is failing and the SDK is using its fallback. Check the CNAME, domain registration, and any firewall or authentication rules in front of the subdomain.
## Attribution storage
| Data | Storage | Lifetime |
| ------------------------------ | ----------------------------------- | -------------------------------- |
| First-touch UTMs and click IDs | `localStorage` | Until browser storage is cleared |
| Last-touch click IDs | `localStorage` | 90 days |
| Last-touch UTMs | `sessionStorage` and `localStorage` | 24 hours from the campaign click |
| Visitor ID and user ID | `localStorage` | Until browser storage is cleared |
| Session ID and page count | `sessionStorage` | Current tab session |
The 24-hour `localStorage` copy preserves last-touch UTMs when a payment gateway or 3D Secure flow returns the visitor in a new tab.
## Troubleshooting
Confirm that you are using the Web SDK token provided by Linkrunner. Mobile SDK project tokens do not work with the Web SDK. If you need a token, contact [support@linkrunner.io](mailto:support@linkrunner.io).
Load the SDK once. In Next.js, put `LinkrunnerScript` in the root layout or `_app.tsx`, not on individual pages. The SDK already tracks SPA navigation by default.
Use [first-party collection](#first-party-collection). A same-origin proxy is the strongest option. A CNAME may still be detected by browsers that inspect DNS records.
Do not trust client-side events for payments, entitlements, or other sensitive state changes. Send those events from your backend with the [Event Capture API](/api-reference/event-capture) or [Revenue Tracking API](/api-reference/revenue-tracking).
## More resources
View the package, current version, and full SDK reference.
Read the source code and release history.
Install Web Attribution on a Shopify storefront and checkout.
**Need help or beta access?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Test Custom Events and Payments
Source: https://docs.linkrunner.io/testing/event-and-revenue-testing
Choose between custom event and payment tracking, then verify each event end to end.
Use this guide after [SDK Integration Testing](/testing/integration-testing). It helps you decide whether an action is a custom event or a payment, then confirms that Linkrunner records it correctly.
## Choose the right method
| What happened | App SDK | Server-side API |
| -------------------------------------------------- | ---------------- | ----------------------- |
| A user action with no authoritative transaction | `trackEvent` | `POST /capture-event` |
| A payment with an amount and unique transaction ID | `capturePayment` | `POST /capture-payment` |
| A captured payment must be removed | `removePayment` | `POST /remove-payment` |
The event name alone is not enough. If `purchase_completed` represents a real transaction, send it as a payment. If it only represents a funnel step with no authoritative transaction, send it as a custom event.
Do not send the same transaction as both a revenue-bearing custom event and a payment. This can duplicate revenue in downstream ad networks.
### Usually send as a custom event
* Product view, search, add to cart, and checkout started
* Content view, level completion, and referral
* Purchase initiated when it only marks the start of checkout
* Payment failure shown for product analytics when you do not need a payment record
### Usually send with `capturePayment`
* Completed purchase
* First or second payment
* One-time or recurring payment
* Subscription creation or renewal with a charge
* Wallet top-up or funds withdrawal
* A failed or cancelled transaction when you need it recorded with `PAYMENT_FAILED` or `PAYMENT_CANCELLED`
If you record a failed or cancelled payment, send its final state once. Linkrunner deduplicates payments by the combination of `type` and `payment_id`, so a later call with the same combination is ignored.
## Before you test
* Complete the [click → install → signup flow](/testing/integration-testing) so the test user is attributed.
* Use the same `user_id` that your app registered through `signup`.
* Generate a fresh `payment_id` for each real transaction.
* Send `amount` as a number in one reporting currency.
* Pick one source for each payment, either the app SDK or your backend.
* For server-side tests, generate a server key from [**Settings → Data APIs**](https://dashboard.linkrunner.io/settings?p_id=4\&s=data-apis).
## Test flow
Use values that you can find in the Events Log:
```text theme={null}
user_id: your attributed test user
payment_id: lr_test_payment_001
amount: a small test amount
type: DEFAULT
status: PAYMENT_COMPLETED
```
Use a new `payment_id` every time you test a new transaction.
Send a non-payment action such as `checkout_started` through your SDK's `trackEvent` method or the [Event Capture API](/api-reference/event-capture).
```bash theme={null}
curl -X POST https://api.linkrunner.io/api/v1/capture-event \
-H "Content-Type: application/json" \
-H "linkrunner-key: YOUR-SERVER-KEY" \
-d '{
"event_name": "checkout_started",
"event_data": { "test_run": "lr_payment_flow_001" },
"user_id": "YOUR_ATTRIBUTED_USER_ID",
"event_id": "lr_test_event_001"
}'
```
A successful API request returns a captured-event response.
Send the actual transaction through your SDK's `capturePayment` method or the [Revenue Tracking API](/api-reference/revenue-tracking).
```bash theme={null}
curl -X POST https://api.linkrunner.io/api/v1/capture-payment \
-H "Content-Type: application/json" \
-H "linkrunner-key: YOUR-SERVER-KEY" \
-d '{
"user_id": "YOUR_ATTRIBUTED_USER_ID",
"payment_id": "lr_test_payment_001",
"amount": 1,
"type": "DEFAULT",
"status": "PAYMENT_COMPLETED"
}'
```
A successful API request returns HTTP `201`.
Open [**Events → Events Log**](https://dashboard.linkrunner.io/dashboard/events), then filter by your test user or event name.
* `checkout_started` appears as a custom event.
* The transaction appears as a payment event.
* The payment row contains the expected **Amount**, **Payment ID**, and **Payment Status**.
Send the same payment again with the same `type` and `payment_id`.
Expected result: the Events Log still contains one payment for that combination. Linkrunner records the first request and ignores later duplicates.
Send another completed payment with a new `payment_id`, such as `lr_test_payment_002`.
Expected result: the Events Log contains a second payment. If it does not, confirm that the new transaction did not reuse the previous `payment_id`.
## Expected classification
| Example action | Send as | Key fields |
| --------------------------------------- | -------------- | --------------------------------------------------- |
| `item_viewed` | Custom event | `event_name`, `user_id` |
| `add_to_cart` | Custom event | `event_name`, `user_id`, product data |
| `checkout_started` | Custom event | `event_name`, `user_id` |
| `purchase_initiated` as a funnel step | Custom event | `event_name`, `user_id` |
| Completed purchase | Payment | `user_id`, `payment_id`, `amount`, `type`, `status` |
| Subscription renewal with a charge | Payment | `type: SUBSCRIPTION_RENEWED` |
| Wallet top-up | Payment | `type: WALLET_TOPUP` |
| Failed payment that must be recorded | Payment | `status: PAYMENT_FAILED` |
| Cancelled payment that must be recorded | Payment | `status: PAYMENT_CANCELLED` |
| Refund or void of a captured payment | Remove payment | The original `payment_id` |
## Test Meta Purchase forwarding
Use this section only when the payment must reach Meta Commerce Manager.
1. In Linkrunner, map the payment type you send, such as `DEFAULT` or `FIRST_PAYMENT`, to Meta's standard `Purchase` event.
2. Include the required `event_data` fields for `Purchase`, including product IDs, `contents`, `content_type`, `value`, `currency`, `num_items`, and `order_id`.
3. Send a new payment with a new `payment_id`.
4. Check **Meta Commerce Manager → Events**. The real-time hit should appear within about 15 minutes. Full reporting can take a few days.
See [Meta Commerce Manager](/ecommerce-manager/meta-commerce-manager#understanding-event_data) for the full ecommerce payload.
## Test payment removal
Remove one test payment using its `payment_id`:
```bash theme={null}
curl -X POST https://api.linkrunner.io/api/v1/remove-payment \
-H "Content-Type: application/json" \
-H "linkrunner-key: YOUR-SERVER-KEY" \
-d '{ "payment_id": "lr_test_payment_001" }'
```
The API does not define a partial-refund adjustment flow. Contact support before using `removePayment` for a partial refund.
Do not test removal with only `user_id` unless you intend to remove every payment attributed to that user.
## Troubleshooting
Confirm the device completed the [attribution test flow](/testing/integration-testing) and that the event uses the same `user_id` registered through `signup`.
The app or backend sent it through `trackEvent` or `/capture-event`. Send authoritative transactions through `capturePayment` or `/capture-payment` instead.
Check whether it reused the same `type` and `payment_id` as an earlier transaction. Linkrunner treats that combination as a duplicate.
Confirm the app and backend are not both sending the transaction with different payment IDs. Choose one source, or use the same stable payment ID for safe retries.
Linkrunner keeps the first record for a `type` and `payment_id` combination. Send the final payment state once instead of sending initiated and completed states with the same combination.
Confirm the payment type is mapped to `Purchase`, the ecommerce payload includes every required field, and the test has had at least 15 minutes to reach Meta Events Manager.
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# SDK Integration Testing
Source: https://docs.linkrunner.io/testing/integration-testing
Validate your Linkrunner setup end-to-end: click → install → signup
## Overview
This guide helps you verify your Linkrunner integration end-to-end using a real test campaign link. **This testing process applies to all Linkrunner SDKs** (React Native, Flutter, iOS, Android, Expo, and Web).
For mobile SDKs, [Open SDK Console](https://dashboard.linkrunner.io/dashboard/sdk-console/register-test-device) in the dashboard. It lets you register a test device and verify SDK initialization, attribution data, signup, custom events, and revenue events from one place.
You'll confirm that:
* A **click** is tracked when you open the campaign link on a device
* An **install** is tracked when you install and open the app after clicking
* A **signup** is tracked when you call the SDK signup method (recommended)
**Attribution is not required for event tracking.** Linkrunner stores events for all users, including organic ones. Events from users with no matching click are stored without campaign attribution. Complete the flow in this guide (click → install → open app) to see events attributed to a campaign.
## Video walkthrough
The testing flow walkthrough starts at **5:16** in the video.
## Prerequisites
### 1) Domain setup (required)
Before you start testing, make sure your Linkrunner domain setup is completed in the dashboard:
* [Domain settings](https://dashboard.linkrunner.io/settings?s=domains)
If you are setting up Universal Links (iOS) or App Links (Android), also complete deep link verification:
* [Setup Deep Linking](https://docs.linkrunner.io/sdk/deep-linking/setup)
* [Configure verification objects](https://docs.linkrunner.io/sdk/deep-linking/verification)
### 2) SDK integrated in your app
Complete integration for any supported SDK:
* [React Native Installation](https://docs.linkrunner.io/sdk/react-native/installation) and [React Native Usage](https://docs.linkrunner.io/sdk/react-native/usage)
* [Flutter Installation](https://docs.linkrunner.io/sdk/flutter/installation) and [Flutter Usage](https://docs.linkrunner.io/sdk/flutter/usage)
* [iOS Installation](https://docs.linkrunner.io/sdk/ios/installation) and [iOS Usage](https://docs.linkrunner.io/sdk/ios/usage)
* [Android Installation](https://docs.linkrunner.io/sdk/android/installation) and [Android Usage](https://docs.linkrunner.io/sdk/android/usage)
* [Expo Installation](https://docs.linkrunner.io/sdk/expo/installation)
### 3) Register a test device in SDK Console
Open SDK Console in the dashboard and register your testing device.
You can register the device by opening your app while SDK Console listens, or by entering the device identifier manually.
For normal testing, you do not need `debug = true`. Registering the device in SDK Console is enough.
Set `debug = true` in SDK init only when SDK Console cannot use the normal device identifier:
GAID (Android device identifier) collection is disabled, or the iOS ATT prompt is disabled so IDFA
(iOS device identifier) is unavailable. Remove `debug = true` before production.
## Test with SDK Console (beta)
Use this flow for most mobile SDK tests:
1. Open SDK Console.
2. Register your test device.
3. Go to **SDK Initialization** and open the generated test link on that device.
4. Install and open your app on the same device.
5. Continue through **Sign-up Tracking**, **Attribution Data Testing**, **Custom Events Tracking**, and **Revenue Event Tracking** as needed.
If your app cannot collect GAID on Android or IDFA on iOS, set `debug = true` in SDK init before you use SDK Console.
## Test flow (click → install → signup)
Use this manual flow when you want to verify campaign counts directly in the dashboard.
### Step 1) Get a test device
Use a physical device or emulator/simulator.
Important setup:
* Make sure the app is **uninstalled** before starting the test.
* If you are retesting, uninstall again before each run to keep results clean.
### Step 2) Create a test campaign in Linkrunner
Create a campaign from the dashboard:
* [Create Campaign](https://dashboard.linkrunner.io/dashboard?m=create-campaign)
Tip: If you prefer creating campaigns via API, see [Campaign APIs](https://docs.linkrunner.io/api-reference/campaign-apis).
### Step 3) Test the click
1. Copy the campaign link from the dashboard.
2. Open the campaign link **on the testing device** (browser or in-app browser).
Expected result:
* The campaign **click count should increase** in the Linkrunner dashboard.
### Step 4) Test the install
1. After clicking the link, install the app **locally from your computer** (the build that contains the Linkrunner SDK).
2. Open the app on the device.
Important ordering rule:
* **Click the link before the app opens for the first time.** A click that lands after the install is never matched
to it.
* The click stays eligible for the campaign's full attribution window — **7 days by default**, and 30 days for
Google and Meta campaigns.
**Alternative flow for slow builds:** If building and installing the app locally takes a while, use this approach
instead: 1. First install the app on your device 2. Then open the campaign link in a browser (Chrome/Safari) on the
same device 3. Open the app after clicking the link. This works because a locally installed build has no Play Store
install timestamp, so the click still lands before the first app open.
Expected result:
* The campaign **install count should increase** in the Linkrunner dashboard.
Optional but recommended:
* Fetch attribution inside the app after first open using `getAttributionData()` (supported in all SDKs).
See your SDK usage guide:
* [React Native Usage](https://docs.linkrunner.io/sdk/react-native/usage)
* [Flutter Usage](https://docs.linkrunner.io/sdk/flutter/usage)
* [iOS Usage](https://docs.linkrunner.io/sdk/ios/usage)
* [Android Usage](https://docs.linkrunner.io/sdk/android/usage)
### Step 5) Test the signup event (recommended)
If you have implemented the SDK signup method:
1. Complete onboarding or login in your app.
2. Call the SDK `signup` method once the user is registered.
Expected result:
* The campaign **signup count should increase** in the Linkrunner dashboard.
Implementation references:
* [React Native Usage (signup)](https://docs.linkrunner.io/sdk/react-native/usage)
* [Flutter Usage (signup)](https://docs.linkrunner.io/sdk/flutter/usage)
* [iOS Usage (signup)](https://docs.linkrunner.io/sdk/ios/usage)
* [Android Usage (signup)](https://docs.linkrunner.io/sdk/android/usage)
## Testing Remarketing
After verifying basic attribution (click → install → signup), you can test remarketing to confirm reengagement and install reattribution are working.
### Prerequisites
* Complete the [basic test flow](#test-flow-click--install--signup) above first (click → install → signup).
* [Deep linking](/features/deep-linking-setup) is configured in your app.
* The `handleDeepLink` function from the Linkrunner SDK is implemented. Refer to your SDK guide ([React Native](/sdk/react-native#handle-deeplink), [Flutter](/sdk/flutter#handle-deeplink), [Android](/sdk/android#handle-deeplink), [iOS](/sdk/ios#handle-deeplink)) for details.
* Android backup configuration is set up for reinstall detection on Android. Refer to your SDK guide ([React Native](/sdk/react-native#step-4%3A-android-backup-configuration), [Flutter](/sdk/flutter#backup-configuration), [Android](/sdk/android#step-3%3A-backup-configuration)) for details.
### Test 1: Reengagement (Deep Link Open)
This test verifies that a returning user opening the app via a deep link is attributed to a reengagement campaign.
1. **Create a campaign and install the app:** Follow the [basic test flow](#test-flow-click--install--signup) above to create a campaign, click the link, and install the app.
2. **Create a reengagement campaign:** Create a new campaign in the dashboard. This will be your reengagement campaign.
* [Create Campaign](https://dashboard.linkrunner.io/dashboard?m=create-campaign)
3. **Click the reengagement campaign link:** Open the reengagement campaign link on the same device. The app should open via the deep link.
**Expected result:**
* In the **Retargeting view** on the dashboard, the reengagement campaign should show **Reengagement: 1**.
### Test 2: Install Reattribution
This test verifies that a user who uninstalls and reinstalls the app is attributed as a reattribution.
1. **Create a campaign and install the app:** Follow the [basic test flow](#test-flow-click--install--signup) above to create a campaign, click the link, and install the app.
2. **Create a reengagement campaign:** Create a new campaign in the dashboard.
* [Create Campaign](https://dashboard.linkrunner.io/dashboard?m=create-campaign)
3. **Uninstall the app:** Remove the app from the test device.
4. **Click the reengagement campaign link:** Open the reengagement campaign link on the same device.
5. **Reinstall the app:** Install and open the app again on the device.
**Expected result:**
* In the **Retargeting view** on the dashboard, the reengagement campaign should show the **Reattribution** count incremented by 1.
## Troubleshooting
### Click count not increasing
* Confirm you opened the **campaign link on the same device** you plan to install on.
* Confirm domain setup is complete: [Domain settings](https://dashboard.linkrunner.io/settings?s=domains)
* If using Universal Links/App Links, verify deep link verification:
* [Configure verification objects](https://docs.linkrunner.io/sdk/deep-linking/verification)
### Install count not increasing
* Confirm the app was **uninstalled before the test**.
* Confirm you **clicked the link before opening the app**. A click that lands after the install is never matched to
it. The click stays eligible for the campaign's full attribution window (7 days by default, 30 for Google and
Meta).
* If you are using SDK Console, confirm the device is registered as a test device.
* If GAID collection is disabled on Android or the ATT prompt is disabled on iOS, set `debug = true` in SDK init and reopen the app.
### Signup count not increasing
* Confirm your app calls `signup` as soon as the user is identified (signup or login), and not before.
* Confirm you are passing the required user identifier fields per your SDK usage guide.
### Test device not showing in SDK Console
* Confirm the app with the Linkrunner SDK was opened on the test device.
* If GAID collection is disabled on Android, set `debug = true` in SDK init and reopen the app.
* If the ATT prompt is disabled on iOS, set `debug = true` in SDK init and reopen the app.
# Test Meta Android Attribution
Source: https://docs.linkrunner.io/testing/meta-android-attribution-testing
Verify that a fresh Android install from a Meta ad returns campaign, ad set, and creative data on the first attribution request
Use this flow to test Meta Install Referrer attribution on Android. A successful test returns the Meta campaign, ad set, and creative from `getAttributionData()` after the first app open.
A deferred deep link is not required for campaign attribution. It only controls the `deeplink` value returned by the SDK. Meta campaign data can be present while `deeplink` is empty.
## Before you start
Complete these requirements before testing:
* Configure your Meta App ID and Install Referrer Decryption Key in [Meta App Integration](/ad-networks/meta-ads#1-configure-meta-app-integration).
* Add your Facebook App ID to `AndroidManifest.xml`. See [Meta Install Referrer](/features/meta-install-referrer#configure-meta-install-referrer-support).
* Use a supported SDK version:
* Android SDK 3.5.2 or later
* Flutter SDK 3.6.2 or later
* React Native SDK 2.6.2 or later
* Use Facebook 428 or later, or Instagram 296 or later, on the test device.
* Make sure the Meta App ID in your app matches the Meta app used by the ad campaign.
* Make sure your Play Store release contains the Linkrunner SDK and Meta Install Referrer configuration.
* Implement `getAttributionData()` for your SDK: [Android](/sdk/android#getting-attribution-data), [React Native](/sdk/react-native#getting-attribution-data), or [Flutter](/sdk/flutter#getting-attribution-data).
## 1. Prepare a clean Android device
Record the device model and GAID. These values help Linkrunner identify the exact test requests if you need support.
Update Facebook or Instagram to a supported version, then sign in to the account that will receive the ad preview.
Remove your app before each test. This creates a new Linkrunner install instance when the app opens again.
Force-stopping or clearing the app from recents is not enough. Uninstall the app before every fresh-install test.
## 2. Send the Meta ad preview to the device
Use a published App Promotion ad or inject an ad preview into Facebook or Instagram.
In Meta Ads Manager, open the App Promotion ad and choose **Share → Share a link**.
Open the preview link while signed in to Facebook. Select the placement you want to test.
For Facebook Feed, choose **Options → Send notification to Facebook**. For Instagram Feed, choose **Options → Preview in Instagram app**.
Open the notification or preview in the Meta app. Tap the ad call to action from there.
Do not stop at the side-by-side browser preview. Open the injected ad inside Facebook or Instagram before tapping the call to action.
If you are also testing a deferred deep link, save and publish it on the exact ad used for the test. Then wait up to 30 minutes for Linkrunner's Meta creative sync. A value visible in the Ads Manager editor or preview does not confirm that Meta's API exposes it to Linkrunner.
See Meta's [ad preview instructions](https://www.facebook.com/business/help/788849608374258) and [Meta Install Referrer verification guide](https://developers.facebook.com/documentation/app-ads/meta-install-referrer) for the current Meta workflow.
## 3. Install and open your app
Tap the ad call to action and continue to your app's Google Play listing.
Install the Play Store build that contains your Linkrunner integration.
Launch the app immediately after installation. The Linkrunner SDK reads the Meta Install Referrer and sends the install to Linkrunner during initialization.
## 4. Call `getAttributionData()`
Wait for `init` to complete, then call `getAttributionData()` once for the new install.
```kotlin theme={null}
val result = LinkRunner.getInstance().getAttributionData()
result.onSuccess { attributionData ->
val campaign = attributionData.campaignData
println("Network: ${campaign.adNetwork}")
println("Campaign: ${campaign.name}")
println("Campaign ID: ${campaign.adNetworkCampaignId}")
println("Ad set: ${campaign.adSetName}")
println("Creative: ${campaign.adCreativeName}")
}.onFailure { error ->
println("Attribution error: ${error.message}")
}
```
```javascript theme={null}
const attributionData = await linkrunner.getAttributionData();
console.log("Network:", attributionData?.campaignData?.adNetwork);
console.log("Campaign:", attributionData?.campaignData?.name);
console.log("Campaign ID:", attributionData?.campaignData?.adNetworkCampaignId);
console.log("Ad set:", attributionData?.campaignData?.adSetName);
console.log("Creative:", attributionData?.campaignData?.adCreativeName);
```
```dart theme={null}
final attributionData = await LinkRunner().getAttributionData();
final campaign = attributionData?.campaignData;
print('Network: ${campaign?.adNetwork}');
print('Campaign: ${campaign?.name}');
print('Campaign ID: ${campaign?.adNetworkCampaignId}');
print('Ad set: ${campaign?.adSetName}');
print('Creative: ${campaign?.adCreativeName}');
```
For a successful Meta install, the first call after `init` completes should return `adNetwork: "META"` with the campaign details for that install.
## Expected response
The exact type depends on your SDK, but the result should contain these values:
```json theme={null}
{
"deeplink": "",
"campaignData": {
"id": "linkrunner-campaign-id",
"name": "Android App Promotion",
"type": "INORGANIC",
"adNetwork": "META",
"adNetworkCampaignId": "120000000000000001",
"adSetId": "120000000000000002",
"adSetName": "Android audience",
"adCreativeId": "120000000000000003",
"adCreativeName": "Android creative"
}
}
```
Confirm all of the following:
* `campaignData.adNetwork` is `META`.
* `campaignData.type` is `INORGANIC`.
* `campaignData.name` matches the Meta campaign.
* The Meta campaign, ad set, and creative IDs match Ads Manager.
* The first `getAttributionData()` call after the Meta install returns the campaign data.
An empty `deeplink` does not mean attribution failed. It means the ad did not provide a deferred deep link. Campaign attribution is verified through `campaignData`.
## Troubleshooting
### The first response is organic
* Confirm the app was uninstalled before you opened the injected ad.
* Confirm you tapped the ad inside Facebook or Instagram, not only in the browser preview.
* Confirm the Facebook App ID in `AndroidManifest.xml` matches the Meta app used by the campaign.
* Confirm the Install Referrer Decryption Key is correct in Linkrunner.
* Confirm `getAttributionData()` runs after `init` completes.
* Record the device model, GAID, app version, build number, and test timestamp before contacting support.
### Campaign data is present but `deeplink` is empty
This is expected when the ad has no deferred deep link. If you expected one:
* Confirm the value was saved and published on the exact ad returned in `campaignData`.
* Wait up to 30 minutes for Linkrunner's Meta creative sync before repeating the install test.
* Confirm the published ad, not only the Ads Manager editor or preview, exposes the deferred deep link.
See [Deferred Deep Linking](/ad-networks/meta-ads#5-deferred-deep-linking) for configuration details.
### The SDK cannot read Meta Install Referrer
* Update Facebook or Instagram on the device.
* Confirm at least one supported Meta app is installed.
* Confirm your app includes the Facebook App ID metadata.
* Confirm your SDK meets the minimum version requirement.
### The wrong campaign is returned
Uninstall the app and repeat the test from the intended ad. Do not reuse an earlier install instance. See [attribution testing troubleshooting](/testing/testing-attribution-troubleshooting).
**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
# Why Is My Test Showing Attribution to the Wrong Campaign?
Source: https://docs.linkrunner.io/testing/testing-attribution-troubleshooting
Fix the common issue where test installs or signups get attributed to a previously tested campaign link instead of the new one
## The Problem
You click a new campaign link and test a signup, but the dashboard attributes it to a **different campaign** — usually an older test link with a stale date.
## Why This Happens
The SDK generates a unique **install instance ID** on first launch and persists it on the device. All subsequent activity (installs, signups, events) is tied to this ID. When you test again **without uninstalling**, the SDK reuses the same ID and the backend attributes everything to the original campaign.
**No uninstall = same ID = attribution stuck on the first campaign.**
## How to Fix It
### 1. Uninstall the app before each test
This clears the stored install instance ID so a fresh one is generated on next launch.
Force-stopping the app is **not enough** — you must fully uninstall it.
### 2. Enable debug mode
Set `debug = true` in your SDK init to see logs confirming a fresh install.
```javascript theme={null}
await linkrunner.init("TOKEN", "SECRET", "KEY_ID", false, true);
```
```dart theme={null}
await LinkRunner().init('TOKEN', 'SECRET', 'KEY_ID', false, true);
```
```swift theme={null}
await LinkrunnerSDK.shared.initialize(
token: "TOKEN", secretKey: "SECRET", keyId: "KEY_ID",
disableIdfa: false, debug: true
)
```
```kotlin theme={null}
LinkRunner.getInstance().init(
context = applicationContext, token = "TOKEN",
secretKey = "SECRET", keyId = "KEY_ID", debug = true
)
```
### 3. Run the test flow
1. **Click** your new campaign link on the test device
2. **Install** and **open** the app — the click must land before the first app open
3. **Signup** (if testing signup attribution)
**Multiple tests?** Uninstall between each run for clean attribution.
## Still seeing issues?
* Confirm you clicked the link **on the same device** where the app is installed
* Confirm you **clicked the link before opening the app** — a click that lands after the install is never matched to it
* See the full [Integration Testing guide](/testing/integration-testing) for a detailed walkthrough
Need help? Reach out at [darshil@linkrunner.io](mailto:darshil@linkrunner.io).