> ## Documentation Index
> Fetch the complete documentation index at: https://docs.trygravity.com/llms.txt
> Use this file to discover all available pages before exploring further.

# App install tracking

> Track iOS and Android app installs as Gravity conversions.

## Overview

Track app installs back to the Gravity ad that drove them. Three integration paths:

<CardGroup cols={3}>
  <Card title="Adjust" icon="plug" href="#option-1-adjust">
    Gravity is a dynamic network partner in Adjust. Send us your Adjust tracker link and event tokens; we return a link with the Gravity callbacks attached.
  </Card>

  <Card title="Other MMP postback" icon="arrows-rotate" href="#option-3-other-mmps-postback">
    AppsFlyer, Kochava, Branch, Singular. Point a postback URL at Gravity. No code on your side.
  </Card>

  <Card title="Server-to-Server" icon="server" href="#option-2-server-to-server">
    Your backend calls the Gravity Conversions API on install. Full control over data and timing.
  </Card>
</CardGroup>

All three write to the same conversion pipeline. Click-through and view-through attribution are supported.

***

## How attribution works

1. User sees or clicks a Gravity ad, and Gravity captures a unique click identifier
2. User lands on your website or App Store page
3. User installs and opens your app
4. The install is reported to Gravity (via your backend or MMP) with the click identifier
5. Gravity attributes the install back to the originating campaign and ad

<Note>
  Gravity attribution does not depend on IDFA, so this works with iOS 14.5+ App Tracking Transparency restrictions.
</Note>

***

## Option 1: Adjust

Gravity is a **dynamic network partner** in Adjust. Adjust does not fire callbacks on its own: the callbacks are carried as parameters on the tracker link itself, and Adjust fires them when the install or event happens. There is no Data Sharing screen for Gravity, and Adjust will not prompt you for a Gravity API key. The callbacks carry no key either: they authenticate with the Gravity click ID Adjust echoes back.

<Note>
  Share **in-app events**, not just installs. Gravity optimizes toward whatever you send it, so if all we see is installs, that's all we can optimize for. Each in-app event needs its own callback on the link (see step 3).
</Note>

### Before you start

* Adjust access to Campaign Lab
* The Adjust **event token** for every in-app event you want Gravity to see (Adjust → App → Events)
* A live Gravity campaign with an ad group (you'll set the final link as its landing page in step 4)

### Setup

<Steps>
  <Step title="Create the tracker link in Adjust">
    In **Campaign Lab**, create a link for the Gravity network on the app you're advertising (iOS and Android are separate apps, so you'll have one link each). Copy the full click URL, an `https://app.adjust.com/...` or `https://<yourapp>.go.link/...` URL. Not the App Store URL.
  </Step>

  <Step title="Send Gravity the link and your event tokens">
    Send your Gravity contact the tracker link(s) plus the event token for each in-app event (trial, signup, purchase, ...). We attach the Gravity callbacks and send back the final link. If you'd rather do it yourself, the exact parameters are in [Build the link yourself](#build-the-link-yourself) below.
  </Step>

  <Step title="Check the final link">
    The link you get back has one `install_callback=` parameter and one `event_callback_<event_token>=` parameter per in-app event (on a `go.link` branded link these are `adj_install_callback=` and `adj_event_callback_<event_token>=`). Each callback is a URL-encoded copy of the Gravity postback URL. It carries no API key: Gravity accepts the callback because `click_id` is a click ID Gravity itself issued. A callback whose `click_id` is unknown is rejected with `401` and nothing is recorded.
  </Step>

  <Step title="Set it as your ad group landing page">
    Paste the **final link as the landing page on your Gravity ad group**. Ads have to point at the Adjust link: on every click Gravity appends its click ID as `click_id` (and `label`) to the link, Adjust stores it, and the callbacks echo it back so the install and events attribute to the ad.
  </Step>
</Steps>

### Build the link yourself

Append these to your Adjust tracker link. Each callback value is the full Gravity URL, URL-encoded once.

Gravity callback (same for install and events):

```
https://conversions.trygravity.ai/mmp/postback/adjust
  ?click_id={click_id}
  &activity_kind={activity_kind}
  &event_name={event_name}
  &created_at={created_at}
  &idfa={idfa}
  &gps_adid={gps_adid}
  &ip_address={ip_address}
  &revenue={revenue}
  &currency={currency}
  &app_id={app_id}
  &country={country}
  &campaign_name={campaign_name}
```

