Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions ad-networks/google-ads-discrepancies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Walk through this checklist.

<Steps>
<Step title="Confirm the Google Ads integration is active in Linkrunner">
Open [Settings → Google Ads integration](https://dashboard.linkrunner.io/settings?s=google-ads-integration) and verify the Link ID is saved for the correct platform (Android or iOS).
Open [Integrations → Google Ads](https://dashboard.linkrunner.io/dashboard/integrations/google-ads?p_id=89), open the three-dot menu on the Google Ads row, and verify the link ID is saved for the correct platform (Android or iOS).
</Step>
<Step title="Import first_open in Google Ads">
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).
Expand Down Expand Up @@ -80,6 +80,7 @@ Google is a Self-Reporting Network (SRN), so Google Ads reports its own attribut

- **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.
</Accordion>
Expand All @@ -98,7 +99,7 @@ Google Ads dates conversions to the **ad click**. Linkrunner dates them to the *
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.
</Card>
<Card title="iOS (SKAN)" icon="apple">
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.
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.
</Card>
</CardGroup>

Expand Down
14 changes: 10 additions & 4 deletions ad-networks/google-ads.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,10 @@ To connect your mobile app with Google Ads, you'll need to create a link ID that
### Step 3: Configure in Linkrunner Dashboard

1. Copy the received link ID from Google Ads
2. Navigate to your Linkrunner Dashboard
3. Go to [Settings → Google Ads integration](https://dashboard.linkrunner.io/settings?s=google-ads-integration)
4. Paste the link ID in the designated field
5. Save your configuration
2. Go to [Integrations → Google Ads](https://dashboard.linkrunner.io/dashboard/integrations/google-ads?p_id=89) in your Linkrunner dashboard
3. Open the three-dot menu on the Google Ads row and choose the link IDs option
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**

![Google Ads Dashboard - Link ID Created](/images/google_ads/google-ads-dashboard-3.webp)

Expand Down Expand Up @@ -134,6 +134,12 @@ After creating the link ID and mapping events in Linkrunner, you can import app
events.
</Tip>

<Note>
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.
</Note>

## 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).
Expand Down
2 changes: 2 additions & 0 deletions docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@
"features/uninstall-tracking",
"features/retention",
"features/meta-install-referrer",
"features/google-icm",
"features/send-consent",
"features/ios-campaign-data-limitations",
"features/webhooks",
"features/mcp",
Expand Down
120 changes: 120 additions & 0 deletions features/google-icm.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
---
title: "Google Integrated Conversion Measurement"
description: "Recover Google App Campaign attribution on iOS when no click identifier or advertising ID is available"
icon: "google"
sidebarTitle: "Google ICM"
---

## 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.

<Warning>
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.
</Warning>

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.

<Note>
ICM complements SKAdNetwork, it does not replace it. Keep your [SKAdNetwork integration](/features/skadnetwork-integration) in place.
</Note>

## Prerequisites

Four things have to be in place. ICM stays inactive until all four are done, and it fails quietly rather than loudly, so work through them in order.

<Steps>
<Step title="Update to an ICM-capable SDK version">
ICM shipped in the 4.1.0 line of the native SDKs. Anything older collects no `odm_info` and has no consent API.

| Platform | Minimum version |
| --- | --- |
| Android | `io.linkrunner:android-sdk:4.1.0` |
| iOS | LinkrunnerKit 4.1.0 |
| React Native | `rn-linkrunner` 3.1.0 |
| Flutter | `linkrunner` 4.1.0 |
| Expo | `rn-linkrunner` 3.1.0 |
| Unity | LinkrunnerKit 4.1.0 and `io.linkrunner:android-sdk:4.1.0` |
</Step>

<Step title="Create an iOS link ID in Google Ads">
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?p_id=89), open the three-dot menu on the Google Ads row, 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).
</Step>

<Step title="Add Google's ODM SDK to your iOS 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.
</Step>

<Step title="Report consent with setConsent">
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.
</Step>
</Steps>

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 |

<Note>
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.
</Note>

## 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

<CardGroup cols={2}>
<Card title="Android" icon="android" href="/sdk/android#google-ads-consent">
Report consent for the App Conversion API
</Card>
<Card title="iOS" icon="apple" href="/sdk/ios#google-integrated-conversion-measurement-optional">
Add the ODM SDK and report consent
</Card>
<Card title="React Native" icon="react" href="/sdk/react-native#google-integrated-conversion-measurement-optional">
Add the pod and report consent
</Card>
<Card title="Flutter" icon="flutter" href="/sdk/flutter#google-integrated-conversion-measurement-optional">
Add the pod and report consent
</Card>
</CardGroup>

**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
179 changes: 179 additions & 0 deletions features/send-consent.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
---
title: "Send Consent"
description: "Report DMA consent signals with your installs and events"
icon: "user-shield"
sidebarTitle: "Send Consent"
---

## 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.

<Note>
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.
</Note>

## 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`.

<Note>
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.
</Note>

## What to report

<Warning>
**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.
</Warning>

<Tabs>
<Tab title="User outside the EEA, UK, Switzerland">
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
LinkRunner.getInstance().setConsent(
LinkrunnerConsent(isEEA = ConsentStatus.DENIED)
)
```

The two omitted fields default to `UNKNOWN` and are dropped from the payload.
</Tab>

<Tab title="User inside the EEA, UK, Switzerland">
Report all three from the answers your consent screen actually collected.

```kotlin
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.
</Tab>

<Tab title="You do not know yet">
Leave the signal `unknown`, which is what it is before you set anything. Linkrunner omits it.

```kotlin
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.
</Tab>
</Tabs>

<Warning>
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.
</Warning>

## 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`.

<Steps>
<Step title="Ask the user">
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.
</Step>

<Step title="Determine whether the user is in scope">
Most CMPs detect this for you. Otherwise resolve it server side from the IP address. If you cannot resolve it, leave `isEEA` unknown.
</Step>

<Step title="Call setConsent before you initialize">
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.

<Tabs>
<Tab title="Android">
```kotlin
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
)
)
```
</Tab>
<Tab title="iOS">
```swift
LinkrunnerSDK.shared.setConsent(
LinkrunnerConsent(
isEEA: .granted,
hasConsentForDataUsage: .granted,
hasConsentForAdsPersonalization: .denied
)
)
```
</Tab>
<Tab title="React Native">
```javascript
linkrunner.setConsent({
isEEA: "granted",
hasConsentForDataUsage: "granted",
hasConsentForAdsPersonalization: "denied",
});
```
</Tab>
<Tab title="Flutter">
```dart
await LinkRunner().setConsent(
LRConsent(
isEEA: ConsentStatus.GRANTED,
hasConsentForDataUsage: ConsentStatus.GRANTED,
hasConsentForAdsPersonalization: ConsentStatus.DENIED,
),
);
```
</Tab>
<Tab title="Expo">
```javascript
linkrunner.setConsent({
isEEA: "granted",
hasConsentForDataUsage: "granted",
hasConsentForAdsPersonalization: "denied",
});
```
</Tab>
<Tab title="Unity">
```csharp
LinkrunnerSDK.SetConsent("granted", "granted", "denied");
```
</Tab>
</Tabs>
</Step>

<Step title="Call it again whenever the choice changes">
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.
</Step>
</Steps>

**Need help?** Contact [support@linkrunner.io](mailto:support@linkrunner.io)
Loading