> ## Documentation Index
> Fetch the complete documentation index at: https://docs.trygravity.ai/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. Two integration paths:

<CardGroup cols={2}>
  <Card title="Server-to-Server" icon="server">
    Your backend calls the Gravity Conversions API when a user installs your app. Full control over data and timing.
  </Card>

  <Card title="MMP Postback" icon="arrows-rotate">
    Your MMP (AppsFlyer, Adjust, etc.) fires postbacks to Gravity automatically. No code on your side.
  </Card>
</CardGroup>

Both paths 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 — 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: 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 `click_id` parameter:

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

Capture and persist it server-side:

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

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

### 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 `click_id` 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, click_id):
        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": {
                    "click_id": click_id,
                    "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 },
      clickId: 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: {
            click_id: clickId,
            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?,
            clickId: String
        ) {
            var userData: [String: Any] = [
                "click_id": clickId,
                "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,
            clickId: String,
        ) {
            Thread {
                val emailHash = email?.let {
                    sha256(it.trim().lowercase())
                }

                val userData = mutableMapOf<String, Any>(
                    "click_id" to clickId,
                    "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": {
            "click_id": stored_click_id,
            "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 `click_id`. Include the user's email hash — 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 2: MMP Postback

Best when you already use an MMP. No code changes on your side — configure Gravity as a partner in your MMP dashboard.

### 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="Adjust">
    ```
    https://conversions.trygravity.ai/mmp/postback/adjust
      ?click_id={click_id}
      &activity_kind={activity_kind}
      &created_at={created_at}
      &idfa={idfa}
      &ip_address={ip_address}
      &revenue={revenue}
      &currency={currency}
      &app_id={app_id}
      &country={country}
      &campaign_name={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 `click_id` 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 `click_id` from the original install to maintain the attribution chain.
  </Accordion>

  <Accordion title="S2S vs MMP — which should I use?">
    **S2S** gives you full control — you call the API from your backend with all available data. **MMP** is easier if you already use one — configure a URL template and the MMP handles the rest. Both 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>