Do not put your Gravity API key in the callback. The tracker link is public (it's in every ad click), so anything in it is visible to anyone.

Tracker link with callbacks attached (one `event_callback_` per event token):

```
https://app.adjust.com/abc123
  ?install_callback=<encoded Gravity callback>
  &event_callback_k9x2ab=<encoded Gravity callback>
  &event_callback_p7m4qz=<encoded Gravity callback>
```

On a branded / universal link (`https://<yourapp>.go.link/...`) Adjust requires the `adj_` prefix on every parameter, otherwise it ignores them:

```
https://yourapp.go.link/abc123
  ?adj_install_callback=<encoded Gravity callback>
  &adj_event_callback_k9x2ab=<encoded Gravity callback>
```

Keep the `{...}` placeholders as-is; Adjust fills them when it fires the callback. `{click_id}` is the Gravity click ID we append on every click. `{event_name}` is what tells Gravity which in-app event fired; without it every event arrives as a generic `event`.

### Event mapping

| Adjust `event_name` | Gravity event type | Why it matters |
| - | - | - |
| `install` (from `activity_kind`) | `app_install` | Baseline: volume only, no quality signal |
| `session` / `re_engagement` | `app_open` | Retention signal |
| `registration` / `signup` | `complete_registration` | First real intent signal; best default for freemium apps |
| `purchase` / `in_app_purchase` | `in_app_purchase` | Optimizes toward revenue when `revenue` + `currency` are present |
| `subscribe` | `subscribe` | Best signal for subscription apps |
| `add_to_cart` | `add_to_cart` | Mid-funnel signal |
| `lead` | `lead` | For lead-gen apps |

Any other event name is passed through as-is (lowercased, spaces and dashes become `_`), so custom events like `trial_started` still land. They just won't be normalized into one of the types above.

### Verify

Install the app from a real Gravity ad click and confirm the conversion appears in the Gravity dashboard as attributed. To test without a live campaign, follow [Adjust's callback test steps](https://help.adjust.com/en/article/test-your-callbacks-partner): append your device's advertising ID to the final link, click it through a Gravity ad so `click_id` is real, install and open the app, then trigger the events. To smoke-test the endpoint directly, pass your API key (only in this manual call, never on the link):

```bash theme={null}
curl "https://conversions.trygravity.ai/mmp/postback/adjust\
?click_id=test-click-123\
&activity_kind=event\
&event_name=trial_started\
&created_at=$(date +%s)\
&app_id=com.example.app\
&api_key=YOUR_API_KEY"
```

```json theme={null}
{
  "status": "ok",
  "conversion_id": "uuid",
  "attributed": false,
  "event_type": "trial_started",
  "provider": "adjust"
}
```

`attributed: false` is expected for a fake click ID. Without `api_key`, the same fake click ID returns `401 Unknown click_id`.

### Troubleshooting

| Symptom | Cause | Fix |
| - | - | - |
| `401 Unknown click_id` / everything unattributed | Gravity's click ID isn't reaching the callback | The ad group landing page must be the Adjust link (`*.adjust.com` or `*.go.link`), and the callback must include `click_id={click_id}`. Clicks older than 7 days are also rejected |
| Installs show up but nothing else | No `event_callback_<token>` on the link | Add one `event_callback_<event_token>=` per in-app event; Adjust only fires callbacks that are on the link |
| Nothing arrives from a `go.link` link | Callback params lack the `adj_` prefix | Use `adj_install_callback=` / `adj_event_callback_<token>=` on branded and universal links |
| Every in-app event shows as `event` | Callback is missing `event_name={event_name}` | Add it to the callback and rebuild the link |
| Purchases attribute but revenue is `0` | Callback is missing `revenue={revenue}&currency={currency}` | Add them to the callback and rebuild the link |
| `status: "duplicate"` | Same click ID + event type inside the dedup window | Expected for retried postbacks; no action needed |

***

## Option 2: Server-to-Server

Best when you control the app backend and can capture the click identifier from the ad click URL.

### Step 1: Capture the click identifier on your landing page

When a user arrives from a Gravity ad, the URL includes a `grclid` parameter:

```
https://yourapp.com/download?grclid=abc-123
```

Capture and persist it server-side:

```javascript theme={null}
const params = new URLSearchParams(window.location.search);
const grclid = params.get('grclid');

// Store server-side (preferred) or in a first-party cookie
fetch('/api/store-attribution', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ grclid }),
});
```

### Step 2: Pass the click identifier through your deep link

If you use a deep link provider (Branch, Firebase Dynamic Links, or a custom universal link), pass the `grclid` as a custom parameter so it survives the Web → App Store → App flow.

### Step 3: Report the install

When the user opens your app for the first time, fire a conversion from your backend.

<Tabs>
  <Tab title="Python">
    ```python theme={null}
    import requests
    import hashlib
    import time

    API_KEY = "your-gravity-api-key"
    GATEWAY_URL = "https://conversions.trygravity.ai/gateway/events"

    def report_app_install(user, grclid):
        email_hash = hashlib.sha256(
            user["email"].strip().lower().encode()
        ).hexdigest() if user.get("email") else None

        payload = {
            "data": [{
                "event_name": "AppInstall",
                "event_time": int(time.time()),
                "event_id": f"install-{user['id']}-{int(time.time())}",
                "action_source": "app",
                "user_data": {
                    "grclid": grclid,
                    "em": [email_hash] if email_hash else None,
                    "external_id": [str(user["id"])],
                },
                "custom_data": {
                    "value": 0,
                    "currency": "USD",
                }
            }]
        }

        return requests.post(
            GATEWAY_URL,
            json=payload,
            headers={"Authorization": f"Bearer {API_KEY}"},
            timeout=10,
        ).json()
    ```
  </Tab>

  <Tab title="Node.js">
    ```typescript theme={null}
    import crypto from 'crypto';

    const API_KEY = 'your-gravity-api-key';
    const GATEWAY_URL = 'https://conversions.trygravity.ai/gateway/events';

    async function reportAppInstall(
      user: { id: string; email?: string },
      grclid: string,
    ) {
      const hashPii = (val: string) =>
        crypto.createHash('sha256')
          .update(val.trim().toLowerCase())
          .digest('hex');

      const payload = {
        data: [{
          event_name: 'AppInstall',
          event_time: Math.floor(Date.now() / 1000),
          event_id: `install-${user.id}-${Date.now()}`,
          action_source: 'app',
          user_data: {
            grclid,
            em: user.email ? [hashPii(user.email)] : undefined,
            external_id: [user.id],
          },
          custom_data: { value: 0, currency: 'USD' },
        }],
      };

      const resp = await fetch(GATEWAY_URL, {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${API_KEY}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(payload),
      });

      return resp.json();
    }
    ```
  </Tab>

  <Tab title="Swift">
    ```swift theme={null}
    import Foundation
    import CryptoKit

    struct GravityInstallReporter {
        static let apiKey = "your-gravity-api-key"
        static let gatewayURL = URL(
            string: "https://conversions.trygravity.ai/gateway/events"
        )!

        static func reportInstall(
            userId: String,
            email: String?,
            grclid: String
        ) {
            var userData: [String: Any] = [
                "grclid": grclid,
                "external_id": [userId],
            ]

            if let email {
                let normalized = email
                    .lowercased()
                    .trimmingCharacters(in: .whitespaces)
                let hash = SHA256.hash(data: Data(normalized.utf8))
                userData["em"] = [hash.map {
                    String(format: "%02x", $0)
                }.joined()]
            }

            let event: [String: Any] = [
                "event_name": "AppInstall",
                "event_time": Int(Date().timeIntervalSince1970),
                "event_id": "install-\(userId)-\(Int(Date().timeIntervalSince1970))",
                "action_source": "app",
                "user_data": userData,
                "custom_data": ["value": 0, "currency": "USD"],
            ]

            let payload: [String: Any] = ["data": [event]]

            var request = URLRequest(url: gatewayURL)
            request.httpMethod = "POST"
            request.setValue(
                "Bearer \(apiKey)",
                forHTTPHeaderField: "Authorization"
            )
            request.setValue(
                "application/json",
                forHTTPHeaderField: "Content-Type"
            )
            request.httpBody = try? JSONSerialization.data(
                withJSONObject: payload
            )

            URLSession.shared.dataTask(with: request) { _, _, _ in }
                .resume()
        }
    }
    ```
  </Tab>

  <Tab title="Kotlin">
    ```kotlin theme={null}
    import java.net.HttpURLConnection
    import java.net.URL
    import java.security.MessageDigest

    object GravityInstallReporter {
        private const val API_KEY = "your-gravity-api-key"
        private const val GATEWAY_URL =
            "https://conversions.trygravity.ai/gateway/events"

        fun reportInstall(
            userId: String,
            email: String? = null,
            grclid: String,
        ) {
            Thread {
                val emailHash = email?.let {
                    sha256(it.trim().lowercase())
                }

                val userData = mutableMapOf<String, Any>(
                    "grclid" to grclid,
                    "external_id" to listOf(userId),
                )
                emailHash?.let { userData["em"] = listOf(it) }

                val event = mapOf(
                    "event_name" to "AppInstall",
                    "event_time" to (System.currentTimeMillis() / 1000),
                    "event_id" to "install-$userId-${System.currentTimeMillis()}",
                    "action_source" to "app",
                    "user_data" to userData,
                    "custom_data" to mapOf(
                        "value" to 0, "currency" to "USD"
                    ),
                )

                val payload = """{"data":[${
                    org.json.JSONObject(event)
                }]}"""

                val conn = URL(GATEWAY_URL).openConnection()
                    as HttpURLConnection
                conn.requestMethod = "POST"
                conn.setRequestProperty(
                    "Authorization", "Bearer $API_KEY"
                )
                conn.setRequestProperty(
                    "Content-Type", "application/json"
                )
                conn.doOutput = true
                conn.outputStream.write(payload.toByteArray())
                conn.responseCode
                conn.disconnect()
            }.start()
        }

        private fun sha256(input: String): String {
            val bytes = MessageDigest.getInstance("SHA-256")
                .digest(input.toByteArray())
            return bytes.joinToString("") { "%02x".format(it) }
        }
    }
    ```
  </Tab>
</Tabs>

### Post-install events

Track in-app purchases and other downstream events with the same pattern:

```python theme={null}
payload = {
    "data": [{
        "event_name": "InAppPurchase",
        "event_time": int(time.time()),
        "event_id": f"purchase-{order_id}",
        "action_source": "app",
        "user_data": {
            "grclid": stored_grclid,
            "em": [email_hash],
            "external_id": [user_id],
        },
        "custom_data": {
            "value": 29.99,
            "currency": "USD",
            "order_id": order_id,
            "content_name": "Premium Subscription",
        }
    }]
}
```

### App event types

| Event Name | Internal Type | When to fire |
| - | - | - |
| `AppInstall` | `app_install` | First app open after install |
| `AppOpen` | `app_open` | Subsequent app opens (re-engagement) |
| `InAppPurchase` | `in_app_purchase` | In-app purchase completed |
| Any custom string | Lowercased | Custom app events |

### View-through attribution (no click)

If the user installs without clicking an ad (e.g., they saw the ad but went to the App Store directly), send the event without a `grclid`. Include the user's email hash and Gravity will attempt to attribute the install to a recent ad impression.

```python theme={null}
payload = {
    "data": [{
        "event_name": "AppInstall",
        "event_time": int(time.time()),
        "event_id": f"install-{user_id}",
        "action_source": "app",
        "user_data": {
            "em": [email_hash],
            "client_ip_address": user_ip,
            "external_id": [user_id],
        },
        "custom_data": { "value": 0, "currency": "USD" }
    }]
}
```

***

## Option 3: Other MMPs (postback)

For MMPs other than Adjust, configure a postback URL in the MMP's dashboard. On Adjust the callbacks go on the tracker link instead, see [Option 1](#option-1-adjust).

### Supported MMPs

| MMP | Postback URL |
| - | - |
| **AppsFlyer** | `https://conversions.trygravity.ai/mmp/postback/appsflyer` |
| **Adjust** | `https://conversions.trygravity.ai/mmp/postback/adjust` |
| **Kochava** | `https://conversions.trygravity.ai/mmp/postback/kochava` |
| **Branch** | `https://conversions.trygravity.ai/mmp/postback/branch` |
| **Singular** | `https://conversions.trygravity.ai/mmp/postback/singular` |
| **Other** | `https://conversions.trygravity.ai/mmp/postback?provider=<name>` |

### Setup

<Steps>
  <Step title="Configure your click URL">
    In your MMP's partner configuration for Gravity, ensure that the click identifier from Gravity's ad URL is captured by the MMP as a custom parameter. Most MMPs call this `clickid`, `click_id`, or `label`.
  </Step>

  <Step title="Add the postback URL">
    In your MMP dashboard, add Gravity as a postback partner. Use the URL template for your MMP (see examples below).
  </Step>

  <Step title="Test the integration">
    Send a test postback to verify:

    ```bash theme={null}
    curl "https://conversions.trygravity.ai/mmp/postback/appsflyer\
    ?clickid=test-click-123\
    &event_name=install\
    &event_time=$(date +%s)\
    &ip=203.0.113.50\
    &app_id=com.example.app\
    &api_key=YOUR_API_KEY"
    ```
  </Step>
</Steps>

### Postback URL templates

<Tabs>
  <Tab title="AppsFlyer">
    ```
    https://conversions.trygravity.ai/mmp/postback/appsflyer
      ?clickid={clickid}
      &event_name={event_name}
      &event_time={event_time}
      &idfa={idfa}
      &ip={ip}
      &revenue={event_revenue}
      &currency={event_revenue_currency}
      &app_id={app_id}
      &country_code={country_code}
      &campaign={campaign_name}
      &api_key=YOUR_GRAVITY_API_KEY
    ```
  </Tab>

  <Tab title="Kochava">
    ```
    https://conversions.trygravity.ai/mmp/postback/kochava
      ?click_id={click_id}
      &event_name={event_name}
      &event_timestamp={event_timestamp}
      &idfa={idfa}
      &device_ip={device_ip}
      &revenue={revenue}
      &currency={currency}
      &app_id={app_id}
      &country_code={country_code}
      &campaign_name={campaign_name}
      &api_key=YOUR_GRAVITY_API_KEY
    ```
  </Tab>

  <Tab title="Branch">
    ```
    https://conversions.trygravity.ai/mmp/postback/branch
      ?click_id={click_id}
      &event_name={event_name}
      &timestamp={timestamp}
      &ip={ip}
      &revenue={revenue}
      &revenue_currency={revenue_currency}
      &api_key=YOUR_GRAVITY_API_KEY
    ```
  </Tab>

  <Tab title="Singular">
    ```
    https://conversions.trygravity.ai/mmp/postback/singular
      ?click_id={click_id}
      &event_name={event_name}
      &timestamp={timestamp}
      &idfa={idfa}
      &ip={ip}
      &revenue={revenue}
      &currency={currency}
      &campaign_name={campaign_name}
      &api_key=YOUR_GRAVITY_API_KEY
    ```
  </Tab>
</Tabs>

### MMP event mapping

The postback receiver normalizes common MMP event names:

| MMP Event | Gravity Event Type |
| - | - |
| `install`, `attributed_install`, `first_open` | `app_install` |
| `session`, `re_engagement`, `app_open` | `app_open` |
| `purchase`, `in_app_purchase`, `af_purchase` | `in_app_purchase` |
| `registration`, `signup` | `complete_registration` |
| Custom events | Passed through as-is (lowercased) |

***

## Response format

Both paths return the same structure:

```json theme={null}
{
  "status": "ok",
  "conversion_id": "uuid",
  "attributed": true,
  "event_type": "app_install",
  "provider": "appsflyer"
}
```

| Status | Meaning |
| - | - |
| `ok` | Processed and stored |
| `duplicate` | Already seen (deduplicated) |
| `test_ok` | Test mode: validated but not stored |
| `error` | Processing failed |

***

## FAQ

<AccordionGroup>
  <Accordion title="Do I need IDFA for attribution?">
    No. Gravity does not depend on IDFA for attribution. This works with iOS 14.5+ App Tracking Transparency restrictions. If the user consented to tracking and you have IDFA, you can include it for additional signal, but it's not required.
  </Accordion>

  <Accordion title="What if the user doesn't click the ad?">
    Send the conversion without `grclid` but include the user's email hash (`em`). Gravity will attempt to attribute the install to a recent ad impression.
  </Accordion>

  <Accordion title="Can I track post-install events?">
    Yes. Use `InAppPurchase` (or any custom event name) with `action_source: "app"`. Include `custom_data.value` for revenue attribution. Use the same `grclid` from the original install to maintain the attribution chain.
  </Accordion>

  <Accordion title="Which option should I use?">
    On **Adjust**, send us your tracker link and event tokens and set the link we return as your ad group landing page. On another **MMP**, configure the postback URL in the MMP dashboard. **S2S** gives you full control if you'd rather call the API from your backend with all available data. All three feed into the same conversion pipeline.
  </Accordion>
</AccordionGroup>

## Next

<CardGroup cols={2}>
  <Card title="Pixel & web conversions" icon="eye" href="/advertisers/pixel">
    Track web conversions (purchases, signups, etc.).
  </Card>

  <Card title="Conversions API" icon="code" href="/advertisers/server-conversions">
    Send web conversions from your backend.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.